diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..20bb515 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Kopie als .env anlegen und ausfüllen. .env ist in .gitignore — Keys gehören +# niemals ins Repo. + +# --- Musikgenerierung --------------------------------------------------- + +# ElevenLabs Music — vorgesehener Startadapter (siehe docs/APIS.md) +ELEVENLABS_API_KEY= + +# Suno — aktuell KEINE öffentliche API verfügbar (Stand August 2026). +# Platzhalter für den Fall, dass die Bewerbung beim Partner-Programm +# durchgeht. Bis dahin leer lassen. +SUNO_API_KEY= + +# Stable Audio (Stability AI) — Alternative für instrumentale Texturen +STABILITY_API_KEY= + +# --- Samples ------------------------------------------------------------ + +# Freesound APIv2 — kostenlos für nicht-kommerzielle Nutzung +FREESOUND_API_KEY= + +# --- Lokale Pfade ------------------------------------------------------- + +# Wohin generierte Tracks geschrieben werden +GENERATED_DIR=./generated + +# Bestehende Traktor-Sammlung für den Import (docs/TRAKTOR-REFERENZ.md) +# TRAKTOR_COLLECTION_PATH= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bc54275 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + name: fmt, clippy, tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # CPAL bindet unter Linux gegen ALSA. + - name: ALSA-Header installieren + run: | + sudo apt-get update + sudo apt-get install -y libasound2-dev + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Formatierung + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Tests + run: cargo test --all + + # Das Freigabe-Gatter ist Python und braucht keine laufende Anlage — + # geprüft wird das Gatter, nicht die Anwendung. Ohne diesen Schritt wären + # seine Wächter Dekoration: Sie liefen nur, wenn jemand daran denkt. + - name: Freigabe-Gatter + run: | + python3 -m pip install --quiet pytest + python3 -m pytest -q mcp/musik/tests/test_freigabe.py diff --git a/.gitignore b/.gitignore index a111428..045848a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,12 @@ .env.* !.env.example +# Rust — Cargo.lock wird bewusst eingecheckt (Anwendung, keine Bibliothek) +/target/ + +# Analyse-Sidecars (aus dem Audio reproduzierbar) +.musik-analyse/ + # Node node_modules/ dist/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4bd2611 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4911 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags 2.13.1", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "analysis" +version = "0.1.0" +dependencies = [ + "audio-core", + "base64", + "blake3", + "rustfft", + "serde", + "serde_json", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.20", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "audio-core" +version = "0.1.0" +dependencies = [ + "cpal", + "symphonia", + "thiserror 2.0.20", +] + +[[package]] +name = "audio-engine" +version = "0.1.0" +dependencies = [ + "audio-core", + "cpal", + "rtrb", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "blake3" +version = "1.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "control" +version = "0.1.0" +dependencies = [ + "audio-core", + "audio-engine", + "getrandom 0.3.4", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags 2.13.1", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f77b11176c37874be37e8d691c946e31b2b8c357abce9526f6a99eb469e1028" +dependencies = [ + "alsa", + "block2 0.6.2", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.4", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "web-sys", + "windows", + "windows-core", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6758be723a3f298bbfda4db75748bc2ba0abafe096b6383c7c32da264764fbc3" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc8234e2f681b2afd2b2e8c332fa614de2fddbdcb28d0acf5c420449682ea90" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "pollster", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796c98d50b79631281d516343a6f6e93c0666462ca36e2c93b39f25d7793325" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.1", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", +] + +[[package]] +name = "egui-wgpu" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e6cfac0725563555fa4f91e9f799b9d7c6c5dd831fca6abc8234afc64b7a34" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.20", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea6bf3608db949588b95b8b341ee358d0c3f95cf4dc3f53d8d76717edee87db" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52274b9bfb8d8e252cd0f00c9f6214f360d75a0962baa77a2d62ddb002fe99d4" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "memoffset", + "profiling", + "winit", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "emath" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4ec073c9898516584d8c6cfdcee95b530b3d941cd5031ef4050aa36812308b" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e60a8888b51da911df23918fd7301359b1d43a406a0ff3b8863af093dd7fc6c" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ee4e1f553a3584c301f3a56ff1a775f1384781396cea301c8d952e9b93f560" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.1", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.20", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0431e8e389aa0f1e72bb9d1c2db8957a1a7a3580e8ed97db819c14837aac9b3e" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "library" +version = "0.1.0" +dependencies = [ + "audio-core", + "quick-xml", + "rusqlite", + "thiserror 2.0.20", +] + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "musik-app" +version = "0.1.0" +dependencies = [ + "analysis", + "anyhow", + "audio-core", + "audio-engine", + "control", + "eframe", + "egui", + "image", + "library", +] + +[[package]] +name = "musik-cli" +version = "0.1.0" +dependencies = [ + "analysis", + "anyhow", + "audio-core", + "audio-engine", + "control", + "library", +] + +[[package]] +name = "naga" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.1", + "libc", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "dispatch2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.20", +] + +[[package]] +name = "rtrb" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153" + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.20", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "symphonia" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee69ad01236a67260b82fd1ff9790dd75ead29f2f46af145e63b7e72273e0e03" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76" +dependencies = [ + "lazy_static", + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50baee168f0e9dcf6ba7fc06e8b57eb62072a4490cc7cf13af77e72baae5d328" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45b07b4423cd8e0fc472575909a5554b12c2f58e3c190b38c24f042e732fd8de" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-common" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "lazy_static", + "log", + "num-complex", + "smallvec", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681" +dependencies = [ + "lazy_static", + "log", + "regex-lite", + "smallvec", + "symphonia-core", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "smallvec", + "thiserror 2.0.20", +] + +[[package]] +name = "vello_cpu" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "588691169aed86b5c8fb487266afee01323234e6fd0a3f2aaec0eaa8e4007f23" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.20", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1fb1798be2a912497d4c224f72d39bb0cb34af50e8bcc29865bc339c943059" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.1", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "ndk-sys", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.20", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "js-sys", + "log", + "raw-window-handle", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..811a407 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] +resolver = "2" +members = ["crates/analysis","crates/audio-core", "crates/audio-engine", "crates/control", "crates/library", "crates/musik-app", "crates/musik-cli"] + +[workspace.package] +edition = "2021" +publish = false + +[workspace.dependencies] +analysis = { path = "crates/analysis" } +audio-core = { path = "crates/audio-core" } +audio-engine = { path = "crates/audio-engine" } +library = { path = "crates/library" } diff --git a/README.md b/README.md index 52afa9f..64b4f3a 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,450 @@ # Musik -Traktor-artiges DJ-/Musik-Tool. Decks, Mixer, Library — mit dem Ziel, später die -[Suno API](https://suno.com) für KI-Musikgenerierung direkt in den Workflow einzubinden. +Traktor-artiges DJ-/Musik-Tool, das generative Musik als gleichberechtigte +Quelle behandelt — mit dem Fernziel eines Agenten-Teams, das Musik aus den +Reaktionen der Crowd formt. -Status: **Konzeptphase** — noch kein Code, nur Zielbild und Roadmap. +Status: **neun von zehn Phasen gebaut.** Decks mit Keylock, Hot Cues und +Loops; Mixer mit EQ, Filter, Crossfader, Cue-Bus und AUX; Analyse für Tempo, +Beatgrid, Tonart, Wellenform **und die Gliederung eines Tracks in Intro, +Aufbau, Drop, Break und Outro**; Sync über Tempo *und* Phase; Sammlung mit +Traktor-Import; Oberfläche mit zwei Decks, Wellenform, Beatgrid und +Plattenkiste; vier Effekte hinter dem Fader; **Fernsteuerung über einen +selbstbeschreibenden Steuerraum**; Mitschnitt der Summe — und daneben eine +**Mitschrift**, die festhält, was gemeint war, damit `musik-kritik` das Set +hinterher nachmessen kann statt zu raten. Nativ in Rust. Was fehlt, sind Stems +und MIDI. + +![Die Oberfläche](docs/bilder/oberflaeche.png) ## Zielbild -Ein DJ-Werkzeug, das sich wie Traktor bedienen lässt, aber generative Musik als -erstklassige Quelle behandelt: Ein Deck kann einen Track aus der Library laden — -oder einen, der gerade erst per Prompt entsteht. +Drei Ebenen, aufeinander aufbauend: + +1. **Ein DJ-Werkzeug**, das sich wie Traktor bedienen lässt — Decks, Mixer, + Beatgrid, Library. +2. **Generierte Tracks als normale Quelle.** Ein Deck unterscheidet nicht + zwischen einer Datei von der Platte und einem Track, der gerade per Prompt + entstanden ist. +3. **Ein Agenten-Team, das die Crowd liest.** Die Menge schreibt keine Noten, + aber ihre Reaktion wird Eingabe in Auswahl, Übergang und Komposition. + +Ebene 1 ist die Voraussetzung für alles andere. Ohne ein Tool, das mixt, gibt +es nichts, was Agenten steuern könnten. + +## Dokumentation + +| Dokument | Inhalt | +| --- | --- | +| **[docs/PLAN.md](docs/PLAN.md)** | **Vollständiger Bauplan der DJ-Software — Architektur, Phasen, Risiken** | +| [docs/TRAKTOR-REFERENZ.md](docs/TRAKTOR-REFERENZ.md) | Was Traktor Pro 4 kann, was wir davon übernehmen | +| [docs/MIXXX.md](docs/MIXXX.md) | Mixxx als Referenz — was übernehmbar ist, warum es nicht eingebettet werden kann | +| **[docs/STEUERUNG.md](docs/STEUERUNG.md)** | **Fernsteuerung: Socket, Protokoll, selbstbeschreibender Steuerraum** | +| [docs/FREIGABE.md](docs/FREIGABE.md) | Spec: was ein Agent von sich aus darf | +| [docs/SPACE.md](docs/SPACE.md) | Spec: der Space `musik` in VibeMind | +| [docs/GENERATOR.md](docs/GENERATOR.md) | Spec: Tracks erzeugen lassen | +| [mcp/musik/README.md](mcp/musik/README.md) | MCP-Brücke für Agenten — Werkzeuge aus dem Katalog erzeugt | +| [docs/BAUSTEINE.md](docs/BAUSTEINE.md) | Fertige Bibliotheken für Audio, Analyse, Stems — inkl. Lizenzfallen | +| [docs/APIS.md](docs/APIS.md) | Generierungs-APIs und Sample-Quellen, Zugangsstatus | +| **[docs/DJ.md](docs/DJ.md)** | **Plan für einen AI-DJ, der nicht herzlos klingt — Reihenfolge und Begründung** | +| **[docs/FAHRPLAN.md](docs/FAHRPLAN.md)** | **Was noch aussteht — ungebaute Schichten, ungeprüfte Behauptungen, und was an Hardware und Musik hängt** | +| [docs/AGENTEN.md](docs/AGENTEN.md) | Multi-Agenten-Team und Crowd-Feedback-Schleife | +| [docs/VIBEMIND.md](docs/VIBEMIND.md) | Anschluss an VibeMind — MCP, Arbeitsteilung, Lizenzfolgen | +| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Bausteine und Schnittstellen | -## Geplante Funktionen +## Aktuelle Blocker -### Kern (DJ) -- Zwei oder mehr Decks mit unabhängiger Wiedergabe -- Pitch/Tempo-Kontrolle, Keylock -- BPM-Erkennung und Beatgrid -- Sync zwischen Decks -- Crossfader, Kanal-EQ, Gain, Filter -- Cue-Points und Loops -- Waveform-Darstellung (Übersicht + Zoom) -- Track-Library mit Metadaten, Suche, Playlists +**Suno hat keine öffentliche Entwickler-API.** Stand August 2026 gibt es nur ein +kuratiertes Partner-Programm mit Bewerbungsformular (seit Juli 2026) — keine +Endpunkte, keine Doku, kein Termin. Generierung ist damit vorerst nicht über +Suno machbar. -### Suno-Integration (später) -- Track per Prompt generieren und direkt auf ein Deck laden -- Generierte Tracks in der Library mit Prompt-Historie ablegen -- Stem-/Variantenerzeugung als Material für Übergänge -- Warteschlange für Generierungen, damit der Mix nicht blockiert +Konsequenz: Der Generierungs-Teil wird **hinter einer Adapter-Schnittstelle** +gebaut und startet mit einem erreichbaren Anbieter (Vorschlag: ElevenLabs +Music). Suno wird später ein weiterer Adapter, kein Umbau. Details und +Alternativenvergleich in [docs/APIS.md](docs/APIS.md). ## Roadmap +**Neun von zehn Phasen sind gebaut.** Die Software lässt sich bedienen: +Tracks aus der Sammlung auf die Decks legen, Wellenform und Beatgrid sehen, +nach Tempo und Tonart aussuchen, mischen, mit Effekten, und das Ganze +mitschneiden. Was fehlt, sind Stems und MIDI. + +Der ausführliche Bauplan der DJ-Software mit Architektur, Abnahmekriterien und +Risiken liegt in **[docs/PLAN.md](docs/PLAN.md)**. Grobe Reihenfolge: + | Phase | Inhalt | | --- | --- | -| 0 | Setup, Tech-Stack-Entscheidung, Audio-Prototyp (ein Deck, Play/Pause/Pitch) | -| 1 | Zweites Deck, Crossfader, EQ — ein Mix ist durchgängig spielbar | -| 2 | BPM-Analyse, Beatgrid, Sync | -| 3 | Waveforms, Cue-Points, Loops | -| 4 | Library: Import, Metadaten, Suche, Playlists | -| 5 | Suno-API-Adapter: Prompt → Track → Deck | -| 6 | Generierungs-Queue, Prompt-Historie, Varianten | +| 1 ⚙️ | Mehrdeck-Engine mit Mixer, Cue-Bus und AUX — Code steht, Abnahme braucht Hardware | +| ~~2~~ ✅ | ~~Analyse-Pipeline (BPM, Grid, Tonart, Peaks)~~ | +| ~~3~~ ✅ | ~~UI mit Wellenformen, Beatgrid und Plattenkiste~~ | +| ~~4~~ ✅ | ~~Sync und Beatmatching über Tempo und Phase~~ | +| ~~5~~ ✅ | ~~Hot Cues und Loops~~ | +| ~~6~~ ✅ | ~~Library inkl. Traktor-Import, Suche nach Tempo und Tonart~~ | +| ~~S~~ ✅ | ~~Steuerraum und Fernsteuerung~~ — [docs/STEUERUNG.md](docs/STEUERUNG.md) | +| ~~7~~ ✅ | ~~Effekte: Delay, Gater, Flanger, Crusher — post-fader, tempo-synchron~~ | +| ~~9~~ ✅ | ~~Mitschnitt der Summe als WAV~~ | +| **8, 10** | **Stems · MIDI-Controller** | + +Zum Auflegen fehlt jetzt nur noch Hardware: Phase 1 ist abgenommen, sobald der +Cue-Bus hörbar getrennt auf den Ausgängen 3/4 liegt, und das braucht ein +Interface mit vier Ausgängen. + +**Ohne Gerät lässt sich der halbe Weg trotzdem hören.** `musik-mix --cue` +rendert vierkanalig — denselben Weg, den auch das Gerät nimmt — und legt Summe +und Kopfhörer als zwei Dateien ab: + +```sh +musik-mix --a A.wav --b B.wav --out summe.wav --cue kopfhoerer.wav +``` + +In der Summe überwog im Versuch der Akkord von Deck A dreifach, im Kopfhörer +der von Deck B viereinhalbfach — bei geschlossenem Fader auf B. Was damit +**nicht** geprüft ist: ob der Treiber die Pufferkanäle 3/4 auch auf die Buchsen +3/4 legt. Das bleibt am Gerät hängen, ist aber jetzt der einzige ungeprüfte +Schritt. + +Darauf setzen die beiden Schichten aus dem Zielbild auf: + +| Phase | Inhalt | Abhängig von | +| --- | --- | --- | +| G1 | Generierungs-Adapter: Prompt → Track → Deck, asynchron mit Queue | 6, API-Zugang | +| A1 | Crowd-Signale einsammeln und visualisieren — noch ohne Steuerung | 6 | +| A2 | Agenten-Team: Energie-Analyse, Set-Planung, Übergangssteuerung | G1, A1 | + +G1 hängt am API-Zugang, A1 nicht — Crowd-Signale lassen sich auch gegen eine +reine Datei-Library testen. ## Tech-Stack -Noch offen. Vorschlag als Ausgangspunkt: +**Nativ, in Rust.** Ausschlaggebend war der getrennte Kopfhörer-Cue-Ausgang, +nicht die Latenz — ohne Vorhören ist ernsthaftes DJing nicht möglich, und im +Browser gibt es das nicht sauber. Rust statt C++, weil der Stack damit klein und +permissiv bleibt — zum Entscheidungszeitpunkt war die Lizenzfrage noch offen, +und der Werkzeugkasten sollte sie nicht vorwegnehmen. -- **Audio:** Web Audio API — reicht für Decks, EQ, Filter und Zeitstreckung, - läuft ohne Installation und ist gut zu debuggen. -- **UI:** TypeScript + React -- **Analyse (BPM/Beatgrid):** zunächst im Browser, bei Bedarf als separater Worker +| Crate | Lizenz | Wofür | +| --- | --- | --- | +| `cpal` | Apache-2.0 | Audioausgabe | +| `rusqlite` | MIT | Sammlung als SQLite-Datei | +| `quick-xml` | MIT | Traktor-`collection.nml` lesen | +| `symphonia` | MPL-2.0 | MP3, FLAC, WAV, AAC/M4A, OGG | +| `rustfft` | MIT/Apache-2.0 | STFT für die Onset-Erkennung | +| `rtrb` | MIT/Apache-2.0 | Lock-freier Ringpuffer für AUX | +| `blake3`, `serde`, `base64` | permissiv | Fingerabdruck und Sidecar | +| `eframe`, `egui` | MIT/Apache-2.0 | Oberfläche | +| `image` | MIT/Apache-2.0 | Screenshots schreiben | -Alternative, falls die Latenz im Browser nicht reicht: nativer Audio-Kern -(Rust/C++ mit CPAL bzw. JUCE) und die UI darüber. +Zeitstreckung (WSOLA) und Tempoerkennung sind selbst implementiert — damit ist +der gesamte Stack MIT-verträglich. Rubber Band wäre beim Stretching besser, ist +aber GPL und würde einen späteren Anschluss an VibeMind verbauen (siehe unten). +Reicht die eigene Variante hörbar nicht, ist `signalsmith-stretch` (MIT) der +Weg, der beide Optionen offenlässt. Details in +[docs/BAUSTEINE.md](docs/BAUSTEINE.md#phase-0-was-gebaut-ist). -Diese Entscheidung fällt in Phase 0. +**UI: egui/eframe**, entschieden. Begründung und die verworfenen Alternativen +(Tauri, Electron) in [docs/PLAN.md](docs/PLAN.md#entschiedene-punkte). ## Nicht-Ziele (vorerst) - Kein DVS/Timecode-Vinyl - Keine Controller-/MIDI-Anbindung in den ersten Phasen - Kein Streaming-Dienst-Import (Spotify, Beatport) +- Keine Echtzeit-Stem-Separation — beim Import vorberechnen (siehe + [docs/BAUSTEINE.md](docs/BAUSTEINE.md#stem-separation)) + +## Entschieden + +**Nativ in Rust** (Phase 0) und **nicht kommerziell.** + +Die zweite Entscheidung entspannt die Lizenzlage: Copyleft greift bei +*Weitergabe*, nicht bei Nutzung. Solange das Tool auf dem eigenen Rechner bleibt +und nicht verteilt wird, entstehen aus GPL-Abhängigkeiten überhaupt keine +Pflichten — deshalb liegt auch bewusst keine `LICENSE`-Datei im Repo. + +⚠️ **Trotzdem bleibt der Stack permissiv.** Ein späterer Anschluss an +[VibeMind](https://github.com/Flissel/Vibemind_V1) steht im Raum, und das ist +MIT und zur Veröffentlichung vorgesehen — also Weitergabe. Damit fielen +Rubber Band, aubio und Essentia wieder aus, und CC-BY-NC-Samples dürften nicht +mitgeliefert werden. Solange die Frage offen ist, ist jede GPL-Abhängigkeit eine +Tür, die hinter einem zufällt. Details in +[docs/VIBEMIND.md](docs/VIBEMIND.md#1-die-lizenzlage-kippt-zurück). + +Der aktuelle Stand ist sauber: alles im Repo ist MIT-verträglich. + +## Offene Entscheidungen + +1. **Wieviel Kontrolle behält der Mensch am Pult?** Vollautomat oder Assistent — + prägt das Agenten-Design. +2. **Audio-Interface mit vier Ausgängen.** Ohne getrennten Cue-Ausgang lässt + sich Phase 1 nicht abnehmen — eine Hardware-, keine Softwarefrage. Warum + zwei Geräte nicht gehen, steht in + [docs/PLAN.md](docs/PLAN.md#1-der-cue-ausgang-zwingt-zu-einem-gerät-mit-vier-ausgängen). ## Setup -Kommt mit Phase 0, sobald der Stack steht. +Voraussetzung: Rust (aktuelles Stable). Unter Linux zusätzlich die +ALSA-Header — `sudo apt install libasound2-dev`. + +```sh +cargo build +cargo test +``` + +Ein Deck fahren: + +```sh +cargo run -p musik-cli -- /pfad/zum/track.mp3 +``` + +Danach im Prompt: + +| Befehl | Wirkung | +| --- | --- | +| `p` | Play/Pause | +| `t +6` | Tempo +6 % (oder `t 1.06` als Verhältnis) | +| `k` | Keylock an/aus — Tonhöhe beim Tempowechsel halten | +| `s 30` | Auf Sekunde 30 springen | +| `i` | Status | +| `q` | Beenden | + +Der Vergleich, um den es geht: `t +6` einmal mit und einmal ohne `k`. Mit +Keylock bleibt die Tonhöhe stehen, ohne wandert sie hoch wie beim +Plattenspieler. + +Tracks analysieren — braucht **kein** Audiogerät: + +```sh +cargo run -p musik-cli --bin musik-analyze -- ~/Musik/*.mp3 +``` + +Liefert BPM, Beatgrid-Anker, Tonart und Wellenform-Spitzen und legt sie als Sidecar +unter `.musik-analyse/` ab, adressiert über einen Hash des Audioinhalts. Ein +zweiter Lauf über dieselbe Datei liest aus dem Cache; ein umbenannter Ordner +entwertet die Analyse nicht. + +Einen Übergang rendern — ebenfalls **ohne** Audiogerät: + +```sh +cargo run -p musik-cli --bin musik-mix -- --a a.mp3 --b b.mp3 --out mix.wav +``` + +Zieht Deck B per Keylock auf das Tempo von A, fährt einen Crossfade mit +Bass-Swap und schreibt das Ergebnis als WAV. Ohne `--a`/`--b` nimmt es +synthetische Loops — praktisch, um die Engine zu hören, wenn gerade keine +Musik zur Hand ist. `--aux ` legt einen dritten Kanal auf Thru dazu, +der den Crossfader unbeschadet übersteht. + +Sammlung aufbauen — ebenfalls **ohne** Audiogerät: + +```sh +cargo run -p musik-cli --bin musik-lib -- scan ~/Musik +cargo run -p musik-cli --bin musik-lib -- import-traktor ~/Documents/Native\ Instruments/Traktor/collection.nml +cargo run -p musik-cli --bin musik-lib -- mixable 128 +cargo run -p musik-cli --bin musik-lib -- harmonic 8A +``` + +`scan` dekodiert, analysiert und legt Tempo, Beatgrid, Tonart und Inhalts-Hash +in einer SQLite-Datei ab. `mixable` sucht nach Tempo, `harmonic` nach Tonart — +letzteres nach der Regel des Camelot-Rads und wahlweise als `Am` oder `8A`. `import-traktor` übernimmt Tempo, Beatgrid und Hot Cues aus +einer bestehenden Traktor-Sammlung — ⚠️ gegen eine *echte* `collection.nml` ist +das noch nicht geprüft, nur gegen selbst geschriebene Beispiele. Der erste Lauf +gehört stichprobenartig nachgesehen. + +Die Analyse gegen das prüfen, was jemand gehört hat — **ohne** Audiogerät: + +```sh +cargo run -p musik-cli --bin musik-material -- /tmp/schwierig +cargo run -p musik-cli --bin musik-pruefstand -- /tmp/schwierig/wahrheit.txt +``` + +`musik-pruefstand` legt die Analyse neben eine Wahrheitsdatei und meldet den +Abstand — mit Vorzeichen, ohne Urteil. `musik-material` baut vier Fälle mit +bekannter Gliederung und schreibt die Wahrheitsdatei gleich dazu: ein Track ohne +Outro, einer ohne Intro, einer mit zwei Breaks, einer mit Tempowechsel. Jeder +bricht eine Annahme, die in der Gliederung steckt. + +⚠️ Gebautes Material findet Fehler, ersetzt aber keine echte Musik mit gehörter +Wahrheit. Eine Vorlage dafür steht in [`docs/wahrheit-vorlage.txt`](docs/wahrheit-vorlage.txt). + +Etwas von außen hereinlassen — Plattenspieler, Interface, ein zweiter Rechner: + +```sh +cargo run -p musik-cli --release --bin musik-eingang -- --liste +cargo run -p musik-cli --release --bin musik-eingang -- --gerät scarlett +cargo run -p musik-app -- --aux-in +``` + +`musik-eingang` öffnet das Gerät auf demselben Weg wie die Anwendung und sagt +vorher, was hinterher zu spät wäre: ob überhaupt etwas ankommt, ob noch Luft +unter dem Begrenzer ist und ob es lückenlos ankommt. `musik-app --aux-in` legt +den AUX-Kanal dann darauf. + +**Zwei Arten mitzuschneiden, und sie meinen Verschiedenes:** + +```sh +musik-eingang --gerät scarlett --aufnehmen /musik/roh.wav # die Quelle +# im Pult: do master.record /musik/abend.wav # den Abend +``` + +`--aufnehmen` schreibt **am Mixer vorbei** — ohne Begrenzer, ohne Kanalzug, so +wie es ankommt. Das will, wer Lieder gewinnen will. `do master.record` nimmt die +Summe auf, hinter Fader, EQ und Begrenzer; das will, wer sein Set behalten will. +Ohne `--sekunden` läuft die Aufnahme, bis Strg-C kommt, und bleibt trotzdem +lesbar: Der Kopf wird jede Sekunde nachgetragen. + +⚠️ **Ein Gerät, ein Zugriff.** Die meisten Treiber lassen sich nur von einem +Programm zugleich aufnehmen — entweder `musik-app --aux-in` oder +`musik-eingang --aufnehmen`, nicht beides auf demselben Gerät. + +⚠️ **An echter Hardware ungeprüft** — hier gibt es kein Audiogerät. Geprüft ist +der Weg gegen das `null`-Gerät von ALSA: Auswahl, Rate, Format, Callback, +Umrechnung auf Stereo. Ob der Treiber tut, was seine Beschreibung sagt, weiß +erst, wer es anschließt. + +Eine lange Aufnahme in Lieder zerlegen: + +```sh +cargo run -p musik-cli --release --bin musik-schneiden -- aufnahme.wav /musik/neu +``` + +`musik-schneiden` schneidet an den Lücken zwischen den Stücken, analysiert jedes +gleich mit und legt das Sidecar daneben — danach hat die Sammlung Tempo, Tonart +und Gliederung. `--probe` zeigt die Schnitte, ohne etwas zu schreiben. + +⚠️ **Ein gemixtes Set lässt sich nicht sauber schneiden.** Während einer Blende +laufen zwei Lieder gleichzeitig; dort gibt es keine Grenze, nur eine Stelle, an +der man sie zieht. Findet das Werkzeug weder Lücke noch Klangwechsel, schneidet +es gar nicht und sagt es. + +Und den Kritiker gegen die Anlage halten: + +```sh +cargo run -p musik-cli --release --bin musik-streuung -- /tmp/streuung +``` + +`musik-streuung` fährt 24 Sets durch die echte Anlage — echter Mixer, echtes +Pult, echtes Repertoire, echte Mitschrift — und vergleicht danach, wo der +Kritiker den Übergang **hört**, mit dem Frame, in dem der Crossfader sich +wirklich bewegt hat. Heraus kommt eine Verteilung statt einer Anekdote: Median ++2,0 s, ein harter Schnitt auf ein Fenster genau, eine Blende über 64 Beats gar +nicht gefunden. Was das heißt, steht in [`docs/FAHRPLAN.md`](docs/FAHRPLAN.md) +unter N3. + +Auflegen — **hier** braucht es ein Audiogerät: + +```sh +cargo run --release -p musik-app -- --db musik.db +``` + +| Aufruf | Wirkung | +| --- | --- | +| *(ohne Argumente)* | synthetische Demo-Tracks auf beiden Decks | +| `--db musik.db` | die eigene Sammlung in der Plattenkiste | +| `--a a.mp3 --b b.mp3` | zwei Dateien direkt auf die Decks | +| `--screenshot bild.png` | ein Bild aufnehmen und beenden | + +Ohne Audiogerät startet die Oberfläche trotzdem: der Mixer läuft dann von einem +Taktgeber im Leerlauf, die Decks bewegen sich, die Bedienung lässt sich +beurteilen — nur hören kann man nichts. Findet die Soundkarte weniger als vier +Ausgänge, sagt die Kopfzeile das und das Vorhören entfällt. + +`--release` ist keine Zierde: die Analyse eines frisch geladenen Tracks dauert +im Debug-Build spürbar länger. + +Von außen steuern, während sie läuft: + +```sh +$ nc -U "$XDG_RUNTIME_DIR/musik.sock" +list deck1. +set deck1.play 1 +set channel1.fader 0.8 +setn channel2.eq_low 0 # normiert, für MIDI +``` + +`list` liefert zu jedem Control Typ, Bereich, Einheit, Schreibbarkeit und +Bedeutung — der Steuerraum beschreibt sich selbst, ein Handbuch ist dafür nicht +nötig. + +**Auf Windows gibt es keine Unix-Sockets**, und dort lief die Anlage bis vor +Kurzem ganz ohne Steuerung — also auch ohne MCP und ohne Agenten. `--tcp` +lauscht stattdessen auf der Rückschleife: + +```sh +$ musik-app --tcp +Steuerung: 127.0.0.1:7657 +Schlüssel: 50270126063f57dd96e73b655ce56d5a + +$ nc 127.0.0.1 7657 +auth 50270126063f57dd96e73b655ce56d5a +ok angemeldet +``` + +Gebunden wird **nur** an `127.0.0.1` oder `::1` — eine andere Adresse wird +abgelehnt, damit ein Tippfehler die Anlage nicht ins WLAN stellt. Der Schlüssel +entsteht bei jedem Start neu und ist kein Zierrat: Die Rückschleife erreicht +jedes Programm auf dem Rechner, eine Webseite im Browser eingeschlossen. + +Ein Übergang ist aber keine Folge von Reglerstellungen, sondern eine Bewegung +über Takte. Dafür gibt es Verben für Zeit: + +```sh +ramp channel1.eq_low 0.0 8 # Bass raus über 8 Beats +in 8 ramp channel2.fader 0.9 16 # danach Deck B rein über 16 +plan # was vorgemerkt ist — auch von anderen +``` + +Gerechnet wird in **Beats, nicht in Sekunden**: Dreht jemand am Tempo, bleibt +die Blende musikalisch richtig; steht das Deck, wartet sie. Und **eine Rampe +gibt auf, sobald jemand anders denselben Regler anfasst** — der Griff daneben +ist stärker als die Automatik, ob er von einem Menschen kommt oder von einem +zweiten Agenten. + +Damit der Mensch diesen Griff nicht blind gewinnt, steht rechts in der +Oberfläche die **Regie**: was vorgemerkt ist, wie weit jede Blende gelaufen +ist, und was als Nächstes kommt. + +![Die Regie-Spalte](docs/bilder/regie.png) + +Und weil die Frage beim Auflegen nicht „wie viele Sekunden" lautet, sondern +„wie viele Beats", tragen die Decks das auch: + +```sh +get deck1.beats_left # 84 +when deck1.beats_left < 32 do master.queue_next +``` + +Was als Nächstes kommt, steht ebenfalls in der Anlage und nicht in einem Kopf: + +```sh +do master.queue_add /musik/track.mp3 +do master.queue_note 1 mehr Druck nach dem Break +do master.queue_next # auf ein Deck, das gerade nicht läuft +``` + +**Jeder Eintrag trägt eine Notiz** — warum er dort steht. Das ist der +Unterschied zu einer Playlist, und für den Nächsten, der liest, der ganze Punkt. + +Und was der Raum sagt, kommt von außen herein — der Anfang der dritten Ebene aus +dem Zielbild: + +```sh +set master.signal1_name Energie auf der Flaeche +set master.signal1 0.7 +get master.signal1_trend # +1.28 je Minute +when master.signal1_trend < -0.3 do master.queue_next +``` + +Ein einzelner Wert nützt dabei fast nichts; „0,7 und seit zwei Minuten fallend" +ist die Auskunft, nach der sich handeln lässt. Deshalb merkt sich ein Signal +seine jüngste Vergangenheit. + +Das ist die Grundlage für die Agenten-Schicht und für den Anschluss an +VibeMind — für die liegt in [`mcp/musik/`](mcp/musik/README.md) eine MCP-Brücke bereit, +deren Werkzeugbeschreibungen aus demselben Katalog erzeugt werden. Details in +[docs/STEUERUNG.md](docs/STEUERUNG.md). + +![Von außen gesteuert](docs/bilder/fernsteuerung.png) + +Für API-Keys siehe [`.env.example`](.env.example). diff --git a/crates/analysis/Cargo.toml b/crates/analysis/Cargo.toml new file mode 100644 index 0000000..a2cf3ae --- /dev/null +++ b/crates/analysis/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "analysis" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +audio-core = { version = "0.1.0", path = "../audio-core" } +base64 = "0.23.1" +blake3 = "1.8.6" +rustfft = "6.4.1" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" diff --git a/crates/analysis/src/lib.rs b/crates/analysis/src/lib.rs new file mode 100644 index 0000000..3a4e5ad --- /dev/null +++ b/crates/analysis/src/lib.rs @@ -0,0 +1,177 @@ +//! Offline-Analyse: Tempo, Beatgrid, Tonart und Wellenform-Spitzen. +//! +//! Läuft bewusst außerhalb des Abspielpfads. Nichts hier hat Echtzeitauflagen, +//! dafür ist alles reproduzierbar und wird über den Inhalts-Hash +//! zwischengespeichert — siehe [`sidecar`]. + +pub mod onset; +pub mod peaks; +pub mod sidecar; +pub mod struktur; +pub mod tempo; +pub mod tonart; +pub mod wechsel; + +#[cfg(test)] +pub(crate) mod testing; + +use audio_core::Track; + +pub use sidecar::{AbschnittData, Analysis, Store}; +pub use struktur::{Abschnitt, Art, Struktur}; +pub use tempo::Beatgrid; +pub use tonart::Tonart; + +/// Analysiert einen dekodierten Track vollständig. +pub fn analyze(track: &Track) -> Analysis { + let envelope = onset::onset_envelope(&track.samples, track.sample_rate); + let grid = tempo::detect(&envelope) + .map(|g| tempo::refine_anchor(&track.samples, track.sample_rate, g)); + let levels = peaks::compute(&track.samples); + let gliederung = + grid.and_then(|g| struktur::analysiere(&track.samples, track.sample_rate, g, &envelope)); + let key = tonart::erkenne(&track.samples, track.sample_rate); + + Analysis { + version: sidecar::FORMAT_VERSION, + fingerprint: sidecar::fingerprint(track), + sample_rate: track.sample_rate, + frames: track.frames() as u64, + duration_secs: track.duration_secs(), + bpm: grid.map(|g| g.bpm), + beat_anchor_frames: grid.map(|g| g.anchor_frames), + bpm_confidence: grid.map(|g| g.confidence), + musical_key: key.map(|k| k.tonart.name()), + key_confidence: key.map(|k| k.konfidenz), + abschnitte: gliederung + .map(|s| { + s.abschnitte + .iter() + .map(|a| sidecar::AbschnittData { + von_frames: a.von_frames, + bis_frames: a.bis_frames, + art: a.art.name().to_string(), + pegel: a.pegel, + bass: a.bass, + dichte: a.dichte, + }) + .collect() + }) + .unwrap_or_default(), + peaks: levels + .iter() + .map(sidecar::PeakLevelData::from_level) + .collect(), + } +} + +/// Analysiert einen Track, sofern kein passendes Sidecar vorliegt. +/// +/// Gibt zusätzlich zurück, ob gerechnet werden musste (`true`) oder der Cache +/// gereicht hat (`false`). +pub fn analyze_cached(track: &Track, store: &Store) -> (Analysis, bool) { + let fp = sidecar::fingerprint(track); + if let Some(vorhanden) = store.load(&fp) { + return (vorhanden, false); + } + (analyze(track), true) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{akkordfolge, click_track}; + + fn track(bpm: f64, secs: f64) -> Track { + Track { + samples: click_track(bpm, 44_100, secs, 0.0), + sample_rate: 44_100, + stems: Vec::new(), + } + } + + #[test] + fn vollstaendige_analyse_liefert_alle_felder() { + let t = track(128.0, 25.0); + let a = analyze(&t); + + assert_eq!(a.version, sidecar::FORMAT_VERSION); + assert_eq!(a.sample_rate, 44_100); + assert_eq!(a.frames, t.frames() as u64); + assert!((a.duration_secs - 25.0).abs() < 0.1); + assert_eq!(a.peaks.len(), peaks::LEVELS.len()); + + let bpm = a.bpm.expect("kein BPM erkannt"); + assert!((bpm - 128.0).abs() < 1.0, "BPM {bpm:.2}"); + assert!(a.beat_anchor_frames.is_some()); + } + + #[test] + fn die_tonart_landet_in_der_analyse() { + // Die Erkennung selbst prüft `tonart`; hier zählt nur, dass sie + // überhaupt aufgerufen wird und ihr Ergebnis im Sidecar ankommt. + let t = Track { + samples: akkordfolge(0, false), + sample_rate: 44_100, + stems: Vec::new(), + }; + let a = analyze(&t); + + assert_eq!(a.musical_key.as_deref(), Some("C")); + assert_eq!(a.tonart().map(|k| k.camelot()), Some("8B".to_string())); + assert!(a.key_confidence.is_some_and(|k| k > 0.0)); + } + + #[test] + fn perkussion_bekommt_keine_tonart_angedichtet() { + // Ein Klick-Track hat keine Harmonik. Eine geratene Tonart wäre beim + // harmonischen Mischen schlimmer als gar keine. + let a = analyze(&track(128.0, 25.0)); + assert_eq!(a.musical_key, None, "Tonart geraten: {:?}", a.musical_key); + } + + #[test] + fn gleichfoermiges_material_liefert_kein_tempo() { + // Ein Dauerton hat keine Onsets — hier ist `None` die richtige Antwort, + // nicht eine geratene Zahl. + let frames = 44_100 * 20; + let mut samples = Vec::with_capacity(frames * 2); + for i in 0..frames { + let v = (2.0 * std::f32::consts::PI * 220.0 * i as f32 / 44_100.0).sin() * 0.5; + samples.push(v); + samples.push(v); + } + let t = Track { + samples, + sample_rate: 44_100, + stems: Vec::new(), + }; + + let a = analyze(&t); + assert!( + a.bpm.is_none(), + "Tempo aus einem Dauerton geraten: {:?}", + a.bpm + ); + } + + #[test] + fn zweiter_lauf_kommt_aus_dem_cache() { + let dir = std::env::temp_dir().join("musik-analysis-test-cached"); + std::fs::remove_dir_all(&dir).ok(); + let store = Store::new(&dir); + + let t = track(128.0, 25.0); + + let (erste, gerechnet) = analyze_cached(&t, &store); + assert!(gerechnet); + store.save(&erste).expect("speichern"); + + let (zweite, nochmal_gerechnet) = analyze_cached(&t, &store); + assert!(!nochmal_gerechnet, "Cache wurde nicht genutzt"); + assert_eq!(zweite.fingerprint, erste.fingerprint); + assert_eq!(zweite.bpm, erste.bpm); + + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/analysis/src/onset.rs b/crates/analysis/src/onset.rs new file mode 100644 index 0000000..94f60b8 --- /dev/null +++ b/crates/analysis/src/onset.rs @@ -0,0 +1,177 @@ +//! Onset-Hüllkurve über spektralen Fluss. +//! +//! Grundlage für alles Rhythmische: Statt im Zeitsignal nach lauten Stellen zu +//! suchen, wird gemessen, wie stark sich das *Spektrum* von Frame zu Frame +//! ändert. Ein Hi-Hat auf einer laufenden Bassline erzeugt kaum Pegelanstieg, +//! aber deutlichen spektralen Fluss — deshalb funktioniert das dort, wo eine +//! Pegelmessung versagt. + +use rustfft::{num_complex::Complex, FftPlanner}; + +use audio_core::track::CHANNELS; + +pub const WINDOW: usize = 1024; +pub const HOP: usize = 256; + +pub struct OnsetEnvelope { + /// Ein Wert pro Analyse-Frame, halbwellengleichgerichtet, Maximum auf 1.0. + pub values: Vec, + /// Frames pro Sekunde. + pub rate: f64, + pub hop: usize, +} + +impl OnsetEnvelope { + /// Rechnet eine Frame-Position (auch gebrochen) in Sample-Frames um. + pub fn to_sample_frames(&self, frame: f64) -> f64 { + frame * self.hop as f64 + } +} + +/// Berechnet die Hüllkurve aus interleaved Stereo. +pub fn onset_envelope(samples: &[f32], sample_rate: u32) -> OnsetEnvelope { + let rate = sample_rate as f64 / HOP as f64; + let mono = downmix(samples); + + if mono.len() < WINDOW { + return OnsetEnvelope { + values: Vec::new(), + rate, + hop: HOP, + }; + } + + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(WINDOW); + let window = hann(WINDOW); + let bins = WINDOW / 2 + 1; + + let mut prev = vec![0.0f32; bins]; + let mut cur = vec![0.0f32; bins]; + let mut scratch = vec![Complex::new(0.0f32, 0.0f32); WINDOW]; + let mut flux = Vec::with_capacity((mono.len() - WINDOW) / HOP + 1); + + let frames = (mono.len() - WINDOW) / HOP + 1; + for f in 0..frames { + let off = f * HOP; + for i in 0..WINDOW { + scratch[i] = Complex::new(mono[off + i] * window[i], 0.0); + } + fft.process(&mut scratch); + + for (k, slot) in cur.iter_mut().enumerate() { + *slot = scratch[k].norm(); + } + + if f == 0 { + flux.push(0.0); + } else { + // Nur Zuwachs zählt — abklingende Energie ist kein Onset. + let mut sum = 0.0; + for k in 0..bins { + let d = cur[k] - prev[k]; + if d > 0.0 { + sum += d; + } + } + flux.push(sum); + } + + std::mem::swap(&mut prev, &mut cur); + } + + detrend(&mut flux, rate); + + OnsetEnvelope { + values: flux, + rate, + hop: HOP, + } +} + +fn downmix(samples: &[f32]) -> Vec { + samples + .as_chunks::() + .0 + .iter() + .map(|f| 0.5 * (f[0] + f[1])) + .collect() +} + +fn hann(n: usize) -> Vec { + (0..n) + .map(|i| 0.5 - 0.5 * (2.0 * std::f32::consts::PI * i as f32 / n as f32).cos()) + .collect() +} + +/// Zieht den gleitenden Mittelwert ab und richtet gleich. +/// +/// Ohne diesen Schritt dominiert der laute Teil eines Tracks die gesamte +/// Hüllkurve, und eine leise Passage liefert keine verwertbaren Onsets mehr. +fn detrend(values: &mut [f32], rate: f64) { + let win = ((0.3 * rate) as usize).max(1) | 1; + let half = win / 2; + let n = values.len(); + if n == 0 { + return; + } + + let mut prefix = Vec::with_capacity(n + 1); + prefix.push(0.0f64); + for v in values.iter() { + prefix.push(prefix[prefix.len() - 1] + *v as f64); + } + + let mut out = Vec::with_capacity(n); + for (i, value) in values.iter().enumerate() { + let lo = i.saturating_sub(half); + let hi = (i + half + 1).min(n); + let mean = (prefix[hi] - prefix[lo]) / (hi - lo) as f64; + out.push((*value as f64 - mean).max(0.0) as f32); + } + + let peak = out.iter().cloned().fold(0.0f32, f32::max); + let scale = if peak > 0.0 { 1.0 / peak } else { 0.0 }; + for (dst, src) in values.iter_mut().zip(out) { + *dst = src * scale; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::click_track; + + #[test] + fn klicks_erzeugen_klare_spitzen() { + let rate = 44_100; + let track = click_track(120.0, rate, 6.0, 0.0); + let env = onset_envelope(&track, rate); + + assert!(!env.values.is_empty()); + assert!((env.rate - rate as f64 / HOP as f64).abs() < 1e-9); + + // Bei 120 BPM in 6 s liegen 12 Klicks. Deutlich über der Hälfte des + // Maximums sollten grob so viele Frames liegen — nicht hunderte. + let laut = env.values.iter().filter(|v| **v > 0.5).count(); + assert!( + (1..=60).contains(&laut), + "unplausibel viele/wenige Spitzen: {laut}" + ); + } + + #[test] + fn stille_liefert_keine_onsets() { + let rate = 44_100; + let stille = vec![0.0f32; rate as usize * CHANNELS]; + let env = onset_envelope(&stille, rate); + + assert!(env.values.iter().all(|v| *v == 0.0)); + } + + #[test] + fn zu_kurzes_material_paniert_nicht() { + let env = onset_envelope(&[0.0; 32], 44_100); + assert!(env.values.is_empty()); + } +} diff --git a/crates/analysis/src/peaks.rs b/crates/analysis/src/peaks.rs new file mode 100644 index 0000000..6bf02d0 --- /dev/null +++ b/crates/analysis/src/peaks.rs @@ -0,0 +1,127 @@ +//! Vorberechnete Wellenform-Spitzen in mehreren Auflösungen. +//! +//! Die Darstellung darf nie über den Rohdaten rechnen: Bei fünf Minuten Audio +//! sind das dreizehn Millionen Werte pro Deck und Bild. Stattdessen werden +//! Min/Max je Fenster einmalig abgelegt, in drei Stufen von Übersicht bis Zoom. +//! +//! Quantisiert auf `i8` — für eine Wellenformdarstellung sind 8 Bit reichlich, +//! und es viertelt die Dateigröße gegenüber `f32`. + +use audio_core::track::CHANNELS; + +/// Samples pro Spitze, von fein nach grob. +pub const LEVELS: [u32; 3] = [256, 2048, 16384]; + +#[derive(Debug, Clone)] +pub struct PeakLevel { + pub samples_per_peak: u32, + pub min: Vec, + pub max: Vec, +} + +impl PeakLevel { + pub fn len(&self) -> usize { + self.min.len() + } + + pub fn is_empty(&self) -> bool { + self.min.is_empty() + } +} + +/// Berechnet alle Stufen aus interleaved Stereo. +pub fn compute(samples: &[f32]) -> Vec { + let mono: Vec = samples + .as_chunks::() + .0 + .iter() + .map(|f| 0.5 * (f[0] + f[1])) + .collect(); + + LEVELS.iter().map(|n| level(&mono, *n)).collect() +} + +fn level(mono: &[f32], samples_per_peak: u32) -> PeakLevel { + let bucket = samples_per_peak as usize; + let count = mono.len().div_ceil(bucket); + + let mut min = Vec::with_capacity(count); + let mut max = Vec::with_capacity(count); + + for chunk in mono.chunks(bucket) { + let (lo, hi) = chunk + .iter() + .fold((f32::MAX, f32::MIN), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + min.push(quantize(lo)); + max.push(quantize(hi)); + } + + PeakLevel { + samples_per_peak, + min, + max, + } +} + +fn quantize(v: f32) -> i8 { + (v.clamp(-1.0, 1.0) * 127.0).round() as i8 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spitzen_treffen_die_extremwerte() { + // Rampe von -1 bis +1 über 4096 Frames. + let frames = 4096; + let mut samples = Vec::with_capacity(frames * CHANNELS); + for i in 0..frames { + let v = -1.0 + 2.0 * i as f32 / (frames - 1) as f32; + samples.push(v); + samples.push(v); + } + + let levels = compute(&samples); + assert_eq!(levels.len(), LEVELS.len()); + + let fein = &levels[0]; + assert_eq!(fein.samples_per_peak, 256); + assert_eq!(fein.len(), frames / 256); + + // Erster Bucket beginnt bei -1, letzter endet bei +1. + assert_eq!(fein.min[0], -127); + assert_eq!(*fein.max.last().unwrap(), 127); + + // Innerhalb eines Buckets muss max >= min gelten. + for (lo, hi) in fein.min.iter().zip(&fein.max) { + assert!(hi >= lo); + } + } + + #[test] + fn groebere_stufen_haben_weniger_spitzen() { + let samples = vec![0.5f32; 100_000 * CHANNELS]; + let levels = compute(&samples); + + for pair in levels.windows(2) { + assert!( + pair[0].len() > pair[1].len(), + "Stufen sind nicht monoton gröber" + ); + } + } + + #[test] + fn leeres_material_ergibt_leere_stufen() { + let levels = compute(&[]); + assert!(levels.iter().all(|l| l.is_empty())); + } + + #[test] + fn uebersteuerung_wird_geklemmt() { + let samples = vec![4.0f32; 512 * CHANNELS]; + let levels = compute(&samples); + assert_eq!(levels[0].max[0], 127); + } +} diff --git a/crates/analysis/src/sidecar.rs b/crates/analysis/src/sidecar.rs new file mode 100644 index 0000000..2727594 --- /dev/null +++ b/crates/analysis/src/sidecar.rs @@ -0,0 +1,365 @@ +//! Ablage der Analyseergebnisse, adressiert über den Inhalt. +//! +//! Der Schlüssel ist ein Hash über die dekodierten Audiodaten — **nicht** über +//! den Dateipfad. Sonst ist die Arbeit weg, sobald jemand einen Ordner +//! umbenennt, und doppelt vorhandene Tracks würden zweimal analysiert. +//! +//! Gehasht wird auf `i16` quantisiert. Ein geänderter ID3-Tag lässt die +//! Sidecars damit unberührt, und die Empfindlichkeit gegenüber winzigen +//! Fließkomma-Unterschieden sinkt deutlich. +//! +//! Sie verschwindet aber nicht: Werte direkt an einer Rundungsgrenze können +//! trotzdem kippen. Ein Decoder-Wechsel kann Sidecars also entwerten — das ist +//! hingenommen, weil die Analyse dann schlicht neu läuft. + +use std::path::{Path, PathBuf}; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; + +use audio_core::Track; + +use crate::peaks::PeakLevel; + +/// Bei einer Änderung des Formats hochzählen — ältere Sidecars werden dann +/// verworfen statt falsch gelesen. +/// +/// 2: Tonart dazugekommen. Ein Sidecar aus Version 1 hätte sie schlicht nicht, +/// und `serde(default)` würde daraus ein „keine Tonart erkannt" machen — also +/// eine Falschaussage statt einer fehlenden Angabe. Deshalb der Bump: Der +/// Track wird einmal neu gerechnet und weiß danach Bescheid. +/// +/// 3: Gliederung dazugekommen — aus demselben Grund. Eine leere Abschnittsliste +/// hieße „dieser Track hat keine Struktur", und das ist etwas anderes als „hier +/// hat noch niemand nachgesehen". +pub const FORMAT_VERSION: u32 = 3; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Analysis { + pub version: u32, + pub fingerprint: String, + pub sample_rate: u32, + pub frames: u64, + pub duration_secs: f64, + + /// `None`, wenn kein verlässliches Tempo gefunden wurde. + #[serde(default)] + pub bpm: Option, + /// Erster Beat in Sample-Frames. + #[serde(default)] + pub beat_anchor_frames: Option, + #[serde(default)] + pub bpm_confidence: Option, + + /// Tonart in üblicher Schreibweise (`Am`, `F#`). `None`, wenn das Material + /// keine klare hergibt — Perkussion etwa. + #[serde(default)] + pub musical_key: Option, + #[serde(default)] + pub key_confidence: Option, + + /// Gliederung in Abschnitte. Leer, wenn keine erkennbar war — etwa ohne + /// Beatgrid oder bei einem Track von zwei Phrasen Länge. + #[serde(default)] + pub abschnitte: Vec, + + pub peaks: Vec, +} + +/// Ein Abschnitt, wie er im Sidecar steht. +/// +/// **Ohne Beats.** Die stehen nicht mit drin, weil sie sich aus Tempo und Anker +/// ergeben, die ohnehin danebenliegen. Zwei Angaben derselben Sache in einer +/// Datei laufen irgendwann auseinander, und dann glaubt man der falschen. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AbschnittData { + pub von_frames: u64, + pub bis_frames: u64, + /// `intro`, `aufbau`, `drop`, `break`, `outro` oder `teil`. + pub art: String, + pub pegel: f32, + pub bass: f32, + pub dichte: f32, +} + +impl Analysis { + /// Die Gliederung als Wert, mit den Beats aus Tempo und Anker gerechnet. + /// + /// `None`, wenn keine erkannt wurde oder das Tempo fehlt — ohne Grid gäbe + /// es keine Beats, auf die sich ein Abschnitt beziehen ließe. + pub fn struktur(&self) -> Option { + let (bpm, anker) = (self.bpm?, self.beat_anchor_frames?); + if self.abschnitte.is_empty() || bpm <= 0.0 { + return None; + } + let je_beat = 60.0 / bpm as f64 * self.sample_rate as f64; + let beat = |frames: u64| (frames as f64 - anker as f64) / je_beat; + + Some(crate::struktur::Struktur { + phrase_beats: crate::struktur::PHRASE_BEATS, + abschnitte: self + .abschnitte + .iter() + .map(|a| crate::struktur::Abschnitt { + von_frames: a.von_frames, + bis_frames: a.bis_frames, + von_beat: beat(a.von_frames), + bis_beat: beat(a.bis_frames), + art: crate::struktur::Art::parse(&a.art).unwrap_or(crate::struktur::Art::Teil), + pegel: a.pegel, + bass: a.bass, + dichte: a.dichte, + }) + .collect(), + }) + } + + /// Die erkannte Tonart als Wert, sofern eine dasteht. + pub fn tonart(&self) -> Option { + self.musical_key + .as_deref() + .and_then(audio_core::Tonart::parse) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PeakLevelData { + pub samples_per_peak: u32, + /// base64, ein Byte je Spitze (i8 als u8 kodiert). + pub min: String, + pub max: String, +} + +impl PeakLevelData { + pub fn from_level(level: &PeakLevel) -> Self { + PeakLevelData { + samples_per_peak: level.samples_per_peak, + min: encode(&level.min), + max: encode(&level.max), + } + } + + pub fn to_level(&self) -> Option { + Some(PeakLevel { + samples_per_peak: self.samples_per_peak, + min: decode(&self.min)?, + max: decode(&self.max)?, + }) + } +} + +fn encode(values: &[i8]) -> String { + let bytes: Vec = values.iter().map(|v| *v as u8).collect(); + STANDARD.encode(bytes) +} + +fn decode(s: &str) -> Option> { + STANDARD + .decode(s) + .ok() + .map(|b| b.into_iter().map(|v| v as i8).collect()) +} + +/// Inhalts-Hash eines dekodierten Tracks. +pub fn fingerprint(track: &Track) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(&track.sample_rate.to_le_bytes()); + + let mut buf = Vec::with_capacity(2 * 8192); + for chunk in track.samples.chunks(8192) { + buf.clear(); + for s in chunk { + let q = (s.clamp(-1.0, 1.0) * 32_767.0).round() as i16; + buf.extend_from_slice(&q.to_le_bytes()); + } + hasher.update(&buf); + } + + hasher.finalize().to_hex()[..32].to_string() +} + +/// Verzeichnisbasierter Speicher für Sidecars. +pub struct Store { + root: PathBuf, +} + +impl Store { + pub fn new(root: impl Into) -> Self { + Store { root: root.into() } + } + + pub fn path_for(&self, fingerprint: &str) -> PathBuf { + self.root.join(format!("{fingerprint}.json")) + } + + /// Lädt ein Sidecar. `None` bei fehlender Datei, kaputtem JSON oder + /// veralteter Formatversion — in allen drei Fällen ist neu rechnen richtig. + pub fn load(&self, fingerprint: &str) -> Option { + let raw = std::fs::read_to_string(self.path_for(fingerprint)).ok()?; + let analysis: Analysis = serde_json::from_str(&raw).ok()?; + if analysis.version != FORMAT_VERSION { + return None; + } + Some(analysis) + } + + pub fn save(&self, analysis: &Analysis) -> std::io::Result { + std::fs::create_dir_all(&self.root)?; + let path = self.path_for(&analysis.fingerprint); + let json = serde_json::to_string_pretty(analysis) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + std::fs::write(&path, json)?; + Ok(path) + } + + pub fn root(&self) -> &Path { + &self.root + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::peaks; + use crate::testing::click_track; + + fn track(bpm: f64, secs: f64) -> Track { + Track { + samples: click_track(bpm, 44_100, secs, 0.0), + sample_rate: 44_100, + stems: Vec::new(), + } + } + + #[test] + fn fingerabdruck_ist_stabil_und_unterscheidet() { + let a = track(128.0, 2.0); + let b = track(128.0, 2.0); + let c = track(140.0, 2.0); + + assert_eq!(fingerprint(&a), fingerprint(&b)); + assert_ne!(fingerprint(&a), fingerprint(&c)); + assert_eq!(fingerprint(&a).len(), 32); + } + + #[test] + fn lautstaerkeaenderung_aendert_den_hash() { + // Der Hash beschreibt den Inhalt, nicht die Datei — anderer Klang, + // anderer Schlüssel, damit die Analyse neu läuft. + let a = track(128.0, 2.0); + let mut b = track(128.0, 2.0); + for s in b.samples.iter_mut() { + *s *= 0.5; + } + + assert_ne!(fingerprint(&a), fingerprint(&b)); + } + + #[test] + fn spitzen_ueberleben_die_kodierung() { + let t = track(128.0, 2.0); + let levels = peaks::compute(&t.samples); + + for original in &levels { + let zurueck = PeakLevelData::from_level(original) + .to_level() + .expect("dekodieren"); + assert_eq!(zurueck.samples_per_peak, original.samples_per_peak); + assert_eq!(zurueck.min, original.min); + assert_eq!(zurueck.max, original.max); + } + } + + #[test] + fn speichern_und_laden() { + let dir = std::env::temp_dir().join("musik-analysis-test-store"); + std::fs::remove_dir_all(&dir).ok(); + let store = Store::new(&dir); + + let analysis = Analysis { + version: FORMAT_VERSION, + fingerprint: "abc123".into(), + sample_rate: 44_100, + frames: 1000, + duration_secs: 0.0227, + bpm: Some(128.0), + beat_anchor_frames: Some(42), + bpm_confidence: Some(0.9), + musical_key: Some("Am".into()), + key_confidence: Some(0.2), + abschnitte: Vec::new(), + peaks: vec![], + }; + + store.save(&analysis).expect("speichern"); + let geladen = store.load("abc123").expect("laden"); + + assert_eq!(geladen.bpm, Some(128.0)); + assert_eq!(geladen.beat_anchor_frames, Some(42)); + assert_eq!(geladen.tonart(), audio_core::Tonart::parse("Am")); + assert!(store.load("gibtesnicht").is_none()); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn veraltete_version_wird_verworfen() { + let dir = std::env::temp_dir().join("musik-analysis-test-version"); + std::fs::remove_dir_all(&dir).ok(); + let store = Store::new(&dir); + + let mut analysis = Analysis { + version: FORMAT_VERSION, + fingerprint: "veraltet".into(), + sample_rate: 44_100, + frames: 0, + duration_secs: 0.0, + bpm: None, + beat_anchor_frames: None, + bpm_confidence: None, + musical_key: None, + key_confidence: None, + abschnitte: Vec::new(), + peaks: vec![], + }; + analysis.version = FORMAT_VERSION + 1; + store.save(&analysis).expect("speichern"); + + assert!(store.load("veraltet").is_none()); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// Ein Sidecar aus einer älteren Fassung darf nicht als vollständig + /// durchgehen. + /// + /// Ohne die Versionsprüfung würde `serde(default)` das fehlende Feld zu + /// `None` machen, und `None` heißt hier „geprüft, keine Tonart gefunden" — + /// eine Falschaussage. Verworfen wird es, und dann rechnet die Analyse neu. + #[test] + fn ein_sidecar_ohne_tonart_wird_neu_gerechnet_statt_ergaenzt() { + let dir = std::env::temp_dir().join("musik-analysis-test-alt"); + std::fs::remove_dir_all(&dir).ok(); + std::fs::create_dir_all(&dir).unwrap(); + let store = Store::new(&dir); + + // So sah ein Sidecar der Version 1 aus — ohne die beiden neuen Felder. + let alt = r#"{ + "version": 1, + "fingerprint": "alt", + "sample_rate": 44100, + "frames": 1000, + "duration_secs": 0.02, + "bpm": 128.0, + "beat_anchor_frames": 0, + "bpm_confidence": 0.9, + "peaks": [] + }"#; + std::fs::write(store.path_for("alt"), alt).unwrap(); + + assert!(store.load("alt").is_none()); + + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/analysis/src/struktur.rs b/crates/analysis/src/struktur.rs new file mode 100644 index 0000000..a288522 --- /dev/null +++ b/crates/analysis/src/struktur.rs @@ -0,0 +1,915 @@ +//! Gliederung eines Tracks: Intro, Aufbau, Drop, Break, Outro. +//! +//! **Die größte Lücke in der Analyse.** Die Anlage kennt Tempo, Tonart und die +//! Wellenform. Sie kennt nicht die Stellen, an denen ein Übergang überhaupt +//! sitzen darf — und deshalb ist „blende aus, während das Outro läuft" bisher +//! nicht ausdrückbar gewesen, weil das Wort Outro nicht existierte. +//! +//! Die Mitschrift (S1b) hat das beim ersten gemessenen Übergang sofort +//! vorgeführt: Der ausgehende Track lag sauber auf seiner Phrasengrenze, der +//! eingehende setzte auf Sekunde 0 ein — fünfzehn Beats neben seiner eigenen +//! Eins. Ohne Gliederung gibt es keinen besseren Einstiegspunkt als „von +//! vorn". +//! +//! # Wie +//! +//! Gerechnet wird **auf dem vorhandenen Beatgrid**, nicht auf der Uhr. Der +//! Track zerfällt in Phrasen von [`PHRASE_BEATS`] Beats, und je Phrase stehen +//! drei Zahlen: +//! +//! | Größe | Was sie unterscheidet | +//! | --- | --- | +//! | Pegel | laut gegen leise — Drop gegen Intro | +//! | Bass | **die entscheidende**: ein Break ist Bass weg, ein Drop ist Bass da | +//! | Dichte | Schläge gegen keine Schläge — Intro ohne Drums gegen Break mit | +//! +//! Ein Break und ein Intro sind beide leise; sie unterscheiden sich darin, ob +//! Perkussion läuft. Ein Drop und ein Aufbau sind beide laut; sie unterscheiden +//! sich im Bass. Zwei Zahlen reichen dafür nicht, vier wären Zierde. +//! +//! Höhen (Hi-Hats, Brillanz) fehlen bewusst. Sie würden trennen, was die +//! anderen drei schon trennen, und jede Größe, die nichts entscheidet, macht +//! die Schwellen nur schwerer zu begründen. +//! +//! # Wo es aufhört: ein Tempo je Track +//! +//! Gerechnet wird auf **einem** Beatgrid, und ein Beatgrid hat ein Tempo. +//! Springt das Tempo mitten im Stück — ein Mitschnitt zweier Tracks in einer +//! Datei, ein Edit mit Übergang —, stimmen die Phrasengrenzen danach nicht +//! mehr, und die Gliederung findet ab dort nichts. Gemessen an gebautem +//! Material mit Sprung von 124 auf 140: drei von sechs Abschnitten gefunden, +//! alle drei davor. +//! +//! Das ist eine Grenze und kein Fehler: Ein zweites Grid wäre eine eigene +//! Schicht (Tempokurve statt Tempo), und die brauchte man erst, wenn solche +//! Dateien im Ordner liegen. **Gemeldet wird sie trotzdem** — eine Gliederung, +//! die nach der halben Datei aufhört und das verschweigt, wäre schlimmer als +//! keine. +//! +//! Eine Grenze liegt dort, wo sich das Klangbild über eine Phrasengrenze hinweg +//! ändert: Der Abstand zwischen dem Mittel der [`KERN`] Phrasen davor und dem +//! der [`KERN`] danach ist die Neuheit, und ihre lokalen Spitzen sind die +//! Grenzen. Dass sie auf Phrasengrenzen liegen, ist keine nachträgliche +//! Rundung, sondern folgt aus der Bauart — in dieser Musik wechseln die Teile +//! dort und nirgends sonst. +//! +//! # Was daran belegt ist und was nicht +//! +//! **Die Grenzen sind geprüft**, an eigens gebautem Material mit bekannter +//! Gliederung: Intro, Aufbau, Drop, Break, Drop, Outro. Sie werden gefunden. +//! +//! **Die Benennung ist es nicht.** Sie hängt an Quantilen des Tracks selbst — +//! keine absolute Zahl, weil eine solche Schwelle in diesem Projekt schon +//! viermal an echten Aufnahmen zerbrochen ist, nachdem sie an synthetischem +//! Material gut aussah. Relativ zu messen macht das besser, aber nicht wahr: +//! Ob „lauteste 25 % mit viel Bass" auf echten Produktionen wirklich der Drop +//! ist, weiß erst, wer es an echten Produktionen mit bekannter Gliederung +//! nachmisst. Bis dahin stehen die Zahlen neben jedem Namen im Bericht, damit +//! man ihm widersprechen kann. + +use audio_core::track::CHANNELS; + +pub use audio_core::struktur::{Abschnitt, Art, Struktur, PHRASE_BEATS}; + +use crate::onset::OnsetEnvelope; +use crate::tempo::Beatgrid; + +/// Obere Grenze des Bassbands, in Hertz. +/// +/// Kick und Bassline liegen darunter, alles Melodische darüber. Gefiltert wird +/// mit zwei Einpolern hintereinander — 12 dB je Oktave, also weich. Für die +/// Frage „ist der Bass da oder weg" reicht das; wer eine Trennung mit Flanke +/// braucht, baut sie, wenn eine Messung sie verlangt. +pub const BASS_HZ: f32 = 160.0; + +/// Wie viele Phrasen je Seite in die Neuheit eingehen. +/// +/// **Eine.** Der erste Entwurf nahm zwei, aus der Sorge, eine einzelne Phrase +/// sei jeder Variation ausgeliefert. Nachgemessen war das Gegenteil der Fall: +/// Ein Abschnitt von zwei Phrasen ist genauso lang wie ein Kern aus zwei je +/// Seite, und dann vergleicht der Kern nie zwei reine Teile, sondern immer +/// Mischungen. Am gebauten Material stand die echte Grenze deshalb *niedriger* +/// als die Schulter daneben. +/// +/// Die Glättung, die den Kern rechtfertigen sollte, steckt schon in der Phrase: +/// Ein Merkmal ist der Mittelwert über sechzehn Beats, bei 128 BPM also über +/// siebeneinhalb Sekunden. Ein ausgelassener Kick verschwindet darin von +/// selbst. Mit einer Phrase je Seite trennen sich die Grenzen am gebauten +/// Material um das Fünfundzwanzigfache vom Rest, mit zweien nur noch um das +/// Doppelte. +pub const KERN: usize = 1; + +/// Kürzester Abschnitt, in Phrasen. +/// +/// Ein Teil von einer halben Phrase ist kein Teil, sondern eine Variation. +pub const MIN_PHRASEN: usize = 2; + +/// Wie weit sich das Klangbild über eine Grenze hinweg ändern muss. +/// +/// Gemessen als Abstand in einem Raum, in dem jede der drei Größen auf ihren +/// eigenen Höchstwert im Track bezogen ist. 0,15 heißt also: „das Klangbild +/// verschiebt sich um fünfzehn Prozent dessen, was dieser Track selbst an +/// Spannweite hat". +/// +/// **Der erste Entwurf nahm Median plus Streuung der Neuheit** und fand +/// deshalb gar nichts: Wenn jede zweite Phrase eine Grenze ist, sind die +/// Ausschläge nicht die Ausnahme, sondern die Hälfte der Verteilung — sie +/// heben den Median so weit an, dass sie sich selbst überdecken. Ein +/// Bezugspunkt aus dem Material selbst ist hier der falsche, weil das Material +/// bimodal ist. +/// +/// **An eigenem Material geeicht, nicht an echten Aufnahmen.** Dort liegen die +/// Grenzen um das Fünfundzwanzigfache über dem Rest, die genaue Zahl ist also +/// beliebig; bei echten Produktionen wird der Abstand kleiner sein, und dann +/// entscheidet sie. Genau der Satz, der in diesem Projekt schon viermal die +/// Vorstufe eines Fehlers war — er steht hier, damit die Zahl nicht für +/// geprüft gehalten wird. +/// Ab welchem Anteil am lautesten Abschnitt ein Abschnitt „oben" ist. +/// +/// 0,6 trennt am gebauten Material Aufbau und Break (0,48 und 0,40) von Drop +/// und Teil (0,95 und mehr) mit reichlich Abstand nach beiden Seiten. Eine +/// **Höhe** und kein Rang: Ein Stück darf durchweg oben sein, und eines mit +/// vier Drops darf vier haben. +pub const OBEN: f32 = 0.6; + +/// Ab welchem Anteil am stärksten Bass eines lauten Abschnitts er ein Drop ist. +/// +/// Derselbe Gedanke wie bei [`OBEN`]. Am gebauten Material liegen Drops bei +/// 0,95 und darüber, alles ohne Bass bei 0,39 und darunter — die Lücke ist +/// groß, und 0,6 liegt darin. +pub const DROP_BASS: f32 = 0.6; + +pub const SCHWELLE: f32 = 0.15; + +/// Gliedert einen Track. +/// +/// `None`, wenn es kein Grid gibt oder der Track kürzer ist als ein paar +/// Phrasen — aus zwei Phrasen lässt sich keine Gliederung lesen, und eine +/// geratene wäre schlechter als keine. +pub fn analysiere( + samples: &[f32], + sample_rate: u32, + grid: Beatgrid, + env: &OnsetEnvelope, +) -> Option { + let fenster = phrasenfenster(samples.len() / CHANNELS, sample_rate, grid)?; + if fenster.len() < 2 * KERN + MIN_PHRASEN { + return None; + } + + let merkmale = merkmale(samples, sample_rate, env, &fenster); + let z = normieren(&merkmale); + let neuheit = neuheit(&z); + let grenzen = grenzen(&neuheit, fenster.len()); + + let mut abschnitte = zusammenfassen(&fenster, &merkmale, &grenzen, sample_rate, grid); + benennen(&mut abschnitte); + + Some(Struktur { + abschnitte, + phrase_beats: PHRASE_BEATS, + }) +} + +/// Anfang jedes Phrasenfensters in Sample-Frames. +/// +/// Das Gitter hängt am Anker des Beatgrids, nicht an Frame 0: Der Vorlauf vor +/// der ersten Eins gehört zu keiner Phrase. +fn phrasenfenster(frames: usize, sample_rate: u32, grid: Beatgrid) -> Option> { + if grid.bpm <= 0.0 { + return None; + } + let je_beat = 60.0 / grid.bpm as f64 * sample_rate as f64; + let je_phrase = (je_beat * PHRASE_BEATS) as u64; + if je_phrase == 0 { + return None; + } + + let start = grid.anchor_frames % je_phrase; + let mut aus = Vec::new(); + let mut von = start; + while von + je_phrase <= frames as u64 { + aus.push((von, von + je_phrase)); + von += je_phrase; + } + + // Der Rest hinter der letzten vollen Phrase, wenn er mindestens eine halbe + // ist. Ohne das fällt das Outro durch: Ein Track endet selten glatt auf + // einer Phrasengrenze, und was übrig bleibt, ist genau der Teil, den man + // sucht. Am gebauten Material blieben so von zwei Outro-Phrasen nur eine + // übrig — zu wenig für einen eigenen Abschnitt, und der Track hatte + // plötzlich kein Ende mehr. + let rest = frames as u64 - von; + if rest >= je_phrase / 2 { + aus.push((von, frames as u64)); + } + + (!aus.is_empty()).then_some(aus) +} + +/// Pegel, Bass und Dichte je Phrase. +struct Merkmale { + pegel: Vec, + bass: Vec, + dichte: Vec, +} + +impl Merkmale { + fn len(&self) -> usize { + self.pegel.len() + } + + /// Die drei Größen einer Phrase als Vektor. + fn zeile(&self, i: usize) -> [f32; 3] { + [self.pegel[i], self.bass[i], self.dichte[i]] + } +} + +fn merkmale( + samples: &[f32], + sample_rate: u32, + env: &OnsetEnvelope, + fenster: &[(u64, u64)], +) -> Merkmale { + let mono = mono(samples); + let tief = tiefpass(&mono, sample_rate); + + let mut pegel = Vec::with_capacity(fenster.len()); + let mut bass = Vec::with_capacity(fenster.len()); + let mut dichte = Vec::with_capacity(fenster.len()); + + for &(von, bis) in fenster { + let (a, b) = (von as usize, (bis as usize).min(mono.len())); + pegel.push(rms(&mono[a..b])); + bass.push(rms(&tief[a..b])); + + // Die Hüllkurve läuft in ihrer eigenen Rate, nicht in Sample-Frames. + let je = env.hop.max(1); + let (ea, eb) = ( + (a / je).min(env.values.len()), + (b / je).min(env.values.len()), + ); + dichte.push(if eb > ea { + env.values[ea..eb].iter().sum::() / (eb - ea) as f32 + } else { + 0.0 + }); + } + + Merkmale { + pegel, + bass, + dichte, + } +} + +fn mono(samples: &[f32]) -> Vec { + samples + .as_chunks::() + .0 + .iter() + .map(|f| f.iter().sum::() / CHANNELS as f32) + .collect() +} + +/// Zwei Einpoler hintereinander — 12 dB je Oktave. +fn tiefpass(mono: &[f32], sample_rate: u32) -> Vec { + let dt = 1.0 / sample_rate as f32; + let rc = 1.0 / (std::f32::consts::TAU * BASS_HZ); + let a = dt / (rc + dt); + + let mut aus = Vec::with_capacity(mono.len()); + let (mut z1, mut z2) = (0.0f32, 0.0f32); + for &x in mono { + z1 += a * (x - z1); + z2 += a * (z1 - z2); + aus.push(z2); + } + aus +} + +fn rms(werte: &[f32]) -> f32 { + if werte.is_empty() { + return 0.0; + } + (werte.iter().map(|v| v * v).sum::() / werte.len() as f32).sqrt() +} + +/// Jede Größe auf ihren Höchstwert im Track bezogen. +/// +/// Nötig, weil der Pegel sonst den Abstand beherrschte — allein deshalb, weil +/// seine Zahlen größer sind als die der Dichte. +/// +/// **Nicht als z-Wert.** Der erste Entwurf teilte durch die Streuung, und damit +/// bekam gleichförmiges Material dieselbe Spannweite wie ein Track voller +/// Wechsel: Rauschen, auf Streuung 1 gestreckt, sieht aus wie Struktur. Ein +/// Track ohne Abschnitte muss aber Werte nahe beieinander behalten, damit die +/// Neuheit nahe null bleibt und er keine bekommt. +fn normieren(m: &Merkmale) -> Vec<[f32; 3]> { + let spalten = [&m.pegel, &m.bass, &m.dichte]; + let spitzen: [f32; 3] = + std::array::from_fn(|k| spalten[k].iter().copied().fold(0.0f32, f32::max).max(1e-9)); + + (0..m.len()) + .map(|i| { + let z = m.zeile(i); + std::array::from_fn(|k| z[k] / spitzen[k]) + }) + .collect() +} + +/// Wie fremd sich die Phrasen vor und nach einer Grenze sind. +/// +/// Ein Wert je Phrasengrenze: `neuheit[i]` gehört vor Phrase `i`. +fn neuheit(z: &[[f32; 3]]) -> Vec { + (0..z.len()) + .map(|i| { + let vor = mittel(&z[i.saturating_sub(KERN)..i]); + let nach = mittel(&z[i..(i + KERN).min(z.len())]); + match (vor, nach) { + (Some(a), Some(b)) => (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::().sqrt(), + _ => 0.0, + } + }) + .collect() +} + +fn mittel(teil: &[[f32; 3]]) -> Option<[f32; 3]> { + if teil.is_empty() { + return None; + } + let n = teil.len() as f32; + Some(std::array::from_fn(|k| { + teil.iter().map(|z| z[k]).sum::() / n + })) +} + +/// Die Phrasen, vor denen ein neuer Abschnitt beginnt. +fn grenzen(neuheit: &[f32], phrasen: usize) -> Vec { + let mut aus = Vec::new(); + let mut letzte = 0usize; + + // Einschließlich der oberen Grenze: Bei zwölf Phrasen darf auch vor der + // zehnten noch geschnitten werden — dahinter bleiben zwei, und genau dort + // fängt bei einem gewöhnlichen Aufbau das Outro an. + for i in MIN_PHRASEN..=phrasen.saturating_sub(MIN_PHRASEN) { + if neuheit[i] < SCHWELLE { + continue; + } + // Nur die Spitze, nicht die ganze Flanke. + if neuheit[i] < neuheit[i - 1] || neuheit[i] < neuheit[i + 1] { + continue; + } + if i - letzte < MIN_PHRASEN { + continue; + } + aus.push(i); + letzte = i; + } + aus +} + +fn zusammenfassen( + fenster: &[(u64, u64)], + m: &Merkmale, + grenzen: &[usize], + sample_rate: u32, + grid: Beatgrid, +) -> Vec { + let je_beat = 60.0 / grid.bpm as f64 * sample_rate as f64; + let beat = |frames: u64| (frames as f64 - grid.anchor_frames as f64) / je_beat; + + let spitze = |werte: &[f32]| werte.iter().copied().fold(0.0f32, f32::max).max(1e-9); + let (p_max, b_max, d_max) = (spitze(&m.pegel), spitze(&m.bass), spitze(&m.dichte)); + + let mut kanten = vec![0usize]; + kanten.extend_from_slice(grenzen); + kanten.push(fenster.len()); + + kanten + .windows(2) + .map(|w| { + let (a, b) = (w[0], w[1]); + let schnitt = |werte: &[f32]| werte[a..b].iter().sum::() / (b - a) as f32; + Abschnitt { + von_frames: fenster[a].0, + bis_frames: fenster[b - 1].1, + von_beat: beat(fenster[a].0), + bis_beat: beat(fenster[b - 1].1), + art: Art::Teil, + pegel: schnitt(&m.pegel) / p_max, + bass: schnitt(&m.bass) / b_max, + dichte: schnitt(&m.dichte) / d_max, + } + }) + .collect() +} + +/// Gibt den Abschnitten ihre Namen. +/// +/// # Warum keine Quantile mehr +/// +/// Der erste Entwurf verglich jeden Abschnitt mit dem Median der übrigen und +/// nannte einen Drop, wer über dem 75. Perzentil des Bassbands lag. Das ist +/// **rangbasiert**, und darin steckt eine Behauptung über die Musik, die +/// niemand aufgestellt hat: Höchstens ein Viertel der Abschnitte kann je ein +/// Drop sein. Ein Stück mit vier Drops bekam einen und drei „Teil" — die +/// Schwelle lag *zwischen* den Drops. +/// +/// Gemessen an gebautem Material mit bekannter Gliederung (`musik-material`): +/// 13 von 23 Namen stimmten, und die Fehler waren nicht verstreut, sondern +/// genau dieses Muster. Jetzt entscheidet der **Pegel als Anteil am lautesten +/// Abschnitt des Tracks** — eine Höhe, kein Rang. Vier Drops dürfen vier +/// Drops sein. +/// +/// # Was das immer noch nicht ist +/// +/// Ein Maß für Lautstärke über Tracks hinweg. Bezugspunkt bleibt der lauteste +/// Abschnitt *dieses* Stücks; der Drop eines leisen Stücks heißt genauso Drop +/// wie der eines lauten. Das ist Absicht — siehe `bogen::energie_des_tracks`. +fn benennen(abschnitte: &mut [Abschnitt]) { + let Some(lautester) = abschnitte + .iter() + .map(|a| a.pegel) + .fold(None::, |m, p| Some(m.map_or(p, |m: f32| m.max(p)))) + else { + return; + }; + let staerkster_bass = abschnitte + .iter() + .map(|a| a.bass) + .fold(0.0f32, f32::max) + .max(1e-9); + let lautester = lautester.max(1e-9); + + let letzter = abschnitte.len().saturating_sub(1); + for (i, a) in abschnitte.iter_mut().enumerate() { + let laut = a.pegel / lautester >= OBEN; + let bassig = a.bass / staerkster_bass >= DROP_BASS; + + a.art = match (laut, bassig) { + (true, true) => Art::Drop, + // Laut, aber ohne Boden: ein Teil, der läuft. Ihn Drop zu nennen, + // damit ein Name dasteht, macht die Auskunft wertlos. + (true, false) => Art::Teil, + // Unten. Am Anfang ist das ein Intro, am Ende ein Outro, + // dazwischen ein Break — dasselbe, nur woanders. + // + // Der erste Entwurf verlangte für ein Break „Bass weg", und das + // war falsch gemessen: Ein Kick allein bringt reichlich Energie + // unter 160 Hz. Am gebauten Material lag er bei 0,41 gegen 0,99 im + // Drop — leiser, aber weit entfernt von „weg". + _ if i == 0 => Art::Intro, + _ if i == letzter && i > 0 => Art::Outro, + _ => Art::Break, + }; + } + + // Ein Aufbau ist nur einer, wenn danach etwas kommt, worauf er aufbaut — + // und wenn er von unten kommt. Genau das trennt ihn von einem Break: Der + // Aufbau folgt auf etwas Leiseres und führt in den Drop, der Break fällt + // aus einem Drop heraus und in den nächsten hinein. Am Pegel allein sind + // die beiden nicht zu unterscheiden; am gebauten Material lagen sie bei + // 0,48 gegen 0,40. + // + // Deshalb ein zweiter Durchgang: Beim ersten war der nächste Name noch + // nicht vergeben. + for i in 0..abschnitte.len().saturating_sub(1) { + if abschnitte[i + 1].art != Art::Drop { + continue; + } + if !matches!(abschnitte[i].art, Art::Teil | Art::Break) { + continue; + } + let von_unten = i == 0 || abschnitte[i - 1].pegel < abschnitte[i].pegel; + if von_unten { + abschnitte[i].art = Art::Aufbau; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::onset::onset_envelope; + + const RATE: u32 = 44_100; + const BPM: f32 = 120.0; + + /// Baut Material aus Teilen zu je zwei Phrasen. + /// + /// Je Teil: läuft ein Kick, liegt ein Bass darunter, wie laut der Akkord + /// ist. Bei 120 BPM ist eine Phrase acht Sekunden. + fn bauen(teile: &[(bool, bool, f32)]) -> Vec { + let je_beat = 60.0 / BPM as f64 * RATE as f64; + let je_phrase = (je_beat * PHRASE_BEATS) as usize; + let mut mono = Vec::new(); + + for &(kick, bass, akkord) in teile { + let von = mono.len(); + mono.resize(von + je_phrase * 2, 0.0); + let teil = &mut mono[von..]; + + for (i, wert) in teil.iter_mut().enumerate() { + // Die Zeit läuft über das ganze Stück weiter, sonst entstünde + // an jeder Teilgrenze ein Phasensprung — also ein Onset, den + // die Gliederung dann zu Recht fände. + let t = (von + i) as f32 / RATE as f32; + for hz in [220.0f32, 277.18, 329.63] { + *wert += (std::f32::consts::TAU * hz * t).sin() * akkord / 3.0; + } + if bass { + *wert += (std::f32::consts::TAU * 55.0 * t).sin() * 0.5; + } + } + + if kick { + let schlag = je_beat as usize; + let mut start = 0; + while start + RATE as usize / 4 < teil.len() { + for i in 0..RATE as usize / 4 { + let t = i as f32 / RATE as f32; + let f = 110.0 * (-t * 30.0).exp() + 45.0; + teil[start + i] += + (std::f32::consts::TAU * f * t).sin() * (-t * 22.0).exp() * 0.9; + } + start += schlag; + } + } + } + + mono.iter().flat_map(|v| [*v, *v]).collect() + } + + /// Ein Track mit bekannter Gliederung: Intro (Akkord, keine Drums), Aufbau + /// (Drums ohne Bass), Drop (alles), Break (Drums, leiser, kein Bass), Drop, + /// Outro (nur Akkord). Die Grenzen liegen bei Beat 32, 64, 96, 128, 160. + fn gebauter_track() -> Vec { + bauen(&[ + (false, false, 0.30), + (true, false, 0.30), + (true, true, 0.30), + (true, false, 0.12), + (true, true, 0.30), + (false, false, 0.20), + ]) + } + + fn gliedern(samples: &[f32]) -> Struktur { + let env = onset_envelope(samples, RATE); + let grid = Beatgrid { + bpm: BPM, + anchor_frames: 0, + confidence: 1.0, + }; + analysiere(samples, RATE, grid, &env).expect("keine Gliederung") + } + + /// Die Grenzen sitzen dort, wo die Teile wechseln — auf ±1 Phrase. + /// + /// Genauer geht es nicht: Der Kern mittelt über zwei Phrasen je Seite, und + /// genau das macht ihn gegen einzelne Variationen unempfindlich. + #[test] + fn die_grenzen_liegen_an_den_gebauten_uebergaengen() { + let s = gliedern(&gebauter_track()); + let phrase = PHRASE_BEATS; + + let grenzen: Vec = s.abschnitte.iter().skip(1).map(|a| a.von_beat).collect(); + assert!( + grenzen.len() >= 4, + "nur {} Grenzen gefunden: {:?}", + grenzen.len(), + s.abschnitte.iter().map(|a| a.von_beat).collect::>() + ); + + // Gebaut wurde bei Beat 32, 64, 96, 128, 160. + for erwartet in [32.0, 64.0, 96.0, 128.0, 160.0] { + let naechste = grenzen + .iter() + .map(|g| (g - erwartet).abs()) + .fold(f64::MAX, f64::min); + assert!( + naechste <= phrase, + "bei Beat {erwartet} fehlt eine Grenze (nächste {naechste:.0} Beats entfernt): \ +{grenzen:?}" + ); + } + } + + /// Die ganze Kette, gegen bekannte Wahrheit. + /// + /// Das ist der eigentliche Prüfstein von S2: sechs gebaute Teile, sechs + /// erkannte, mit den richtigen Namen in der richtigen Reihenfolge. + #[test] + fn die_gliederung_findet_die_gebauten_teile_mit_namen() { + let s = gliedern(&gebauter_track()); + let arten: Vec = s.abschnitte.iter().map(|a| a.art).collect(); + assert_eq!( + arten, + vec![ + Art::Intro, + Art::Aufbau, + Art::Drop, + Art::Break, + Art::Drop, + Art::Outro + ], + "{:?}", + s.abschnitte + .iter() + .map(|a| (a.art, a.von_beat, a.pegel, a.bass)) + .collect::>() + ); + assert!(s.outro_frames().is_some(), "das Outro fehlt"); + } + + /// Ein Track endet selten glatt auf einer Phrasengrenze. Was hinter der + /// letzten vollen liegt, ist genau das Outro — es darf nicht wegfallen. + #[test] + fn der_rest_hinter_der_letzten_vollen_phrase_zaehlt_mit() { + let mut samples = gebauter_track(); + // Eine Dreivierteilphrase mehr, wie sie beim Ausblenden entsteht. + let je_beat = 60.0 / BPM as f64 * RATE as f64; + let anhang = (je_beat * PHRASE_BEATS * 0.75) as usize * CHANNELS; + let letzter = samples[samples.len() - anhang..].to_vec(); + samples.extend_from_slice(&letzter); + + let s = gliedern(&samples); + let ende = s.abschnitte.last().expect("kein Abschnitt").bis_frames; + assert_eq!( + ende as usize, + samples.len() / CHANNELS, + "der Schwanz des Tracks fehlt in der Gliederung" + ); + } + + /// **Ein Track, der die meiste Zeit oben ist, ist nicht durchweg leise.** + /// + /// Gefunden beim ersten Lauf gegen so ein Stück, nicht im Test: Ein kurzes + /// Intro, sechs Phrasen Vollgas, ein kurzes Outro — und die Mitte hieß + /// `break`. Der Grund war, dass die Quantile über *Phrasen* gebildet + /// wurden, verglichen aber mit Abschnitts*mitteln*. Ein Mittel liegt immer + /// unter der lautesten Phrase darin; ist die Mehrheit der Phrasen laut, + /// liegt der Median fast auf der Spitze, und dann fällt jeder Abschnitt + /// darunter. + /// + /// Das ist beim gebauten Prüfmaterial nie aufgefallen, weil dort laute und + /// leise Teile sich abwechseln und der Median genau dazwischen landet — + /// dieselbe Falle wie bei der Tempo-Schwelle an Klick-Tracks. + #[test] + fn ein_stueck_das_meist_oben_ist_heisst_nicht_break() { + // Kurzes Intro, sechs Phrasen laut, kurzes Outro. **Die lauten Teile + // sind nicht exakt gleich laut** — bei perfekt gleichen Phrasen fällt + // der Fehler nicht auf, weil das Abschnittsmittel dann genau auf dem + // Median liegt und die Prüfung `>=` gerade noch hält. Genau daran ist + // der erste Anlauf dieses Tests vorbeigelaufen. + let s = gliedern(&bauen(&[ + (false, false, 0.20), + (true, true, 0.34), + (true, true, 0.28), + (true, true, 0.31), + (true, true, 0.29), + (true, true, 0.33), + (true, true, 0.30), + (false, false, 0.20), + ])); + + let mitte: Vec = s + .abschnitte + .iter() + .filter(|a| a.pegel > 0.8) + .map(|a| a.art) + .collect(); + assert!( + !mitte.is_empty(), + "kein lauter Abschnitt: {:?}", + s.abschnitte + ); + assert!( + mitte.iter().all(|a| *a == Art::Drop), + "der laute Teil heißt {mitte:?}, nicht Drop: {:?}", + s.abschnitte + .iter() + .map(|a| (a.art, a.pegel, a.bass)) + .collect::>() + ); + } + + /// **Vier Drops dürfen vier Drops sein.** + /// + /// Der erste Entwurf nannte einen Drop, wer über dem 75. Perzentil des + /// Bassbands lag — rangbasiert, und darin steckt eine Behauptung über die + /// Musik, die niemand aufgestellt hat: Höchstens ein Viertel der + /// Abschnitte kann je ein Drop sein. Ein Stück mit drei Drops bekam einen + /// und zwei „Teil", weil die Schwelle *zwischen* den Drops lag. + /// + /// Gefunden am gebauten Prüfmaterial (`musik-material`), nicht hier: Von + /// 23 Namen stimmten 13, und die Fehler waren nicht verstreut, sondern + /// genau dieses Muster. + #[test] + fn mehrere_drops_werden_alle_drops_genannt() { + // Intro, Aufbau, Drop, Break, Drop, Break, Drop, Outro. + let s = gliedern(&bauen(&[ + (false, false, 0.20), + (true, false, 0.30), + (true, true, 0.33), + (true, false, 0.12), + (true, true, 0.31), + (true, false, 0.11), + (true, true, 0.32), + (false, false, 0.18), + ])); + + let arten: Vec = s.abschnitte.iter().map(|a| a.art).collect(); + assert_eq!( + arten, + vec![ + Art::Intro, + Art::Aufbau, + Art::Drop, + Art::Break, + Art::Drop, + Art::Break, + Art::Drop, + Art::Outro + ], + "{:?}", + s.abschnitte + .iter() + .map(|a| (a.art, a.pegel, a.bass)) + .collect::>() + ); + } + + /// **Ein Stück, das auf dem Höhepunkt endet, hat kein Outro** — und eines, + /// das sofort oben anfängt, kein Intro. + /// + /// Beides kommt in jedem DJ-Ordner vor: das eine als Produktion, das + /// andere als Werkzeug-Edit. Eine Regel „der letzte Abschnitt ist ein + /// Outro" wäre für `beats_to_outro` schlimmer als keine Auskunft — dort + /// hinge ein Übergang daran. + #[test] + fn ohne_outro_und_ohne_intro_wird_keins_erfunden() { + let ohne_outro = gliedern(&bauen(&[ + (false, false, 0.20), + (true, false, 0.30), + (true, true, 0.32), + (true, false, 0.12), + (true, true, 0.31), + ])); + assert_eq!( + ohne_outro.abschnitte.last().map(|a| a.art), + Some(Art::Drop), + "aus dem Schluss wurde ein Outro gemacht: {:?}", + ohne_outro + .abschnitte + .iter() + .map(|a| a.art) + .collect::>() + ); + assert!( + ohne_outro.outro_frames().is_none(), + "ein Outro, das es nicht gibt, hängt an beats_to_outro" + ); + + let ohne_intro = gliedern(&bauen(&[ + (true, true, 0.32), + (true, false, 0.12), + (true, true, 0.31), + (false, false, 0.18), + ])); + assert_eq!( + ohne_intro.abschnitte.first().map(|a| a.art), + Some(Art::Drop), + "aus dem Anfang wurde ein Intro gemacht: {:?}", + ohne_intro + .abschnitte + .iter() + .map(|a| a.art) + .collect::>() + ); + } + + /// Der Anfang ist leise und ohne Schläge — das ist ein Intro. + #[test] + fn der_leise_anfang_heisst_intro() { + let s = gliedern(&gebauter_track()); + assert_eq!(s.abschnitte[0].art, Art::Intro, "{:?}", s.abschnitte[0]); + assert!(s.intro_beats().is_some_and(|b| b >= PHRASE_BEATS)); + } + + /// Bass da und laut — das ist ein Drop. Und mindestens einer muss dabei + /// sein, sonst hat die Benennung nichts erkannt. + #[test] + fn wo_der_bass_ist_steht_ein_drop() { + let s = gliedern(&gebauter_track()); + let drops: Vec<&Abschnitt> = s.abschnitte.iter().filter(|a| a.art == Art::Drop).collect(); + assert!( + !drops.is_empty(), + "kein Drop erkannt: {:?}", + s.abschnitte + .iter() + .map(|a| (a.art, a.bass)) + .collect::>() + ); + for d in &drops { + assert!(d.bass > 0.5, "ein Drop ohne Bass: {d:?}"); + } + } + + /// Der Einstiegspunkt ist die erste Eins, nicht Frame 0. + #[test] + fn der_einstieg_liegt_auf_einer_phrasengrenze() { + let samples = gebauter_track(); + let env = onset_envelope(&samples, RATE); + // Ein Anker mitten in der Phrase: Der Vorlauf davor gehört zu keiner. + let grid = Beatgrid { + bpm: BPM, + anchor_frames: (0.75 * RATE as f64) as u64, + confidence: 1.0, + }; + let s = analysiere(&samples, RATE, grid, &env).expect("keine Gliederung"); + + let einstieg = s.einstieg_frames().expect("kein Einstieg"); + let je_beat = 60.0 / BPM as f64 * RATE as f64; + let beat = (einstieg as f64 - grid.anchor_frames as f64) / je_beat; + assert!( + (beat.rem_euclid(PHRASE_BEATS)).abs() < 0.05, + "der Einstieg liegt bei Beat {beat:.2}, nicht auf einer Phrasengrenze" + ); + } + + /// Aus zwei Phrasen lässt sich keine Gliederung lesen. Dann lieber nichts + /// sagen als etwas erfinden. + #[test] + fn ein_zu_kurzer_track_bekommt_keine_gliederung() { + let samples: Vec = vec![0.1; RATE as usize * 2 * CHANNELS]; + let env = onset_envelope(&samples, RATE); + let grid = Beatgrid { + bpm: BPM, + anchor_frames: 0, + confidence: 1.0, + }; + assert!(analysiere(&samples, RATE, grid, &env).is_none()); + } + + /// Ohne Tempo gibt es kein Phrasengitter — und damit keine Gliederung. + #[test] + fn ohne_tempo_keine_gliederung() { + let samples = gebauter_track(); + let env = onset_envelope(&samples, RATE); + let grid = Beatgrid { + bpm: 0.0, + anchor_frames: 0, + confidence: 0.0, + }; + assert!(analysiere(&samples, RATE, grid, &env).is_none()); + } + + /// Gleichförmiges Material hat keine Abschnitte — und darf sich keine + /// ausdenken. + /// + /// **Der erste Anlauf nahm dafür einen reinen Sinus.** Der ist als Prüfstein + /// unbrauchbar: Sein spektraler Fluss driftet über das Stück um 45 %, weil + /// die Bin-Leckage gegen den Hop schwebt. Damit hätte der Test eine + /// Robustheit gegen etwas verlangt, das in Musik nicht vorkommt — dieselbe + /// Falle wie bei der Tempo-Schwelle, die an Klick-Tracks geeicht war. Hier + /// steht jetzt ein durchlaufender Takt: Kick, Bass, Akkord, zwölf Phrasen + /// lang unverändert. + #[test] + fn gleichfoermiges_material_zerfaellt_nicht() { + let samples = bauen(&[(true, true, 0.30); 6]); + let s = gliedern(&samples); + assert_eq!( + s.abschnitte.len(), + 1, + "aus gleichförmigem Material wurden {} Abschnitte: {:?}", + s.abschnitte.len(), + s.abschnitte.iter().map(|a| a.von_beat).collect::>() + ); + } + + #[test] + fn ein_abschnitt_laesst_sich_ueber_den_frame_finden() { + let s = gliedern(&gebauter_track()); + let erster = s.abschnitte[0]; + assert_eq!(s.bei_frames(erster.von_frames), Some(&erster)); + assert_eq!(s.bei_frames(erster.bis_frames - 1), Some(&erster)); + assert!(s.bei_frames(u64::MAX).is_none()); + } + + #[test] + fn jede_art_ueberlebt_ihren_namen() { + for art in [ + Art::Intro, + Art::Aufbau, + Art::Drop, + Art::Break, + Art::Outro, + Art::Teil, + ] { + assert_eq!(Art::parse(art.name()), Some(art)); + } + assert_eq!(Art::parse("refrain"), None); + } +} diff --git a/crates/analysis/src/tempo.rs b/crates/analysis/src/tempo.rs new file mode 100644 index 0000000..796f4dd --- /dev/null +++ b/crates/analysis/src/tempo.rs @@ -0,0 +1,684 @@ +//! Tempo und Beatgrid aus der Onset-Hüllkurve. +//! +//! Drei Stufen mit bewusst unterschiedlichen Werkzeugen: +//! +//! 1. **Grob** per Autokorrelation. Sie vergleicht das Signal mit *einer* +//! Verschiebung und ist damit unempfindlich gegen kleine Periodenfehler. +//! 2. **Grundperiode** per Autokorrelation an der halben Verschiebung. +//! 3. **Fein** per Kammfilter, der zugleich die Phase liefert. +//! +//! Die Reihenfolge ist nicht beliebig. Ein Kammfilter summiert über hunderte +//! Pulse, und ein Periodenfehler von einem halben Frame verschiebt den +//! fünfzigsten Puls bereits um fünfundzwanzig — als Grobsuche ist er deshalb +//! unbrauchbar, als Feinsuche in einem engen Fenster dagegen ideal. +//! +//! Die Phase ist der Teil, den man leicht vergisst: ohne sie hat man ein Tempo, +//! aber kein Grid, und Sync klingt trotz gleicher BPM falsch. + +use audio_core::track::CHANNELS; + +use crate::onset::OnsetEnvelope; + +/// Untergrenze der Tempo-Erkennung. +/// +/// # Was darunter passiert — gemessen, nicht vermutet +/// +/// **Eine harte Untergrenze weist unter sich nicht ab. Sie meldet falsch.** +/// Gebautes Material mit 66 BPM bekommt ein Grid mit **71,51 BPM**, eines mit +/// 68 eines mit 69,88 — und zwar mit hoher Deutlichkeit, denn innerhalb des +/// abgeschnittenen Suchfensters ragt die beste Verschiebung sauber heraus. Sie +/// sitzt nur am Rand. Ein Grid, das um 8 % danebenliegt, ist schlimmer als +/// keines: Jeder Beat driftet, Sync zieht das andere Deck mit, und nichts sagt +/// es. +/// +/// Das Verfahren selbst trägt tiefer. Mit geöffnetem Fenster findet die +/// Autokorrelation zwischen 46 und 58 BPM die richtige Periode, deutlich und +/// stabil — zwei unabhängige Gegenproben (Median der Hüllkurven-Abstände, +/// Grobsuche mit tieferer Grenze) stimmen dort auf ein halbes BPM überein. +/// Die Grenze liegt also an dieser Konstante, nicht am Verfahren. +/// +/// # Warum sie trotzdem steht +/// +/// Zwei naheliegende Auswege wurden ausprobiert und sind beide falsch: +/// +/// 1. **Das Fenster weiter aufmachen** lädt den Halbtempo-Fehler ein, und zwar +/// messbar: Bei Material mit Snare auf zwei und vier greift die Grobsuche +/// auf den Zweitakt-Zyklus. Der Demo-Track mit 124 BPM bekam so gar kein +/// Grid mehr. Einen Backbeat hat fast jede Musik. +/// 2. **Den Rand erkennen** geht nicht lokal. Ein echtes Stück mit 71 BPM sitzt +/// mit 0,989 · Fensterrand **näher** am Rand als das falsche 66 mit 0,975, +/// und „die Korrelation steigt am Rand noch" trifft 92 und 128 BPM genauso. +/// Die nötige Auskunft — wo die eigentliche Spitze liegt — steht per +/// Konstruktion außerhalb des Fensters. +/// +/// Der Weg dahin führt über die Oktavwahl: Erst wenn die Entscheidung zwischen +/// Periode und halber Periode ohne enges Fenster trägt, darf das Fenster +/// aufgehen. Das ist eine eigene Aufgabe und keine Schwellenkorrektur. +/// +/// **Bis dahin gilt: Material unter 70 BPM bekommt ein Grid, dem nicht zu +/// trauen ist** — nicht „kein Grid", sondern ein falsches. Der Messweg dorthin +/// steht in `docs/FAHRPLAN.md` unter N1. +const MIN_BPM: f64 = 70.0; +const MAX_BPM: f64 = 200.0; + +/// Sanfter Erwartungswert für die Oktavwahl, nur für die Grobsuche. +const PRIOR_CENTER_BPM: f64 = 120.0; +const PRIOR_WIDTH: f64 = 1.2; + +/// Ab welcher Güte die halbe Periode als die eigentliche gilt. Siehe [`fundamental`]. +const FUNDAMENTAL_RATIO: f32 = 0.8; + +/// Wie weit die Autokorrelationsspitze aus dem Feld der übrigen +/// Verschiebungen herausragen muss, damit das Ergebnis als Aussage durchgeht +/// statt als Rauschen. Gemessen in Standardabweichungen — siehe +/// [`coarse_period`], warum nicht als Verhältnis zum Mittelwert. +/// +/// Gemessen: Klick-Tracks 4,2–6,0; ein dichter Loop aus Kick, Bass und Hi-Hats +/// 3,5; ein reiner Dauerton 1,7. Der Wert liegt bewusst näher am unteren Ende +/// — lieber ein zweifelhaftes Grid, das der Nutzer sieht und korrigieren kann, +/// als gar keins. Siehe `schwellen_trennen_perkussiv_von_dauerton`. +/// +/// **An fünf echten Aufnahmen nachgemessen** (August 2026): Sie liegen bei 3,0 +/// bis 4,3 — also über der Schwelle, aber deutlich unter dem, was gebautes +/// Material erreicht. Kein Tempo wurde dabei fälschlich abgewiesen. Der Abstand +/// nach unten ist allerdings dünn, und fünf Aufnahmen sind keine Sammlung. +/// +/// **Wichtiger noch: Die daraus abgeleitete Konfidenz sagt nichts über die +/// Richtigkeit.** Bei denselben fünf Aufnahmen war das Tempo mit 0,12 genauso +/// richtig wie mit 0,46 — nachgeprüft über die Oktavlage und über eine +/// Feinsuche, die in allen fünf Fällen bis auf 0,09 BPM denselben Wert fand, +/// stabil über alle Drittel des Stücks. Wer `bpm_confidence` als Gütesiegel +/// liest, liest etwas hinein, das nicht drinsteht: Sie misst, wie deutlich die +/// Spitze aus dem Feld ragt, nicht ob sie an der richtigen Stelle sitzt. +const MIN_SALIENCE: f32 = 2.5; + +/// Glättung der Hüllkurve für die Grobstufe, in Frames. Macht die +/// Autokorrelation an ganzzahligen Verschiebungen tolerant gegen den halben +/// Frame, um den ein Onset danebenliegen kann. +const SMOOTH: usize = 3; + +#[derive(Debug, Clone, Copy)] +pub struct Beatgrid { + pub bpm: f32, + /// Erster erkannter Beat, in Sample-Frames. + pub anchor_frames: u64, + /// 0..1, abgeleitet aus der Schärfe der Autokorrelationsspitze. + pub confidence: f32, +} + +/// Ermittelt Tempo und ersten Beat. +/// +/// `None`, wenn das Material zu kurz ist oder keine verwertbare Periodizität +/// zeigt — eine geratene Zahl wäre schlechter als keine. +pub fn detect(env: &OnsetEnvelope) -> Option { + let v = &env.values; + let p_short = env.rate * 60.0 / MAX_BPM; + let p_long = env.rate * 60.0 / MIN_BPM; + + if v.len() < (p_long * 4.0) as usize { + return None; + } + if v.iter().all(|x| *x <= 0.0) { + return None; + } + + let smoothed = smooth(v, SMOOTH); + let (coarse, salience) = coarse_period(&smoothed, env.rate, p_short, p_long)?; + + if salience < MIN_SALIENCE { + return None; + } + + let period = fundamental(&smoothed, coarse, p_short); + let (period, phase) = refine(v, period, p_short); + + Some(Beatgrid { + bpm: (env.rate * 60.0 / period) as f32, + anchor_frames: env.to_sample_frames(phase).round().max(0.0) as u64, + confidence: ((salience - MIN_SALIENCE) / 4.0).clamp(0.0, 1.0), + }) +} + +/// Beste Verschiebung nach Autokorrelation, gewichtet mit dem Oktav-Prior. +/// +/// Zweiter Rückgabewert ist die Deutlichkeit: wie viele Standardabweichungen +/// die Spitze über dem Mittel aller Verschiebungen liegt. +/// +/// Naheliegender wäre Spitze durch Mittelwert gewesen, und genau so stand es +/// hier auch — bis dichtes Material es widerlegt hat. Ein Klick-Track ist +/// zwischen den Klicks still, also korreliert er bei fast jeder Verschiebung +/// mit nahezu null und das Verhältnis wird riesig (14–27). Echte Musik hat +/// dagegen durchgehend Energie: die Korrelation hat überall einen hohen +/// Sockel, und das Verhältnis rutscht gegen 1, auch wenn der Beat schnurgerade +/// durchläuft. Ein dichter Loop aus Kick, Bass und Hi-Hats kam so auf 2,06 — +/// und wäre an einer Schwelle von 3,0 hängengeblieben, obwohl das Tempo längst +/// richtig erkannt war. +/// +/// Der z-Wert misst stattdessen, ob **eine** Verschiebung aus dem Feld +/// heraussticht. Ein konstanter Sockel verschiebt Spitze und Mittel gleich +/// weit und kürzt sich heraus. Auf demselben Material trennt er doppelt so +/// deutlich: 3,46 gegen 1,71 statt 2,06 gegen 1,65. +fn coarse_period(v: &[f32], rate: f64, p_short: f64, p_long: f64) -> Option<(f64, f32)> { + let lo = p_short.floor().max(2.0) as usize; + let hi = p_long.ceil() as usize; + if hi <= lo { + return None; + } + + let mut best = 0.0f64; + let mut best_weighted = f32::NEG_INFINITY; + let mut peak = 0.0f32; + let mut korrelationen = Vec::with_capacity(hi - lo + 1); + + for lag in lo..=hi { + let r = autocorrelation(v, lag as f64); + korrelationen.push(r); + peak = peak.max(r); + + let weighted = r * octave_prior(rate * 60.0 / lag as f64) as f32; + if weighted > best_weighted { + best_weighted = weighted; + best = lag as f64; + } + } + + if best == 0.0 || korrelationen.is_empty() { + return None; + } + + let salience = z_wert(peak, &korrelationen); + Some((best, salience)) +} + +/// Abstand eines Wertes vom Mittel, in Standardabweichungen. +/// +/// Null, wenn alle Werte gleich sind — dann sticht nichts heraus, und genau +/// das soll die Zahl aussagen. +fn z_wert(wert: f32, werte: &[f32]) -> f32 { + if werte.is_empty() { + return 0.0; + } + + let n = werte.len() as f64; + let mittel = werte.iter().map(|r| *r as f64).sum::() / n; + let varianz = werte + .iter() + .map(|r| { + let d = *r as f64 - mittel; + d * d + }) + .sum::() + / n; + let sd = varianz.sqrt(); + + if sd > 0.0 { + ((wert as f64 - mittel) / sd) as f32 + } else { + 0.0 + } +} + +/// Steigt von einer Verschiebung zu ihrer Grundperiode ab. +/// +/// Der Kern des Oktavproblems: Die Autokorrelation hat Spitzen bei *jedem* +/// Vielfachen der Beat-Periode. Bei 174 BPM liegt eine bei 174 und eine +/// genauso hohe bei 87 — der Prior allein wählt die falsche, weil 87 näher an +/// 120 liegt. +/// +/// Umgekehrt gilt es nicht: Bei der *halben* Periode liegt ein Tal, nicht eine +/// Spitze. Deshalb wird halbiert, solange die Korrelation dabei erhalten +/// bleibt, und man landet bei der kleinsten Periode der Reihe. +/// +/// Es bleibt eine echte Mehrdeutigkeit: 87-BPM-HipHop mit Hi-Hats auf Achteln +/// wird als 174 erkannt. Genau dafür haben DJ-Programme den +/// Halbieren/Verdoppeln-Knopf, und den brauchen wir auch. +fn fundamental(v: &[f32], start: f64, p_short: f64) -> f64 { + let mut period = start; + let mut score = autocorrelation(v, period); + + loop { + let half = period / 2.0; + if half < p_short { + return period; + } + let half_score = autocorrelation(v, half); + if half_score < FUNDAMENTAL_RATIO * score { + return period; + } + period = half; + score = half_score; + } +} + +/// Feinsuche ±1 Frame per Kammfilter, auf der ungeglätteten Hüllkurve. +/// +/// Hier kann die Oktave nicht mehr kippen, dafür wird die Periode auf zwei +/// Nachkommastellen genau — nötig, damit der Kamm auch am Ende eines langen +/// Tracks noch auf den Beats sitzt. +fn refine(v: &[f32], around: f64, p_short: f64) -> (f64, f64) { + let mut best = (around, 0.0, f32::NEG_INFINITY); + + let mut p = around - 1.0; + while p <= around + 1.0 { + if p >= p_short { + let (score, phase) = comb(v, p); + if score > best.2 { + best = (p, phase, score); + } + } + p += 0.02; + } + + (best.0, best.1) +} + +/// Blockgröße der Zeitbereichs-Nachkorrektur. 64 Samples ≈ 1,5 ms bei 44,1 kHz. +const FINE_HOP: usize = 64; + +/// Zieht den Anker im Zeitbereich nach. +/// +/// Der spektrale Fluss schlägt aus, sobald ein Transient ins Analysefenster +/// *eintritt* — also bis zu einer Fensterlänge, bevor er tatsächlich erklingt. +/// Über die ganze Kette bleibt davon ein systematischer Vorlauf von gut zehn +/// Millisekunden übrig. Bei 128 BPM sind das vier Prozent eines Beats, und ein +/// so verschobenes Grid macht Sync unbrauchbar. +/// +/// Die Korrektur sucht deshalb auf einer feinen Energie-Anstiegskurve nach, +/// und zwar nur innerhalb einer halben Periode um den vorhandenen Anker. Damit +/// kann sie die Phase schärfen, aber nicht auf einen anderen Beat springen. +pub fn refine_anchor(samples: &[f32], sample_rate: u32, grid: Beatgrid) -> Beatgrid { + let period = 60.0 / grid.bpm as f64 * sample_rate as f64; + if !period.is_finite() || period < (FINE_HOP * 4) as f64 { + return grid; + } + + let mono: Vec = samples + .as_chunks::() + .0 + .iter() + .map(|f| 0.5 * (f[0] + f[1])) + .collect(); + + let blocks = mono.len() / FINE_HOP; + if blocks < 8 { + return grid; + } + + let mut rise = Vec::with_capacity(blocks); + let mut prev = 0.0f32; + for b in 0..blocks { + let energy: f32 = mono[b * FINE_HOP..(b + 1) * FINE_HOP] + .iter() + .map(|v| v * v) + .sum(); + rise.push((energy - prev).max(0.0)); + prev = energy; + } + + let period_blocks = period / FINE_HOP as f64; + let start = grid.anchor_frames as f64 / FINE_HOP as f64; + + let mut best = (f32::NEG_INFINITY, start); + let mut offset = -period_blocks / 2.0; + while offset <= period_blocks / 2.0 { + let phase = start + offset; + offset += 1.0; + if phase < 0.0 { + continue; + } + + let mut sum = 0.0f64; + let mut n = 0usize; + let mut t = phase; + while t < (blocks - 1) as f64 { + sum += interpolate(&rise, t) as f64; + n += 1; + t += period_blocks; + } + + if n > 0 { + let score = (sum / n as f64) as f32; + if score > best.0 { + best = (score, phase); + } + } + } + + Beatgrid { + anchor_frames: (best.1 * FINE_HOP as f64).max(0.0).round() as u64, + ..grid + } +} + +fn smooth(v: &[f32], win: usize) -> Vec { + if win <= 1 { + return v.to_vec(); + } + let half = win / 2; + (0..v.len()) + .map(|i| { + let lo = i.saturating_sub(half); + let hi = (i + half + 1).min(v.len()); + v[lo..hi].iter().sum::() / (hi - lo) as f32 + }) + .collect() +} + +fn autocorrelation(v: &[f32], lag: f64) -> f32 { + let count = v.len() as f64 - lag - 1.0; + if count <= 1.0 { + return 0.0; + } + let count = count as usize; + let mut sum = 0.0f64; + for (i, x) in v.iter().enumerate().take(count) { + sum += (*x * interpolate(v, i as f64 + lag)) as f64; + } + (sum / count as f64) as f32 +} + +/// Bevorzugt Tempi in der Nähe von [`PRIOR_CENTER_BPM`] auf einer Log-Skala. +fn octave_prior(bpm: f64) -> f64 { + let d = (bpm / PRIOR_CENTER_BPM).log2() / PRIOR_WIDTH; + (-0.5 * d * d).exp() +} + +/// Legt einen Pulskamm der Periode `period` über die Hüllkurve und sucht die +/// Phase mit der größten Ausbeute. +/// +/// Rückgabe: (mittlerer Wert an den Pulsstellen, Phase in Frames). +fn comb(v: &[f32], period: f64) -> (f32, f64) { + let steps = (period * 2.0).round().max(1.0) as usize; + let mut best = (f32::NEG_INFINITY, 0.0); + + for s in 0..steps { + let phase = s as f64 * period / steps as f64; + let mut sum = 0.0f64; + let mut n = 0usize; + let mut t = phase; + + while t < (v.len() - 1) as f64 { + sum += interpolate(v, t) as f64; + n += 1; + t += period; + } + + if n > 0 { + let score = (sum / n as f64) as f32; + if score > best.0 { + best = (score, phase); + } + } + } + + best +} + +fn interpolate(v: &[f32], t: f64) -> f32 { + let i = t.floor() as usize; + if i + 1 >= v.len() { + return v[v.len() - 1]; + } + let frac = (t - i as f64) as f32; + v[i] + (v[i + 1] - v[i]) * frac +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::onset::onset_envelope; + use crate::testing::click_track; + + const RATE: u32 = 44_100; + + fn erkenne(bpm: f64, offset_secs: f64, secs: f64) -> Beatgrid { + let track = click_track(bpm, RATE, secs, offset_secs); + let env = onset_envelope(&track, RATE); + detect(&env).expect("kein Beatgrid erkannt") + } + + fn deutlichkeit(samples: &[f32]) -> Option { + let env = onset_envelope(samples, RATE); + let sm = smooth(&env.values, SMOOTH); + let p_short = env.rate * 60.0 / MAX_BPM; + let p_long = env.rate * 60.0 / MIN_BPM; + coarse_period(&sm, env.rate, p_short, p_long).map(|(_, sal)| sal) + } + + fn dauerton(secs: f64) -> Vec { + let frames = (RATE as f64 * secs) as usize; + let mut samples = Vec::with_capacity(frames * 2); + for i in 0..frames { + let v = (2.0 * std::f32::consts::PI * 220.0 * i as f32 / RATE as f32).sin() * 0.5; + samples.push(v); + samples.push(v); + } + samples + } + + /// Klicks auf einem durchgehenden Klangteppich. + /// + /// Das ist der Fall, an dem die alte Deutlichkeit gescheitert ist: der Beat + /// ist so gerade wie beim Klick-Track, aber der Ton dazwischen hebt den + /// Sockel der Autokorrelation an. Echte Musik sieht so aus, ein nackter + /// Klick-Track nicht. + fn klicks_auf_teppich(bpm: f64, secs: f64) -> Vec { + let mut samples = click_track(bpm, RATE, secs, 0.0); + let ton = dauerton(secs); + for (s, t) in samples.iter_mut().zip(ton.iter()) { + *s += *t; + } + samples + } + + /// Material knapp über [`MIN_BPM`] — gebaut, mit Kick auf jedem Beat und + /// Snare auf zwei und vier, auf durchgehendem Bass. + fn langsam(bpm: f64, secs: f64) -> Vec { + let je_beat = RATE as f64 * 60.0 / bpm; + let n = (RATE as f64 * secs) as usize; + let mut mono = vec![0.0f32; n]; + // Durchgehender Ton, damit der Sockel der Autokorrelation liegt wie + // bei echter Musik und nicht wie bei einem Klick-Track. + for (i, s) in mono.iter_mut().enumerate() { + let t = i as f32 / RATE as f32; + *s += 0.20 * (std::f32::consts::TAU * 55.0 * t).sin(); + *s += 0.12 * (std::f32::consts::TAU * 220.0 * t).sin(); + } + let mut beat = 0usize; + loop { + let start = (beat as f64 * je_beat) as usize; + if start + RATE as usize / 4 >= n { + break; + } + for i in 0..RATE as usize / 4 { + let t = i as f32 / RATE as f32; + let f = 110.0 * (-t * 30.0).exp() + 45.0; + mono[start + i] += (std::f32::consts::TAU * f * t).sin() * (-t * 22.0).exp() * 0.9; + if beat % 2 == 1 { + let rausch = ((i * 1103515245 + 12345) % 2048) as f32 / 1024.0 - 1.0; + mono[start + i] += rausch * (-t * 40.0).exp() * 0.35; + } + } + beat += 1; + } + mono.iter().flat_map(|v| [*v, *v]).collect() + } + + /// **Langsames Material mit Backbeat kippt nicht auf die halbe Periode.** + /// + /// Der Test steht hier, weil genau das beim Versuch passiert ist, das + /// Suchfenster zu öffnen: Bei Snare auf zwei und vier gewann der + /// Zweitakt-Zyklus, und der Demo-Track mit 124 BPM bekam gar kein Grid + /// mehr. Solange [`MIN_BPM`] das Fenster eng hält, hält auch das hier — + /// wer daran rührt, sieht es sofort. + #[test] + fn langsames_material_mit_backbeat_kippt_nicht_auf_die_haelfte() { + for bpm in [71.0, 75.0, 92.0] { + let grid = detect(&crate::onset::onset_envelope(&langsam(bpm, 40.0), RATE)) + .unwrap_or_else(|| panic!("{bpm} BPM wurde nicht erkannt")); + let ab = (grid.bpm as f64 - bpm).abs(); + assert!( + ab < 1.5, + "{bpm} BPM wurde als {:.2} erkannt — auf die Hälfte gekippt?", + grid.bpm + ); + } + } + + /// Sichert den Abstand ab, auf dem [`MIN_SALIENCE`] beruht. Rutschen die + /// Enden zusammen, ist die Schwelle nicht mehr begründet. + #[test] + fn schwellen_trennen_perkussiv_von_dauerton() { + for bpm in [100.0, 128.0, 174.0] { + let sal = deutlichkeit(&click_track(bpm, RATE, 25.0, 0.0)) + .unwrap_or_else(|| panic!("keine Periode bei {bpm} BPM")); + assert!( + sal > 4.0, + "Klick-Track bei {bpm} BPM nur bei Deutlichkeit {sal:.2}" + ); + } + + let sal = deutlichkeit(&dauerton(20.0)).unwrap_or(0.0); + assert!( + sal < MIN_SALIENCE, + "Dauerton erreicht Deutlichkeit {sal:.2} und käme durch" + ); + } + + /// Der eigentliche Grund für den z-Wert: dichtes Material muss durchkommen. + /// + /// Mit Spitze-durch-Mittelwert lag genau dieser Fall bei rund 2 und wurde + /// abgewiesen, obwohl das Tempo richtig erkannt war. + #[test] + fn ein_durchgehender_klangteppich_verdeckt_den_beat_nicht() { + for bpm in [100.0, 128.0] { + let samples = klicks_auf_teppich(bpm, 25.0); + + let sal = + deutlichkeit(&samples).unwrap_or_else(|| panic!("keine Periode bei {bpm} BPM")); + assert!( + sal > MIN_SALIENCE, + "Beat unter Dauerton nur bei Deutlichkeit {sal:.2} — käme nicht durch" + ); + + let grid = detect(&onset_envelope(&samples, RATE)) + .unwrap_or_else(|| panic!("kein Grid bei {bpm} BPM")); + assert!( + (grid.bpm as f64 - bpm).abs() < 1.0, + "{} statt {bpm} BPM", + grid.bpm + ); + } + } + + #[test] + fn erkennt_gaengige_tempi() { + for bpm in [100.0, 128.0, 140.0, 174.0] { + let grid = erkenne(bpm, 0.0, 25.0); + assert!( + (grid.bpm as f64 - bpm).abs() < 1.0, + "erwartet {bpm} BPM, erkannt {:.2}", + grid.bpm + ); + } + } + + /// Abstand des Ankers zum nächstgelegenen echten Beat, in Sekunden. + /// Der Anker darf auf jedem Beat sitzen, nur eben genau. + fn rasterfehler(grid: &Beatgrid, bpm: f64, offset: f64) -> f64 { + let period = 60.0 / bpm; + let anchor = grid.anchor_frames as f64 / RATE as f64; + let versatz = ((anchor - offset) / period).rem_euclid(1.0); + versatz.min(1.0 - versatz) * period + } + + #[test] + fn findet_den_ersten_beat() { + let offset = 0.3; + let grid = erkenne(128.0, offset, 25.0); + let fehler = rasterfehler(&grid, 128.0, offset); + + assert!( + fehler < 0.025, + "Anker liegt {:.1} ms neben dem Raster", + fehler * 1000.0 + ); + } + + #[test] + fn nachkorrektur_schaerft_den_anker() { + let offset = 0.4; + let samples = click_track(128.0, RATE, 25.0, offset); + let env = onset_envelope(&samples, RATE); + let roh = detect(&env).expect("kein Beatgrid"); + let fein = refine_anchor(&samples, RATE, roh); + + let vorher = rasterfehler(&roh, 128.0, offset); + let nachher = rasterfehler(&fein, 128.0, offset); + + assert!( + nachher < 0.005, + "nach der Korrektur immer noch {:.1} ms daneben", + nachher * 1000.0 + ); + assert!( + nachher <= vorher, + "Korrektur hat verschlechtert: {:.1} ms → {:.1} ms", + vorher * 1000.0, + nachher * 1000.0 + ); + } + + #[test] + fn nachkorrektur_springt_nicht_auf_einen_anderen_beat() { + let offset = 0.4; + let samples = click_track(128.0, RATE, 25.0, offset); + let env = onset_envelope(&samples, RATE); + let roh = detect(&env).expect("kein Beatgrid"); + let fein = refine_anchor(&samples, RATE, roh); + + let period = 60.0 / 128.0 * RATE as f64; + let sprung = (fein.anchor_frames as f64 - roh.anchor_frames as f64).abs(); + assert!( + sprung <= period / 2.0 + 1.0, + "Anker ist um {sprung:.0} Frames gewandert, mehr als eine halbe Periode" + ); + assert_eq!(fein.bpm, roh.bpm, "Tempo darf sich nicht ändern"); + } + + #[test] + fn waehlt_die_grundperiode_statt_der_haelfte() { + // 87 BPM korreliert bei 174er Material genauso stark wie 174 selbst. + // Der Abstieg muss die kleinere Periode liefern. + let grid = erkenne(174.0, 0.0, 25.0); + assert!( + grid.bpm > 150.0, + "in der halben Frequenz hängengeblieben: {:.1} BPM", + grid.bpm + ); + } + + #[test] + fn verdoppelt_nicht_ueber_das_material_hinaus() { + // Bei 100 BPM läge 200 noch im Suchbereich, dort ist aber ein Tal. + let grid = erkenne(100.0, 0.0, 25.0); + assert!( + (grid.bpm - 100.0).abs() < 1.0, + "fälschlich verdoppelt: {:.1} BPM", + grid.bpm + ); + } + + #[test] + fn zu_kurzes_material_ergibt_none() { + let track = click_track(128.0, RATE, 1.0, 0.0); + let env = onset_envelope(&track, RATE); + assert!(detect(&env).is_none()); + } + + #[test] + fn stille_ergibt_none() { + let env = onset_envelope(&vec![0.0; RATE as usize * 2 * 30], RATE); + assert!(detect(&env).is_none()); + } +} diff --git a/crates/analysis/src/testing.rs b/crates/analysis/src/testing.rs new file mode 100644 index 0000000..2084ff0 --- /dev/null +++ b/crates/analysis/src/testing.rs @@ -0,0 +1,162 @@ +//! Testsignale für die Analyse. + +use audio_core::track::CHANNELS; + +/// Samplerate der harmonischen Testsignale. +const TON_RATE: u32 = 44_100; + +/// Ein Ton mit ein paar Obertönen — ein nackter Sinus wäre unrealistisch +/// einfach, weil er genau einen Bin trifft. +fn ton(halbton_ueber_c4: i32, sekunden: f32, ziel: &mut Vec) { + let grund = 261.63 * 2.0f32.powf(halbton_ueber_c4 as f32 / 12.0); + let n = (TON_RATE as f32 * sekunden) as usize; + let vorher = ziel.len() / CHANNELS; + + ziel.resize((vorher + n) * CHANNELS, 0.0); + for i in 0..n { + let t = i as f32 / TON_RATE as f32; + let mut wert = 0.0; + for (ober, staerke) in [(1.0, 1.0), (2.0, 0.5), (3.0, 0.3), (4.0, 0.15)] { + wert += (std::f32::consts::TAU * grund * ober * t).sin() * staerke; + } + // Weich ein- und ausblenden, sonst erzeugt der Sprung Breitband. + let huelle = (t * 20.0).min(1.0).min((sekunden - t) * 20.0).max(0.0); + let v = wert * huelle * 0.2; + let platz = (vorher + i) * CHANNELS; + ziel[platz] = v; + ziel[platz + 1] = v; + } +} + +/// Ein Beat mit Kick und einer Bassfigur — der schwere Fall für die Tonart. +/// +/// So klingt Clubmusik: Die ganze Energie liegt unten, harmonisch trägt allein +/// der Bass, und der Kick schmiert breitbandig darüber. Genau daran ist die +/// Erkennung zuerst gescheitert, deshalb steht das Material hier und nicht als +/// Zierde in einem einzelnen Test. +/// +/// `noten` sind Halbtöne über C2 (65,4 Hz), je einen Takt lang. +pub fn bass_mit_kick(noten: &[i32], takte: usize) -> Vec { + const RATE: f32 = 44_100.0; + let je_takt = (RATE * 2.0) as usize; + let mut mono = vec![0.0f32; je_takt * takte]; + + for takt in 0..takte { + let note = noten[takt % noten.len()]; + let hz = 65.41 * 2.0f32.powf(note as f32 / 12.0); + + for schlag in 0..4 { + let start = takt * je_takt + schlag * je_takt / 4; + + // Kick: ein Sinus, der von 110 Hz nach unten rutscht. + for i in 0..(RATE * 0.25) as usize { + if start + i >= mono.len() { + break; + } + let t = i as f32 / RATE; + let f = 110.0 * (-t * 30.0).exp() + 45.0; + mono[start + i] += (std::f32::consts::TAU * f * t).sin() * (-t * 22.0).exp() * 0.9; + } + + // Bass: ein Sägezahn, also alle Teiltöne mit 1/n. + for i in 0..(RATE * 0.4) as usize { + if start + i >= mono.len() { + break; + } + let t = i as f32 / RATE; + let huelle = (1.0 - (-t * 120.0).exp()) * (-t * 5.0).exp(); + mono[start + i] += (2.0 * ((hz * t) % 1.0) - 1.0) * huelle * 0.3; + } + } + } + + mono.into_iter().flat_map(|v| [v, v]).collect() +} + +/// Eine Akkordfolge in einer Tonart, um `versatz` Halbtöne verschoben. +/// +/// **Mit Bass und mit längerem Grundakkord**, und das ist nicht Zierde: +/// Dur und die parallele Molltonart bestehen aus denselben zwölf Tönen. +/// Was sie unterscheidet, ist die Gewichtung — welcher Ton trägt, wie +/// lange der Grundakkord steht, was im Bass liegt. Eine Folge mit +/// gleichlangen Akkorden und ohne Bass ist deshalb kein Prüfstein, +/// sondern eine Fangfrage: Sie hat schlicht keine eindeutige Tonart. +pub fn akkordfolge(versatz: i32, moll: bool) -> Vec { + // I–V–vi–IV in Dur, i–VI–III–VII in Moll — beides Allerweltsfolgen. + // Die Zahl dahinter ist die Länge in Sekunden; der Grundakkord steht + // länger, wie in echter Musik auch. + let stufen: &[(&[i32], f32)] = if moll { + &[ + (&[0, 3, 7], 2.0), + (&[8, 12, 15], 1.0), + (&[3, 7, 10], 1.0), + (&[10, 14, 17], 1.0), + ] + } else { + &[ + (&[0, 4, 7], 2.0), + (&[7, 11, 14], 1.0), + (&[9, 12, 16], 1.0), + (&[5, 9, 12], 1.0), + ] + }; + + let mut aus = Vec::new(); + for _ in 0..2 { + for (akkord, dauer) in stufen { + let start = aus.len(); + // Bass auf dem Grundton, eine Oktave tiefer — er sagt dem Ohr, + // welcher Ton der Bezugspunkt ist. + let mut stimmen: Vec = vec![akkord[0] - 12]; + stimmen.extend_from_slice(akkord); + + for note in stimmen { + let mut stimme = Vec::new(); + ton(note + versatz, *dauer, &mut stimme); + if aus.len() < start + stimme.len() { + aus.resize(start + stimme.len(), 0.0); + } + for (i, v) in stimme.iter().enumerate() { + aus[start + i] += v; + } + } + } + } + aus +} + +/// Erzeugt einen Klick-Track als interleaved Stereo. +/// +/// Die Klicks sind kurze, deterministisch erzeugte Rauschstöße mit +/// exponentiellem Abfall — breitbandig genug, damit der spektrale Fluss sie +/// deutlich sieht, und ohne Abhängigkeit auf einen Zufallsgenerator. +pub fn click_track(bpm: f64, sample_rate: u32, secs: f64, offset_secs: f64) -> Vec { + let frames = (sample_rate as f64 * secs) as usize; + let mut out = vec![0.0f32; frames * CHANNELS]; + + let period = 60.0 / bpm * sample_rate as f64; + let burst = ((sample_rate as f64 * 0.008) as usize).max(1); + let decay = burst as f32 * 0.25; + + let mut seed = 0x1234_5678u32; + let mut at = offset_secs * sample_rate as f64; + + while (at as usize) < frames { + let start = at as usize; + for i in 0..burst { + let idx = start + i; + if idx >= frames { + break; + } + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let noise = (seed >> 8) as f32 / 8_388_608.0 - 1.0; + let env = (-(i as f32) / decay).exp(); + let v = noise * env * 0.8; + out[idx * CHANNELS] += v; + out[idx * CHANNELS + 1] += v; + } + at += period; + } + + out +} diff --git a/crates/analysis/src/tonart.rs b/crates/analysis/src/tonart.rs new file mode 100644 index 0000000..961d676 --- /dev/null +++ b/crates/analysis/src/tonart.rs @@ -0,0 +1,515 @@ +//! Tonarterkennung. +//! +//! Nach dem Tempo die zweite Hälfte der Trackauswahl: Zwei Stücke im gleichen +//! Takt können trotzdem gegeneinander klingen, wenn ihre Tonarten nicht +//! zusammenpassen. Wer harmonisch mischt, braucht sie. +//! +//! **Warum selbst gebaut.** Die beiden verbreiteten Verfahren — libKeyFinder +//! und der QM Key Detector — stehen unter GPL, und das schlösse den Weg zu +//! VibeMind (MIT, zur Veröffentlichung vorgesehen) wieder zu. Siehe +//! `docs/BAUSTEINE.md`. Also der klassische Weg von Hand: +//! +//! 1. **Chroma.** Das Spektrum wird auf zwölf Halbtonklassen gefaltet — alle +//! Oktaven eines Tons landen im selben Fach. Was übrig bleibt, ist die +//! Tonverteilung ohne Lage und ohne Klangfarbe. +//! 2. **Vergleich.** Diese Verteilung wird gegen vierundzwanzig Profile +//! korreliert: zwölf Dur- und zwölf Moll-Tonarten. Die Profile stammen von +//! Krumhansl und Kessler (1982) — Messwerte aus Hörversuchen, wie gut ein +//! Ton in eine Tonart passt. +//! 3. **Konfidenz** aus der Güte der besten Anpassung. Ein Stück ohne klare +//! Tonart soll das sagen, statt eine zu erfinden. +//! +//! Der Wert selbst — Name, Camelot-Notation, harmonische Nachbarschaft — +//! liegt in [`audio_core::Tonart`]. Hier steht nur, wie man ihn aus Audio +//! gewinnt. + +use rustfft::{num_complex::Complex, FftPlanner}; + +use audio_core::track::CHANNELS; +pub use audio_core::Tonart; + +/// Fenster für die Chroma-Analyse. +/// +/// Deutlich länger als beim Onset (1024): Dort geht es um zeitliche Schärfe, +/// hier um Frequenzauflösung. Wie weit sie reicht, steht bei [`MIN_HZ`] — sie +/// ist der Grund für die untere Grenze. +pub const FENSTER: usize = 8192; +pub const SPRUNG: usize = 4096; + +/// Unterhalb dieser Anpassungsgüte sieht die Tonverteilung nach gar keiner +/// Tonart aus. +/// +/// **Gemessen wird die Güte, nicht der Vorsprung.** Der naheliegende Weg wäre +/// der Abstand zum zweitbesten Treffer — der taugt aber nicht: Perkussion kam +/// damit auf 0,062 und eine echte Moll-Folge auf 0,088, also praktisch +/// dasselbe. +/// +/// **Der Wert ist an echten Aufnahmen geeicht, nicht an synthetischen.** Das +/// ist der Unterschied, an dem die erste Fassung danebenlag: Selbstgebaute +/// Akkordfolgen erreichen 0,92 bis 0,95, echte Aufnahmen kommen über 0,82 kaum +/// hinaus. Eine an synthetischem Material geeichte Schwelle von 0,80 wies +/// vier von fünf echten Tracks ab — dieselbe Falle wie beim Tempo, wo eine an +/// Klick-Tracks geeichte Schwelle echte Musik verwarf. +/// +/// Geeicht an fünf Aufnahmen, gegengeprüft über die Stabilität in +/// 30-Sekunden-Abschnitten. Eine echte Tonart bleibt über den Track stehen, +/// eine erfundene springt: +/// +/// | r | Abschnitte einig | Urteil | +/// |------|------------------|--------| +/// | 0,81 | 4 von 4 | Tonart | +/// | 0,79 | 5 von 7 | Tonart | +/// | 0,77 | 6 von 7 | Tonart | +/// | 0,71 | 4 von 6, springt | keine | +/// | 0,56 | 4 von 8, springt | keine | +/// +/// **Warum echte Musik niedriger liegt, ist nicht, wofür man es hält.** Nicht +/// Rauschen und nicht Perkussion: Gleichmäßiges Rauschen hebt alle zwölf +/// Klassen um denselben Betrag an, und eine Korrelation ist gegen so einen +/// Offset unempfindlich — gemessen sinkt die Güte selbst bei grobem Rauschen +/// und lauten Drums nur von 0,95 auf 0,90. Was sie drückt, ist **fremde +/// Harmonik**: Töne außerhalb der Tonart, Zwischenteile in anderen Tonarten, +/// Stimmen mit gleitender Tonhöhe. Eine Aufnahme bei 0,75 ist also keine +/// schlechte Aufnahme, sondern eine harmonisch bewegte. +/// +/// **Diese Schwelle lässt sich nicht durch einen Test bewachen.** Um sie zu +/// prüfen, bräuchte es synthetisches Material im Bereich echter Musik, und +/// genau das ist nicht herstellbar: Selbst eine Tritonus-Modulation über ein +/// Drittel der Länge kommt nur auf 0,864, weil Akkorde aus sauberen +/// Obertonreihen über einem starren Halbtonraster immer zu gut korrelieren. +/// Was hier bewacht wird, ist die Tabelle oben — wer den Wert ändert, muss +/// gegen echte Aufnahmen nachmessen. +/// +/// **Fünf Aufnahmen sind eine dünne Grundlage.** Der Abstand zwischen 0,71 und +/// 0,77 ist schmal, und mehr Material würde ihn schärfen. In dieser Richtung +/// zu irren ist aber die richtige: Ein Track ohne Angabe fehlt in der +/// harmonischen Suche, ein Track mit falscher Angabe führt beim Auflegen in +/// den Griff daneben. +const MIN_PASSUNG: f32 = 0.72; + +/// Wie stark die Terz mindestens gegenüber dem Grundton stehen muss. +/// +/// **Die Terz entscheidet über Dur und Moll, also muss sie klingen.** Ein +/// Sägezahn-Bass bringt über seinen fünften Teilton eine große Terz mit, ohne +/// dass sie jemand gespielt hätte; die Erkennung liest daraus verlässlich Dur. +/// Bei einer Bassfigur auf A-A-D-C kommt so „A" statt „Am" heraus — der +/// Grundton stimmt, aber auf dem Camelot-Rad ist das 11B statt 8A und damit +/// der Unterschied zwischen passt und passt nicht. +/// +/// Der Teilton verrät sich durch seine Schwäche. Gemessen als Verhältnis +/// Terz zu Grundton: +/// +/// | Material | Deutung | Terz | +/// |-----------------------------------|---------|------| +/// | Akkordfolge in Dur | richtig | 0,64 | +/// | Akkordfolge in Moll | richtig | 0,95 | +/// | Demo-Deck A (Drums, Bass, Fläche) | richtig | 0,59 | +/// | Demo-Deck B (Drums, Bass, Fläche) | richtig | 0,90 | +/// | Bassfigur A-A-D-C, keine Akkorde | **falsch** | 0,36 | +/// | Bassfigur E-E-A-G, keine Akkorde | **falsch** | 0,44 | +/// +/// Wer nur Bass und Drums hat, bekommt deshalb **gar keine** Tonart. Das +/// kostet Treffer und vermeidet Fehlgriffe; in diese Richtung zu irren ist +/// die richtige. +/// +/// Beide Schwellen sind an synthetischem Material gemessen und gegen eine +/// echte Sammlung nie geprüft — derselbe Vorbehalt wie beim Tempo. +/// +/// Der Vorsprung vor der zweitbesten Deutung bleibt bewusst ungeprüft: Er ist +/// gerade zwischen einer Tonart und ihrer Paralleltonart klein, und deren +/// Verwechslung schadet beim Mischen nicht — auf dem Camelot-Rad tragen beide +/// dieselbe Zahl. +const MIN_TERZ: f32 = 0.5; + +/// Tiefster und höchster berücksichtigter Ton. +/// +/// **Die untere Grenze ist keine Geschmacksfrage, sondern Arithmetik.** Ein +/// Bin ist bei 8192 Punkten und 44,1 kHz 5,4 Hz breit; ein Halbton ist bei +/// C2 (65 Hz) nur 3,9 Hz breit. Unterhalb von rund 200 Hz fallen also mehrere +/// Halbtöne in denselben Bin, und die Zuordnung wird zur Münze — ausgerechnet +/// dort, wo Kick und Bass die meiste Energie haben. Erst ab etwa 200 Hz +/// kommen zwei Bins auf einen Halbton. +/// +/// Der Bass geht dadurch nicht verloren: Seine Obertöne liegen im Band, und +/// [`HARMONISCH`] rechnet sie auf den Grundton zurück. +const MIN_HZ: f32 = 200.0; +/// Darüber sitzen vor allem Obertöne und Becken. +const MAX_HZ: f32 = 2_000.0; + +/// Wie viele Obertöne auf ihren Grundton zurückgerechnet werden. +/// +/// Ohne das liest ein einzelner Bass-Ton wie ein Akkord: Eine Sägezahnwelle +/// auf A hat Teiltöne auf A, E, C#, G — also fast einen A-Dur-Septakkord, und +/// die Erkennung nimmt ihn für bare Münze. Deshalb bekommt jeder Bin nicht nur +/// seine eigene Halbtonklasse, sondern auch die der möglichen Grundtöne +/// darunter, mit fallendem Gewicht. +/// +/// Sechs Teiltöne, weil der siebte 0,31 Halbtöne neben dem Raster liegt und +/// damit mehr Unsinn stiftet als Nutzen (das Verfahren geht auf Gómez 2006 +/// zurück, die HPCP mit „harmonic contribution"). +const HARMONISCH: usize = 6; +const ABFALL: f32 = 0.6; + +/// Krumhansl-Kessler-Profile: wie gut jeder Halbton in eine Dur- bzw. +/// Molltonart passt, gemessen in Hörversuchen (1982). +const DUR: [f32; 12] = [ + 6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88, +]; +const MOLL: [f32; 12] = [ + 6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17, +]; + +#[derive(Debug, Clone, Copy)] +pub struct Ergebnis { + pub tonart: Tonart, + /// Wie gut die Tonverteilung auf das beste Profil passt, 0..1. + pub konfidenz: f32, +} + +/// Ermittelt die Tonart aus interleaved Stereo. +/// +/// `None`, wenn das Material zu kurz ist oder keine klare Tonart zeigt — +/// Perkussion, Rauschen, eine Aufnahme ohne Harmonik, ein Bass ohne Akkorde. +/// Eine geratene Tonart wäre beim harmonischen Mischen schlimmer als keine. +pub fn erkenne(samples: &[f32], sample_rate: u32) -> Option { + let chroma = chroma(samples, sample_rate)?; + let (tonart, konfidenz) = beste_tonart(&chroma); + + // Zwei Fragen, zwei Schwellen: Sieht das überhaupt nach einer Tonart aus, + // und klingt die Terz, die über Dur und Moll entscheidet? + let taugt = konfidenz >= MIN_PASSUNG && terz_anteil(&chroma, tonart) >= MIN_TERZ; + taugt.then_some(Ergebnis { tonart, konfidenz }) +} + +/// Wie stark die Terz gegenüber dem Grundton steht — siehe [`MIN_TERZ`]. +fn terz_anteil(chroma: &[f32; 12], tonart: Tonart) -> f32 { + let grund = chroma[tonart.grundton as usize % 12]; + let terz = chroma[(tonart.grundton as usize + if tonart.dur { 4 } else { 3 }) % 12]; + if grund > 0.0 { + terz / grund + } else { + 0.0 + } +} + +/// Die zwölf Halbtonklassen, aufsummiert über den ganzen Track. +pub fn chroma(samples: &[f32], sample_rate: u32) -> Option<[f32; 12]> { + let mono: Vec = samples + .as_chunks::() + .0 + .iter() + .map(|f| f.iter().sum::() / CHANNELS as f32) + .collect(); + if mono.len() < FENSTER { + return None; + } + + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(FENSTER); + let fenster = hann(FENSTER); + + // Welche Halbtonklassen bekommt welcher Bin, und mit welchem Gewicht? + // Einmal vorab, statt in jeder Runde Logarithmen je Bin zu rechnen. + let bin_klasse = bin_zuordnung(sample_rate); + let oberton = oberton_gewichte(); + + let mut chroma = [0.0f32; 12]; + let mut puffer: Vec> = vec![Complex::new(0.0, 0.0); FENSTER]; + let mut start = 0; + + while start + FENSTER <= mono.len() { + for (i, platz) in puffer.iter_mut().enumerate() { + *platz = Complex::new(mono[start + i] * fenster[i], 0.0); + } + fft.process(&mut puffer); + + for (bin, klasse) in bin_klasse.iter().enumerate() { + let Some(k) = klasse else { continue }; + let staerke = puffer[bin].norm(); + // Der Bin könnte der erste, zweite, dritte … Teilton sein. Jede + // Möglichkeit bekommt eine Stimme, die weiter unten leiser wird. + for (versatz, gewicht) in &oberton { + let ziel = (*k as i32 - versatz).rem_euclid(12) as usize; + chroma[ziel] += staerke * gewicht; + } + } + start += SPRUNG; + } + + let summe: f32 = chroma.iter().sum(); + if summe <= 0.0 { + return None; + } + for wert in &mut chroma { + *wert /= summe; + } + Some(chroma) +} + +/// Für jeden Teilton: um wie viele Halbtöne der Grundton darunter liegt, und +/// wie stark er gewichtet wird. +/// +/// Der `n`-te Teilton liegt `12·log2(n)` Halbtöne über seinem Grundton. Das +/// ist nur für Zweierpotenzen glatt; gerundet wird auf das Halbtonraster, und +/// der größte Fehler bei sechs Teiltönen sind 0,14 Halbtöne (beim fünften). +fn oberton_gewichte() -> Vec<(i32, f32)> { + (1..=HARMONISCH) + .map(|n| { + let halbtoene = (12.0 * (n as f32).log2()).round() as i32; + (halbtoene, ABFALL.powi(n as i32 - 1)) + }) + .collect() +} + +/// Ordnet jedem FFT-Bin eine Halbtonklasse zu — oder keine. +fn bin_zuordnung(sample_rate: u32) -> Vec> { + (0..FENSTER / 2) + .map(|bin| { + let hz = bin as f32 * sample_rate as f32 / FENSTER as f32; + if !(MIN_HZ..=MAX_HZ).contains(&hz) { + return None; + } + // Halbtöne über A4 = 440 Hz gezählt, dann auf C bezogen. + let halbtoene = 12.0 * (hz / 440.0).log2(); + let klasse = (halbtoene.round() as i32 + 9).rem_euclid(12); + Some(klasse as u8) + }) + .collect() +} + +/// Korreliert die Verteilung mit allen vierundzwanzig Profilen. +/// +/// Zurück kommt die beste Deutung und wie gut sie passt — nicht, wie weit sie +/// vor der zweitbesten liegt. Warum, steht bei [`MIN_PASSUNG`]. +fn beste_tonart(chroma: &[f32; 12]) -> (Tonart, f32) { + let mut beste = (Tonart::neu(0, true), f32::MIN); + + for grundton in 0..12u8 { + for dur in [true, false] { + let profil = if dur { &DUR } else { &MOLL }; + // Das Profil wird gedreht, statt das Chroma zu drehen — dasselbe + // Ergebnis, aber der Grundton bleibt lesbar. + let gedreht: Vec = (0..12) + .map(|i| profil[(i + 12 - grundton as usize) % 12]) + .collect(); + + let r = korrelation(chroma, &gedreht); + if r > beste.1 { + beste = (Tonart { grundton, dur }, r); + } + } + } + + (beste.0, beste.1.clamp(0.0, 1.0)) +} + +fn korrelation(a: &[f32], b: &[f32]) -> f32 { + let n = a.len() as f32; + let mittel_a = a.iter().sum::() / n; + let mittel_b = b.iter().sum::() / n; + + let mut oben = 0.0; + let mut links = 0.0; + let mut rechts = 0.0; + for (x, y) in a.iter().zip(b) { + let dx = x - mittel_a; + let dy = y - mittel_b; + oben += dx * dy; + links += dx * dx; + rechts += dy * dy; + } + + let unten = (links * rechts).sqrt(); + if unten > 0.0 { + oben / unten + } else { + 0.0 + } +} + +fn hann(n: usize) -> Vec { + (0..n) + .map(|i| { + let x = std::f32::consts::TAU * i as f32 / n as f32; + 0.5 - 0.5 * x.cos() + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::akkordfolge; + + const RATE: u32 = 44_100; + + #[test] + fn eine_dur_folge_wird_als_dur_erkannt() { + let ergebnis = erkenne(&akkordfolge(0, false), RATE).expect("keine Tonart erkannt"); + assert_eq!(ergebnis.tonart.name(), "C", "erkannt: {:?}", ergebnis); + assert!(ergebnis.tonart.dur); + } + + #[test] + fn eine_moll_folge_wird_als_moll_erkannt() { + let ergebnis = erkenne(&akkordfolge(9, true), RATE).expect("keine Tonart erkannt"); + assert_eq!(ergebnis.tonart.name(), "Am", "erkannt: {:?}", ergebnis); + assert!(!ergebnis.tonart.dur); + } + + /// Der eigentliche Beweis, dass die Kette stimmt. + /// + /// Dieselbe Musik um drei Halbtöne verschoben muss dieselbe Verschiebung + /// im Ergebnis zeigen. Das prüft die ganze Zuordnung von Frequenz zu + /// Halbtonklasse, ohne dass irgendwo eine Wahrheit hinterlegt sein müsste. + #[test] + fn eine_verschobene_folge_verschiebt_die_tonart_mit() { + for versatz in [1, 3, 5, 7] { + let ergebnis = + erkenne(&akkordfolge(versatz, false), RATE).expect("keine Tonart erkannt"); + let erwartet = (versatz as u8) % 12; + assert_eq!( + ergebnis.tonart.grundton, + erwartet, + "um {versatz} verschoben ergibt {}", + ergebnis.tonart.name() + ); + } + } + + /// Bass und Drums allein bekommen keine Tonart. + /// + /// Das ist der Fall, an dem die erste Fassung sichtbar gescheitert ist: Eine + /// Bassfigur auf A, A, D, C — a-Moll bzw. dessen Paralleltonart C-Dur — + /// wurde als **cis-Moll** gelesen, und das mit hoher Konfidenz. + /// + /// Der Grundton stimmt inzwischen, das Tongeschlecht nicht: Der Sägezahn + /// bringt seine eigene große Terz mit, und daraus wird verlässlich Dur. + /// „A" statt „Am" ist auf dem Camelot-Rad 11B statt 8A und damit ein + /// Fehlgriff. Abgefangen wird das von [`MIN_TERZ`], nicht von der + /// Anpassungsgüte — die liegt hier bei 0,85 und damit im Bereich echter + /// Stücke. + #[test] + fn bass_und_drums_allein_bekommen_keine_tonart() { + // Halbtöne über C2: A, A, D, C. + let samples = crate::testing::bass_mit_kick(&[9, 9, 2, 0], 8); + let ergebnis = erkenne(&samples, RATE); + assert!( + ergebnis.is_none(), + "Tonart aus einer Bassfigur behauptet: {:?}", + ergebnis.map(|e| (e.tonart.name(), e.konfidenz)) + ); + + // Und zwar an der Terz, nicht an der Güte — sonst würde der Test auch + // grün bleiben, wenn die Schwelle irgendwann alles Tonale mit + // aussperrt. + let chroma = chroma(&samples, RATE).expect("Chroma"); + let (tonart, guete) = beste_tonart(&chroma); + assert!( + guete >= MIN_PASSUNG, + "die Güte allein hätte schon abgelehnt: {guete:.3}" + ); + assert!( + terz_anteil(&chroma, tonart) < MIN_TERZ, + "die Terz steht bei {:.2} und hätte durchgelassen", + terz_anteil(&chroma, tonart) + ); + } + + /// Der Grundton stimmt — auch wenn er allein nicht ausgereicht hat. + /// + /// Ohne diesen Test wäre nicht belegt, dass die Erkennung an solchem + /// Material überhaupt etwas Richtiges sieht und nur zu Recht schweigt. + /// Geprüft wird deshalb unterhalb der Schwelle, direkt auf der Deutung. + #[test] + fn eine_bassfigur_trifft_wenigstens_ihren_grundton() { + for (noten, grundton, name) in [(&[9, 9, 2, 0], 9u8, "A"), (&[16, 16, 9, 7], 4u8, "E")] { + let chroma = chroma(&crate::testing::bass_mit_kick(noten, 8), RATE).expect("Chroma"); + let (tonart, _) = beste_tonart(&chroma); + assert_eq!( + tonart.grundton, + grundton, + "Bass auf {name} wird als {} gedeutet", + tonart.name() + ); + } + } + + /// Was die Obertonrückrechnung bringt, in Zahlen. + /// + /// Ein Sägezahn-Bass legt auf jede gespielte Note gleich eine halbe + /// Obertonreihe. Ohne [`HARMONISCH`] stehen deshalb Töne, die nie gespielt + /// wurden, über solchen, die es wurden: Bei A-A-D-C rutschte das gespielte + /// D hinter ein G#, das im Stück nicht vorkommt. Zurückgerechnet stehen + /// alle drei gespielten Töne wieder über dem Mittel der übrigen. + #[test] + fn gespielte_toene_stehen_ueber_den_obertoenen() { + // Halbtöne über C2: A, A, D, C. + let gespielt = [9usize, 2, 0]; + let chroma = + chroma(&crate::testing::bass_mit_kick(&[9, 9, 2, 0], 8), RATE).expect("Chroma"); + + let uebrig: f32 = (0..12) + .filter(|i| !gespielt.contains(i)) + .map(|i| chroma[i]) + .sum::() + / (12 - gespielt.len()) as f32; + + for ton in gespielt { + assert!( + chroma[ton] > uebrig, + "{} steht mit {:.3} unter dem Mittel der ungespielten Töne ({uebrig:.3}); Chroma {chroma:?}", + audio_core::tonart::TOENE[ton], + chroma[ton] + ); + } + } + + /// Der Grund für [`MIN_PASSUNG`], als Test festgehalten. + /// + /// Der Vorsprung vor der zweitbesten Deutung trennt tonales von + /// untonalem Material **nicht**: Perkussion lag damit über einer echten + /// Moll-Folge. Die Anpassungsgüte trennt sauber, und dieser Test hielte + /// eine Rückkehr zum alten Maß auf. + #[test] + fn die_passung_trennt_tonales_von_perkussion() { + let tonal = chroma(&akkordfolge(0, false), RATE).expect("kein Chroma"); + let (_, gut) = beste_tonart(&tonal); + + let perkussiv = chroma(&crate::testing::click_track(128.0, RATE, 20.0, 0.0), RATE) + .expect("kein Chroma"); + let (_, schlecht) = beste_tonart(&perkussiv); + + assert!(gut >= MIN_PASSUNG, "Akkordfolge nur {gut:.3}"); + assert!(schlecht < MIN_PASSUNG, "Perkussion schon {schlecht:.3}"); + assert!( + gut - schlecht > 0.2, + "die beiden liegen zu dicht: {gut:.3} gegen {schlecht:.3}" + ); + } + + #[test] + fn rauschen_bekommt_keine_tonart_angedichtet() { + let mut seed = 0x1234_5678u32; + let samples: Vec = (0..RATE as usize * 8) + .flat_map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let v = (seed >> 8) as f32 / 8_388_608.0 - 1.0; + [v * 0.3, v * 0.3] + }) + .collect(); + + let ergebnis = erkenne(&samples, RATE); + assert!( + ergebnis.is_none(), + "Rauschen wurde als {:?} gedeutet", + ergebnis.map(|e| e.tonart.name()) + ); + } + + #[test] + fn zu_kurzes_material_ergibt_none() { + assert!(erkenne(&[0.1; 200], RATE).is_none()); + assert!(erkenne(&[], RATE).is_none()); + } +} diff --git a/crates/analysis/src/wechsel.rs b/crates/analysis/src/wechsel.rs new file mode 100644 index 0000000..a6d4bd2 --- /dev/null +++ b/crates/analysis/src/wechsel.rs @@ -0,0 +1,271 @@ +//! Wo im Mitschnitt ein Übergang liegt — allein aus dem Klang. +//! +//! Das ist die Schätzung, gegen die alles andere gehalten wird. Sie weiß +//! nichts von Plänen, Griffen oder Reglern; sie hat nur die Datei. Genau +//! deshalb ist sie interessant: Was sie findet, hätte auch ein Zuhörer finden +//! können, und was sie danebenliegt, ist das Maß dafür, wie viel eine +//! Mitschrift wert ist. +//! +//! Sie stand lange im Kritiker selbst. Hier steht sie, weil sie zwei Aufrufer +//! hat: den Kritiker, der einen einzelnen Mitschnitt bewertet, und die +//! Streuung, die dieselbe Schätzung über viele Sets gegen die Wahrheit der +//! Anlage hält. Zwei Kopien wären zwei Verfahren, und die Streuung hätte +//! nichts mehr über den Kritiker gesagt. + +use audio_core::track::Track; + +use crate::tonart; + +/// Fensterlänge für das Klangbild. Eine Sekunde ist kurz genug, um den Beginn +/// einer Blende auf den Takt genau einzugrenzen, und lang genug, dass ein +/// einzelner Schlag das Bild nicht kippt. +pub const FENSTER_SEK: f64 = 1.0; + +/// Wie weit zurück verglichen wird. Eine Blende dauert typisch 16 bis 32 Beats, +/// bei 128 BPM also 8 bis 15 Sekunden — der Abstand muss größer sein als das, +/// sonst vergleicht man die Blende mit sich selbst. +pub const ABSTAND_SEK: f64 = 16.0; + +/// Ab welcher Änderung des Klangbilds ein Übergang beginnt. 0 heißt gleich, +/// 1 heißt völlig anders. An echten Mitschnitten liegt der Ruhepegel unter +/// 0,1; eine Blende reißt deutlich darüber. +pub const WECHSEL_SCHWELLE: f32 = 0.25; + +/// Anteil des Ausschlags, ab dem eine Blende als begonnen gilt. +/// +/// Die Schwelle allein taugt nicht als Beginn: Sie reißt erst, wenn der neue +/// Track schon deutlich zu hören ist — bei einer Blende über 16 Sekunden also +/// zehn Sekunden zu spät. Gemessen an einem Mix, dessen Übergang bei genau +/// 40 Sekunden anfing, meldete die Schwelle 51. Deshalb wird vom Ausschlag aus +/// zurückgegangen, bis die Kurve wieder nahe an ihrer Ruhelage liegt. +pub const ANSTIEG_ANTEIL: f32 = 0.12; + +/// Ein gefundener Übergang, in Sekunden. +pub struct Uebergang { + pub beginn: f64, + pub ende: f64, + pub hoehe: f32, + /// Wie unscharf der Beginn ist, in Sekunden. + /// + /// Am Anfang einer langen Blende ist der eingehende Track **per + /// Konstruktion** unhörbar — das ist, was eine Blende ausmacht. Aus dem + /// Mitschnitt allein lässt sich der Griff an den Fader deshalb nicht auf + /// den Beat genau zurückverfolgen. Die Spanne vom zurückverfolgten Anstieg + /// bis zum deutlichen Ausschlag ist das ehrliche Maß dafür. + pub unschaerfe: f64, +} + +/// Findet die Übergänge in einem Mitschnitt. +/// +/// Der bequeme Weg: Kurve rechnen und Bereiche suchen in einem Schritt. +pub fn finden(track: &Track) -> Vec { + uebergaenge_finden(&wechselkurve(track), FENSTER_SEK) +} + +/// Wie stark sich das Klangbild gegenüber vor [`ABSTAND_SEK`] geändert hat. +/// +/// Chroma statt Spektrum: Ein Wechsel des Tracks ändert die Harmonik, ein +/// Filterschwenk oder ein Break nicht. Damit findet die Kurve Trackwechsel und +/// nicht jede Veränderung. +pub fn wechselkurve(track: &Track) -> Vec { + let rate = track.sample_rate as usize; + let fenster = (FENSTER_SEK * rate as f64) as usize; + let abstand = (ABSTAND_SEK / FENSTER_SEK) as usize; + + let chromas: Vec> = track + .samples + .chunks(fenster * 2) + .map(|teil| tonart::chroma(teil, track.sample_rate)) + .collect(); + + chromas + .iter() + .enumerate() + .map(|(i, jetzt)| { + if i < abstand { + return 0.0; + } + match (jetzt, &chromas[i - abstand]) { + (Some(a), Some(b)) => 1.0 - aehnlichkeit(a, b), + _ => 0.0, + } + }) + .collect() +} + +/// Kosinus-Ähnlichkeit zweier Chroma-Vektoren, 0 bis 1. +fn aehnlichkeit(a: &[f32; 12], b: &[f32; 12]) -> f32 { + let punkt: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum(); + let na: f32 = a.iter().map(|x| x * x).sum::().sqrt(); + let nb: f32 = b.iter().map(|x| x * x).sum::().sqrt(); + if na <= f32::EPSILON || nb <= f32::EPSILON { + return 1.0; + } + (punkt / (na * nb)).clamp(0.0, 1.0) +} + +/// Zusammenhängende Bereiche über der Schwelle, mit zurückverfolgtem Beginn. +/// +/// Der Beginn ist die Stelle, an der die Kurve **anfängt zu steigen** — nicht +/// die, an der sie die Schwelle reißt. Auf den ersten kommt es an, wenn man +/// nach der Phrasenlage fragt: Dort hat der Bediener den Fader angefasst. +pub fn uebergaenge_finden(kurve: &[f32], sek_je_wert: f64) -> Vec { + let mut aus = Vec::new(); + let mut start: Option = None; + let mut hoehe = 0.0f32; + + let abschliessen = |aus: &mut Vec, s: usize, e: usize, hoehe: f32| { + let ruhe = ruhelage(kurve); + let grenze = ruhe + (hoehe - ruhe) * ANSTIEG_ANTEIL; + let mut beginn = s; + while beginn > 0 && kurve[beginn - 1] > grenze { + beginn -= 1; + } + aus.push(Uebergang { + // Der Vergleich blickt zurück, also liegt der hörbare Beginn eine + // Fensterbreite vor dem gemessenen Anstieg. + beginn: (beginn as f64 - 1.0).max(0.0) * sek_je_wert, + ende: e as f64 * sek_je_wert, + hoehe, + unschaerfe: (s - beginn) as f64 * sek_je_wert, + }); + }; + + for (i, &w) in kurve.iter().enumerate() { + if w >= WECHSEL_SCHWELLE { + start.get_or_insert(i); + hoehe = hoehe.max(w); + } else if let Some(s) = start.take() { + abschliessen(&mut aus, s, i, hoehe); + hoehe = 0.0; + } + } + if let Some(s) = start { + abschliessen(&mut aus, s, kurve.len(), hoehe); + } + aus +} + +/// Ruhelage der Wechselkurve: der Median. +/// +/// Nicht der Mittelwert — die Ausschläge selbst würden ihn anheben und den +/// Beginn damit systematisch zu spät setzen. +fn ruhelage(kurve: &[f32]) -> f32 { + let mut sortiert: Vec = kurve.iter().copied().filter(|w| w.is_finite()).collect(); + if sortiert.is_empty() { + return 0.0; + } + sortiert.sort_by(|a, b| a.partial_cmp(b).unwrap()); + sortiert[sortiert.len() / 2] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chroma_mit(spitzen: &[usize]) -> [f32; 12] { + let mut c = [0.05f32; 12]; + for &s in spitzen { + c[s] = 1.0; + } + c + } + + #[test] + fn gleiches_klangbild_ist_kein_wechsel() { + let a = chroma_mit(&[0, 4, 7]); + assert!((aehnlichkeit(&a, &a) - 1.0).abs() < 1e-6); + } + + #[test] + fn ein_anderer_akkord_faellt_auf() { + let a = chroma_mit(&[0, 4, 7]); + let b = chroma_mit(&[1, 5, 8]); + assert!( + aehnlichkeit(&a, &b) < 0.4, + "zwei fremde Akkorde galten als ähnlich: {}", + aehnlichkeit(&a, &b) + ); + } + + /// Ein stiller Abschnitt darf keinen Wechsel vortäuschen. + #[test] + fn stille_gilt_als_unveraendert() { + let a = chroma_mit(&[0, 4, 7]); + let leer = [0.0f32; 12]; + assert_eq!(aehnlichkeit(&a, &leer), 1.0); + } + + #[test] + fn ein_zusammenhaengender_ausschlag_ist_ein_uebergang() { + // Ruhe, dann ein Ausschlag über vier Sekunden, dann wieder Ruhe. + let mut kurve = vec![0.05; 30]; + for w in kurve.iter_mut().skip(10).take(4) { + *w = 0.6; + } + let gefunden = uebergaenge_finden(&kurve, 1.0); + + assert_eq!(gefunden.len(), 1); + assert!( + (gefunden[0].beginn - 9.0).abs() < 0.01, + "{}", + gefunden[0].beginn + ); + assert!((gefunden[0].hoehe - 0.6).abs() < 1e-6); + } + + /// Der eigentliche Punkt: Eine Blende steigt langsam an, und die Schwelle + /// reißt erst spät. Gefragt ist, wo der Anstieg **anfing**. + #[test] + fn der_beginn_wird_bis_zum_anstieg_zurueckverfolgt() { + let mut kurve = vec![0.02; 40]; + // Ab Sekunde 10 steigt es langsam, ab 20 reißt es die Schwelle. + for (n, i) in (10..25).enumerate() { + kurve[i] = 0.02 + 0.05 * n as f32; + } + + let gefunden = uebergaenge_finden(&kurve, 1.0); + assert_eq!(gefunden.len(), 1); + assert!( + gefunden[0].beginn <= 11.0, + "der Beginn wurde nicht zurückverfolgt: {}", + gefunden[0].beginn + ); + } + + #[test] + fn die_ruhelage_ist_der_median_und_nicht_der_mittelwert() { + // Ein einzelner großer Ausschlag zieht den Mittelwert hoch, den Median + // nicht — und ein zu hoher Ruhewert setzt jeden Beginn zu spät. + let mut kurve = vec![0.02; 100]; + kurve[50] = 0.9; + assert!((ruhelage(&kurve) - 0.02).abs() < 1e-6); + } + + #[test] + fn zwei_getrennte_ausschlaege_sind_zwei_uebergaenge() { + let mut kurve = vec![0.05; 60]; + for i in [10, 11, 12, 40, 41, 42] { + kurve[i] = 0.5; + } + assert_eq!(uebergaenge_finden(&kurve, 1.0).len(), 2); + } + + /// Ein Ausschlag, der bis zum Ende reicht, geht nicht verloren. + #[test] + fn ein_ausschlag_am_ende_zaehlt_mit() { + let mut kurve = vec![0.05; 20]; + for w in kurve.iter_mut().skip(17) { + *w = 0.7; + } + assert_eq!(uebergaenge_finden(&kurve, 1.0).len(), 1); + } + + #[test] + fn eine_ruhige_kurve_meldet_nichts() { + // Kein Ausschlag heißt kein Übergang. Ein erfundener wäre schlimmer + // als ein übersehener: Er bekäme in der Streuung eine Abweichung. + let kurve = vec![0.03f32; 60]; + assert!(uebergaenge_finden(&kurve, 1.0).is_empty()); + } +} diff --git a/crates/audio-core/Cargo.toml b/crates/audio-core/Cargo.toml new file mode 100644 index 0000000..a5f973e --- /dev/null +++ b/crates/audio-core/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "audio-core" +version = "0.1.0" +edition = "2024" + +[dependencies] +cpal = "0.18.1" +symphonia = { version = "0.6.0", default-features = false, features = ["mp3", "flac", "wav", "pcm", "isomp4", "aac", "vorbis", "ogg"] } +thiserror = "2.0.20" diff --git a/crates/audio-core/src/deck.rs b/crates/audio-core/src/deck.rs new file mode 100644 index 0000000..97a0248 --- /dev/null +++ b/crates/audio-core/src/deck.rs @@ -0,0 +1,983 @@ +//! Deck-Zustand und Rendering. +//! +//! Die Steuerung läuft ausschließlich über Atomics: Der Audio-Callback liest +//! sie, der Rest der Anwendung schreibt sie. Damit braucht der Abspielpfad kein +//! Lock, keine Allokation und keine Zuteilung von Speicher — die drei Dinge, +//! die in einem Audio-Callback zu Aussetzern führen. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; + +use crate::grid::Beatgrid; +use crate::stretch::Wsola; +use crate::track::{CHANNELS, MAX_STEMS, Track}; + +/// Unterhalb dieser Abweichung von 1.0 wird direkt kopiert statt gestreckt. +const TEMPO_EPS: f64 = 1e-4; + +/// Maximale Blockgröße, die am Stück gerendert wird. +const SCRATCH_FRAMES: usize = 4096; + +/// Anzahl der Hot Cues je Deck, wie bei Traktor. +pub const HOT_CUES: usize = 8; + +pub struct DeckState { + playing: AtomicBool, + keylock: AtomicBool, + tempo_bits: AtomicU32, + position: AtomicU64, + seek_to: AtomicI64, + finished: AtomicBool, + + /// Beatgrid als Atomics statt als Struktur hinter einem Lock — der + /// Audio-Thread liest es, und Locks haben dort nichts zu suchen. + /// BPM 0.0 bedeutet: kein Grid bekannt. + grid_bpm_bits: AtomicU32, + grid_anchor: AtomicU64, + + /// −1 = nicht gesetzt. + cues: [AtomicI64; HOT_CUES], + + loop_start: AtomicI64, + loop_end: AtomicI64, + loop_active: AtomicBool, + + /// Pegel je Einzelspur, 0 bis 1. Ohne Stems ohne Wirkung. + /// + /// Als Atomics und nicht hinter einem Lock, aus demselben Grund wie das + /// Grid: Der Audio-Thread liest sie in jedem Block. + stem_pegel: [AtomicU32; MAX_STEMS], +} + +impl Default for DeckState { + fn default() -> Self { + Self::new() + } +} + +impl DeckState { + pub fn new() -> Self { + DeckState { + playing: AtomicBool::new(false), + keylock: AtomicBool::new(true), + tempo_bits: AtomicU32::new(1.0f32.to_bits()), + position: AtomicU64::new(0), + seek_to: AtomicI64::new(-1), + finished: AtomicBool::new(false), + grid_bpm_bits: AtomicU32::new(0.0f32.to_bits()), + grid_anchor: AtomicU64::new(0), + cues: std::array::from_fn(|_| AtomicI64::new(-1)), + loop_start: AtomicI64::new(-1), + loop_end: AtomicI64::new(-1), + loop_active: AtomicBool::new(false), + // Voll auf: Ein frisch geladener Track klingt mit Stems genauso + // wie ohne, bis jemand etwas wegnimmt. + stem_pegel: std::array::from_fn(|_| AtomicU32::new(1.0f32.to_bits())), + } + } + + /// Pegel einer Einzelspur, 0 bis 1. + pub fn stem_pegel(&self, i: usize) -> f32 { + self.stem_pegel + .get(i) + .map(|p| f32::from_bits(p.load(Ordering::Relaxed))) + .unwrap_or(0.0) + } + + pub fn set_stem_pegel(&self, i: usize, wert: f32) { + if let Some(p) = self.stem_pegel.get(i) { + p.store(wert.clamp(0.0, 1.0).to_bits(), Ordering::Relaxed); + } + } + + /// Hinterlegt das Beatgrid aus der Analyse. `None` löscht es. + pub fn set_grid(&self, grid: Option) { + match grid.filter(|g| g.is_usable()) { + Some(g) => { + self.grid_anchor.store(g.anchor_frames, Ordering::Relaxed); + self.grid_bpm_bits.store(g.bpm.to_bits(), Ordering::Relaxed); + } + None => self + .grid_bpm_bits + .store(0.0f32.to_bits(), Ordering::Relaxed), + } + } + + pub fn grid(&self) -> Option { + let bpm = f32::from_bits(self.grid_bpm_bits.load(Ordering::Relaxed)); + let grid = Beatgrid::new(bpm, self.grid_anchor.load(Ordering::Relaxed), 1.0); + grid.is_usable().then_some(grid) + } + + /// Effektives Tempo in BPM — Grid-Tempo mal Pitchfader. + pub fn effective_bpm(&self) -> Option { + self.grid().map(|g| g.bpm * self.tempo()) + } + + /// Lage im laufenden Beat, 0.0 bis 1.0. + pub fn beat_phase(&self, sample_rate: u32) -> Option { + self.grid() + .map(|g| g.phase_at(self.position_frames() as f64, sample_rate)) + } + + /// Setzt einen Hot Cue auf die angegebene Position. + pub fn set_cue(&self, index: usize, frame: Option) { + if let Some(slot) = self.cues.get(index) { + slot.store(frame.map(|f| f as i64).unwrap_or(-1), Ordering::Relaxed); + } + } + + pub fn cue(&self, index: usize) -> Option { + let raw = self.cues.get(index)?.load(Ordering::Relaxed); + (raw >= 0).then_some(raw as u64) + } + + /// Springt auf einen Hot Cue. Gibt zurück, ob er gesetzt war. + pub fn jump_to_cue(&self, index: usize) -> bool { + match self.cue(index) { + Some(frame) => { + self.seek_frames(frame); + true + } + None => false, + } + } + + /// Setzt eine Schleife. `end` muss hinter `start` liegen. + pub fn set_loop(&self, start: u64, end: u64) -> bool { + if end <= start { + return false; + } + self.loop_start.store(start as i64, Ordering::Relaxed); + self.loop_end.store(end as i64, Ordering::Relaxed); + true + } + + pub fn loop_range(&self) -> Option<(u64, u64)> { + let start = self.loop_start.load(Ordering::Relaxed); + let end = self.loop_end.load(Ordering::Relaxed); + (start >= 0 && end > start).then_some((start as u64, end as u64)) + } + + /// Schaltet die Schleife scharf. Ohne gesetzten Bereich passiert nichts. + pub fn set_loop_active(&self, active: bool) -> bool { + let ok = !active || self.loop_range().is_some(); + self.loop_active.store(active && ok, Ordering::Relaxed); + ok + } + + pub fn is_looping(&self) -> bool { + self.loop_active.load(Ordering::Relaxed) && self.loop_range().is_some() + } + + /// Legt eine Schleife über `beats` Beats ab der aktuellen Position, + /// eingerastet auf das Grid. + /// + /// Ohne Einrasten säße der Anfang irgendwo im Takt, und die Schleife + /// klänge bei jedem Durchlauf wie ein Stolperer. + pub fn set_loop_beats(&self, beats: f64, sample_rate: u32) -> bool { + let Some(grid) = self.grid() else { + return false; + }; + if beats <= 0.0 { + return false; + } + + let start = grid + .snap(self.position_frames() as f64, sample_rate) + .max(0.0); + let length = beats * grid.frames_per_beat(sample_rate); + + self.set_loop(start as u64, (start + length) as u64) + } + + pub fn is_playing(&self) -> bool { + self.playing.load(Ordering::Relaxed) + } + + pub fn set_playing(&self, on: bool) { + if on { + self.finished.store(false, Ordering::Relaxed); + } + self.playing.store(on, Ordering::Relaxed); + } + + pub fn toggle_playing(&self) -> bool { + let next = !self.is_playing(); + self.set_playing(next); + next + } + + pub fn keylock(&self) -> bool { + self.keylock.load(Ordering::Relaxed) + } + + pub fn set_keylock(&self, on: bool) { + self.keylock.store(on, Ordering::Relaxed); + } + + /// Tempoverhältnis: 1.0 = Originalgeschwindigkeit, 1.06 = +6 %. + pub fn tempo(&self) -> f32 { + f32::from_bits(self.tempo_bits.load(Ordering::Relaxed)) + } + + pub fn set_tempo(&self, ratio: f32) { + let clamped = ratio.clamp(0.25, 4.0); + self.tempo_bits.store(clamped.to_bits(), Ordering::Relaxed); + } + + pub fn position_frames(&self) -> u64 { + self.position.load(Ordering::Relaxed) + } + + pub fn seek_frames(&self, frame: u64) { + self.seek_to.store(frame as i64, Ordering::Relaxed); + } + + /// Wo das Deck gleich stehen wird: ein angefragter Sprung, sonst die + /// aktuelle Position. + /// + /// [`DeckState::seek_frames`] setzt nur einen Wunsch — ausgeführt wird er + /// vom Audio-Thread im nächsten Block. Wer unmittelbar nach einem Sprung + /// fragt, wo das Deck steht, bekäme sonst die alte Stelle. Für alles, was + /// *auf* die Position zeigt statt sie anzuzeigen — einen Grid-Anker etwa — + /// ist das die falsche Antwort. + pub fn ziel_position(&self) -> u64 { + match self.seek_to.load(Ordering::Relaxed) { + wunsch if wunsch >= 0 => wunsch as u64, + _ => self.position_frames(), + } + } + + /// Track ist bis zum Ende gelaufen. + pub fn is_finished(&self) -> bool { + self.finished.load(Ordering::Relaxed) + } +} + +/// Die Audio-Thread-Seite eines Decks. Wird vom Callback exklusiv besessen. +pub struct Voice { + track: Arc, + state: Arc, + wsola: Wsola, + scratch: Vec, + pos: f64, + stretching: bool, +} + +impl Voice { + pub fn new(track: Arc, state: Arc) -> Self { + Voice { + track, + state, + wsola: Wsola::new(), + scratch: vec![0.0; SCRATCH_FRAMES * CHANNELS], + pos: 0.0, + stretching: false, + } + } + + /// Schreibt einen Block in den Gerätepuffer. `out_channels` ist die + /// Kanalzahl des Geräts, nicht die des Tracks. + pub fn render(&mut self, out: &mut [f32], out_channels: usize) { + if out_channels == 0 { + return; + } + let total_frames = out.len() / out_channels; + let mut done = 0; + + while done < total_frames { + let n = (total_frames - done).min(SCRATCH_FRAMES); + let len = n * CHANNELS; + let mut scratch = std::mem::take(&mut self.scratch); + self.render_stereo(&mut scratch[..len]); + self.scratch = scratch; + + for i in 0..n { + let l = self.scratch[i * CHANNELS]; + let r = self.scratch[i * CHANNELS + 1]; + let base = (done + i) * out_channels; + let frame = &mut out[base..base + out_channels]; + if out_channels == 1 { + frame[0] = 0.5 * (l + r); + } else { + frame[0] = l; + frame[1] = r; + frame[2..].fill(0.0); + } + } + + done += n; + } + } + + /// Schreibt interleaved Stereo in `out`. + /// + /// Das ist der Einstieg für den Mixer: Ein Deck ist dort eine Quelle unter + /// mehreren und liefert immer Stereo, unabhängig davon, wie das + /// Ausgabegerät aussieht. + pub fn render_stereo(&mut self, out: &mut [f32]) { + if let Some(target) = self.take_seek() { + self.pos = target; + self.wsola.reset(); + } + + if !self.state.is_playing() { + out.fill(0.0); + // Auch ein stehendes Deck muss melden, wo es steht. Sonst zeigt es + // nach einem Cue-Sprung die alte Stelle, ein frisch geladener Track + // die Position seines Vorgängers — und `beats_left`, `beat` und + // `beats_to_phrase` erben die Lüge, weil sie von hier rechnen. + self.melde_position(); + return; + } + + let tempo = self.state.tempo() as f64; + let want_stretch = self.state.keylock() && (tempo - 1.0).abs() > TEMPO_EPS; + + if want_stretch != self.stretching { + self.wsola.reset(); + self.stretching = want_stretch; + } + + let mut alive = true; + let mut written = 0usize; + + // Blockweise bis zur nächsten Schleifengrenze rendern. Erst am Ende des + // Callback-Blocks umzubrechen wäre bequemer, würde die Schleife aber um + // bis zu einen Block verlängern — hörbar als ungleichmäßiger Takt. + while written < out.len() { + let rest = &mut out[written..]; + let chunk = self.frames_until_loop_end(tempo).min(rest.len() / CHANNELS); + let len = chunk.max(1) * CHANNELS; + let len = len.min(rest.len()); + + // Die Felder einzeln ausleihen: `track` liest, `wsola` und `pos` + // schreiben. Ohne das Zerlegen hielte `self` beides zugleich. + let Voice { + track, + state, + wsola, + pos, + .. + } = self; + + // Auf dem Stapel, nicht im Vec: Eine Speicheranforderung je Block + // wäre die eine Sache, die im Audio-Callback nie passieren darf. + let mut spuren: [&[f32]; MAX_STEMS] = [&[]; MAX_STEMS]; + let mut pegel: [f32; MAX_STEMS] = [0.0; MAX_STEMS]; + let mut n = 0; + for (i, stem) in track.stems.iter().enumerate().take(MAX_STEMS) { + spuren[i] = &stem.samples; + pegel[i] = state.stem_pegel(i); + n = i + 1; + } + // Ohne Stems die Summe als einzige Spur — derselbe Weg, dieselbe + // Rechnung, damit es nur eine Stelle gibt, die stimmen muss. + if n == 0 { + spuren[0] = &track.samples; + pegel[0] = 1.0; + n = 1; + } + + alive = if want_stretch { + wsola.render_gemischt( + &track.samples, + &spuren[..n], + &pegel[..n], + tempo, + pos, + &mut rest[..len], + ) + } else { + varispeed_gemischt(&spuren[..n], &pegel[..n], tempo, pos, &mut rest[..len]) + }; + + written += len; + + if !alive { + break; + } + self.wrap_loop(); + } + + if !alive { + self.state.playing.store(false, Ordering::Relaxed); + self.state.finished.store(true, Ordering::Relaxed); + } + + self.melde_position(); + } + + /// Schreibt die Abspielposition nach außen. + fn melde_position(&self) { + let frames_total = self.track.frames() as f64; + let clamped = self.pos.clamp(0.0, frames_total); + self.state.position.store(clamped as u64, Ordering::Relaxed); + } + + /// Wie viele Ausgabe-Frames bis zum Schleifenende gerendert werden dürfen. + /// Ohne aktive Schleife: unbegrenzt. + fn frames_until_loop_end(&self, tempo: f64) -> usize { + let Some((_, end)) = self.active_loop() else { + return usize::MAX; + }; + let verbleibend = end as f64 - self.pos; + if verbleibend <= 0.0 { + return 1; + } + // Quell-Frames in Ausgabe-Frames umrechnen: bei doppeltem Tempo + // verbraucht ein Ausgabe-Frame zwei Quell-Frames. + let schritt = tempo.max(1e-6); + (verbleibend / schritt).ceil() as usize + } + + /// Setzt die Position zurück, sobald das Schleifenende überschritten ist. + fn wrap_loop(&mut self) { + let Some((start, end)) = self.active_loop() else { + return; + }; + if self.pos < end as f64 { + return; + } + + let laenge = (end - start) as f64; + let ueberschuss = (self.pos - end as f64).rem_euclid(laenge); + self.pos = start as f64 + ueberschuss; + self.wsola.reset(); + } + + fn active_loop(&self) -> Option<(u64, u64)> { + self.state + .is_looping() + .then(|| self.state.loop_range()) + .flatten() + } + + fn take_seek(&mut self) -> Option { + let raw = self.state.seek_to.swap(-1, Ordering::Relaxed); + if raw < 0 { + return None; + } + let max = self.track.frames().saturating_sub(1) as i64; + Some(raw.min(max) as f64) + } +} + +/// Abspielen mit veränderter Rate — Tempo *und* Tonhöhe wandern mit, wie beim +/// Pitchfader eines Plattenspielers. Das ist der Modus ohne Keylock. +/// Eine einzelne Spur — der Weg, wenn ein Track keine Einzelspuren hat. +#[cfg(test)] +fn varispeed(src: &[f32], ratio: f64, pos: &mut f64, out: &mut [f32]) -> bool { + varispeed_gemischt(std::slice::from_ref(&src), &[1.0], ratio, pos, out) +} + +/// Dasselbe aus mehreren Spuren, jede mit ihrem Pegel. +/// +/// Die Spuren werden an *derselben* Position gelesen und dort summiert. Das ist +/// nicht nur billiger als vier getrennte Durchläufe, es ist auch der einzige +/// Weg, der stimmt: Vier Spuren, die jede für sich interpoliert würden, blieben +/// zwar synchron, aber jede Abweichung im Zeitverhalten wäre hörbar als +/// Verwaschen — und beim Zeitstrecken wird genau daraus ein Problem. +fn varispeed_gemischt( + spuren: &[&[f32]], + pegel: &[f32], + ratio: f64, + pos: &mut f64, + out: &mut [f32], +) -> bool { + let frames = spuren.iter().map(|s| s.len() / CHANNELS).min().unwrap_or(0); + if frames < 2 { + out.fill(0.0); + return false; + } + + let blocks = out.len() / CHANNELS; + for i in 0..blocks { + let idx = pos.floor(); + if idx < 0.0 || idx as usize + 1 >= frames { + out[i * CHANNELS..].fill(0.0); + return false; + } + let idx = idx as usize; + let frac = (*pos - idx as f64) as f32; + + for c in 0..CHANNELS { + let mut summe = 0.0; + for (spur, p) in spuren.iter().zip(pegel) { + let a = spur[idx * CHANNELS + c]; + let b = spur[(idx + 1) * CHANNELS + c]; + summe += (a + (b - a) * frac) * p; + } + out[i * CHANNELS + c] = summe; + } + + *pos += ratio; + } + + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{dominant_freq, sine}; + + const RATE: u32 = 44_100; + + /// Ohne Keylock wandert die Tonhöhe mit dem Tempo — wie beim Plattenspieler. + #[test] + fn varispeed_verschiebt_die_tonhoehe() { + let src = sine(440.0, RATE, 10.0); + let mut pos = 0.0; + let mut out = vec![0.0; 4 * RATE as usize * CHANNELS]; + + assert!(varispeed(&src, 1.06, &mut pos, &mut out)); + + let freq = dominant_freq(&out, RATE); + let erwartet = 440.0 * 1.06; + assert!( + (freq - erwartet).abs() < 6.0, + "erwartet ~{erwartet:.1} Hz, gemessen {freq:.1} Hz" + ); + } + + #[test] + fn varispeed_meldet_das_ende() { + let src = sine(440.0, RATE, 0.1); + let mut pos = 0.0; + let mut out = vec![0.5; RATE as usize * CHANNELS]; + + assert!(!varispeed(&src, 1.0, &mut pos, &mut out)); + assert_eq!(out.last().copied(), Some(0.0)); + } + + #[test] + fn tempo_wird_auf_sinnvolle_grenzen_geklemmt() { + let state = DeckState::new(); + + state.set_tempo(99.0); + assert_eq!(state.tempo(), 4.0); + + state.set_tempo(0.0); + assert_eq!(state.tempo(), 0.25); + + state.set_tempo(1.06); + assert!((state.tempo() - 1.06).abs() < 1e-6); + } + + fn deck_mit_ton(secs: f32) -> (Voice, Arc) { + let track = Arc::new(Track { + samples: sine(440.0, RATE, secs), + sample_rate: RATE, + stems: Vec::new(), + }); + let state = Arc::new(DeckState::new()); + state.set_playing(true); + (Voice::new(track, Arc::clone(&state)), state) + } + + /// Ein stehendes Deck muss melden, wo es steht. + /// + /// Cue setzen und anspringen, bevor der Track läuft, ist der Normalfall vor + /// dem Einsatz. Meldet das Deck dabei die alte Position, lügen auch + /// `beats_left`, `beat` und `beats_to_phrase` — und eine Bedingung, die + /// darauf wartet, entscheidet an der falschen Stelle. + #[test] + fn ein_stehendes_deck_meldet_die_neue_position() { + let (mut voice, state) = deck_mit_ton(10.0); + let mut aus = vec![0.0; 512 * CHANNELS]; + + state.set_playing(false); + state.seek_frames(RATE as u64 * 4); + voice.render_stereo(&mut aus); + + assert_eq!( + state.position_frames(), + RATE as u64 * 4, + "das stehende Deck meldet noch die alte Position" + ); + } + + /// Und ein frisch geladener Track fängt nicht in der Mitte an. + #[test] + fn ein_frisch_geladener_track_meldet_den_anfang() { + let (mut voice, state) = deck_mit_ton(10.0); + let mut aus = vec![0.0; 512 * CHANNELS]; + + // Erst ein Stück spielen, dann anhalten — wie beim Trackwechsel. + voice.render_stereo(&mut aus); + state.set_playing(false); + + // Neuer Abspieler auf demselben Zustand, so wie beim Laden. + let track = Arc::new(Track { + samples: sine(220.0, RATE, 10.0), + sample_rate: RATE, + stems: Vec::new(), + }); + let mut neu = Voice::new(track, Arc::clone(&state)); + neu.render_stereo(&mut aus); + + assert_eq!( + state.position_frames(), + 0, + "der neue Track meldet die Position des alten" + ); + } + + #[test] + fn hot_cues_speichern_und_springen() { + let (_voice, state) = deck_mit_ton(10.0); + + assert_eq!(state.cue(0), None); + assert!(!state.jump_to_cue(0), "Sprung auf leeren Cue meldet Erfolg"); + + state.set_cue(0, Some(12_345)); + assert_eq!(state.cue(0), Some(12_345)); + assert!(state.jump_to_cue(0)); + + state.set_cue(0, None); + assert_eq!(state.cue(0), None); + } + + #[test] + fn cue_index_ausserhalb_paniert_nicht() { + let (_voice, state) = deck_mit_ton(1.0); + state.set_cue(99, Some(1)); + assert_eq!(state.cue(99), None); + assert!(!state.jump_to_cue(99)); + } + + #[test] + fn schleife_haelt_die_position_im_bereich() { + let (mut voice, state) = deck_mit_ton(10.0); + let (start, end) = (RATE as u64, RATE as u64 + 12_000); + + assert!(state.set_loop(start, end)); + state.seek_frames(start); + assert!(state.set_loop_active(true)); + + // Deutlich mehr rendern, als die Schleife lang ist. + let mut out = vec![0.0; 8_192 * CHANNELS]; + for _ in 0..12 { + voice.render_stereo(&mut out); + let pos = state.position_frames(); + assert!( + (start..end).contains(&pos), + "Position {pos} außerhalb der Schleife {start}..{end}" + ); + } + + assert!( + !state.is_finished(), + "Schleife darf nicht ans Trackende laufen" + ); + } + + #[test] + fn schleife_aus_laesst_weiterlaufen() { + let (mut voice, state) = deck_mit_ton(10.0); + state.set_loop(RATE as u64, RATE as u64 + 12_000); + state.seek_frames(RATE as u64); + state.set_loop_active(false); + + let mut out = vec![0.0; 8_192 * CHANNELS]; + for _ in 0..6 { + voice.render_stereo(&mut out); + } + + assert!( + state.position_frames() > RATE as u64 + 12_000, + "ohne aktive Schleife bleibt das Deck stehen" + ); + } + + #[test] + fn unsinnige_schleifen_werden_abgelehnt() { + let (_voice, state) = deck_mit_ton(1.0); + + assert!(!state.set_loop(500, 500), "leere Schleife akzeptiert"); + assert!( + !state.set_loop(500, 100), + "rückwärts laufende Schleife akzeptiert" + ); + assert!(state.loop_range().is_none()); + + assert!( + !state.set_loop_active(true), + "Schleife ohne Bereich scharf gestellt" + ); + assert!(!state.is_looping()); + } + + #[test] + fn beat_schleife_rastet_auf_das_grid() { + let (_voice, state) = deck_mit_ton(10.0); + // 120 BPM = 22 050 Frames je Beat bei 44,1 kHz. + state.set_grid(Some(Beatgrid::new(120.0, 1_000, 1.0))); + state.seek_frames(30_000); + + assert!(state.set_loop_beats(4.0, RATE)); + let (start, end) = state.loop_range().expect("keine Schleife gesetzt"); + + // Start muss auf einem Beat sitzen, nicht auf 30 000. + let grid = state.grid().unwrap(); + let phase = grid.phase_at(start as f64, RATE); + assert!( + !(1e-6..=1.0 - 1e-6).contains(&phase), + "Schleifenanfang liegt bei Phase {phase:.4} statt auf dem Beat" + ); + + let beats = (end - start) as f64 / grid.frames_per_beat(RATE); + assert!( + (beats - 4.0).abs() < 1e-3, + "Schleife ist {beats:.3} Beats lang" + ); + } + + #[test] + fn ohne_grid_gibt_es_keine_beat_schleife() { + let (_voice, state) = deck_mit_ton(1.0); + assert!(!state.set_loop_beats(4.0, RATE)); + } + + #[test] + fn grid_ueberlebt_die_atomics() { + let (_voice, state) = deck_mit_ton(1.0); + assert!(state.grid().is_none()); + + state.set_grid(Some(Beatgrid::new(128.5, 4_242, 0.8))); + let grid = state.grid().expect("Grid verschwunden"); + assert!((grid.bpm - 128.5).abs() < 1e-4); + assert_eq!(grid.anchor_frames, 4_242); + + state.set_grid(None); + assert!(state.grid().is_none()); + + // Unbrauchbare Grids werden gar nicht erst übernommen. + state.set_grid(Some(Beatgrid::new(0.0, 1, 1.0))); + assert!(state.grid().is_none()); + } + + #[test] + fn effektives_tempo_beruecksichtigt_den_pitchfader() { + let (_voice, state) = deck_mit_ton(1.0); + assert_eq!(state.effective_bpm(), None); + + state.set_grid(Some(Beatgrid::new(120.0, 0, 1.0))); + state.set_tempo(1.05); + + let bpm = state.effective_bpm().unwrap(); + assert!((bpm - 126.0).abs() < 0.01, "effektives Tempo {bpm:.2}"); + } + + #[test] + fn play_setzt_das_ende_flag_zurueck() { + let state = DeckState::new(); + state.finished.store(true, Ordering::Relaxed); + + state.set_playing(true); + + assert!(!state.is_finished()); + assert!(state.is_playing()); + } +} + +#[cfg(test)] +mod stem_tests { + use super::*; + use crate::testing::{dominant_freq, sine}; + use crate::track::Stem; + + const RATE: u32 = 44_100; + + /// Zwei Spuren mit verschiedenen Tönen, dazu die Summe. + fn track_mit_zwei_stems(secs: f32) -> Arc { + let tief = sine(220.0, RATE, secs); + let hoch = sine(880.0, RATE, secs); + let summe: Vec = tief.iter().zip(&hoch).map(|(a, b)| a + b).collect(); + Arc::new(Track { + samples: summe, + sample_rate: RATE, + stems: vec![ + Stem { + name: "tief".into(), + samples: tief, + }, + Stem { + name: "hoch".into(), + samples: hoch, + }, + ], + }) + } + + fn spielen(track: Arc, state: Arc, secs: f32) -> Vec { + let mut voice = Voice::new(track, state); + let mut aus = vec![0.0; (RATE as f32 * secs) as usize * CHANNELS]; + voice.render_stereo(&mut aus); + aus + } + + /// **Der Zweck der ganzen Übung:** eine Spur wegnehmen, der Rest bleibt. + /// + /// Zwei Stimmen gleichzeitig sind der hörbarste Mixfehler überhaupt. Ohne + /// Trennung lässt er sich nur vermeiden, indem man gar nicht überlagert. + #[test] + fn eine_spur_laesst_sich_wegnehmen() { + let state = Arc::new(DeckState::new()); + state.set_playing(true); + state.set_keylock(false); + + // Beide offen: Man hört beides. + let beide = spielen(track_mit_zwei_stems(4.0), Arc::clone(&state), 2.0); + let pegel_beide = beide.iter().map(|v| v.abs()).fold(0.0f32, f32::max); + assert!(pegel_beide > 1.5, "beide Spuren fehlen: {pegel_beide:.3}"); + + // Die hohe zu: Es bleibt die tiefe, und man erkennt sie an ihrem Ton. + state.set_stem_pegel(1, 0.0); + let nur_tief = spielen(track_mit_zwei_stems(4.0), Arc::clone(&state), 2.0); + let freq = dominant_freq(&nur_tief, RATE); + assert!( + (freq - 220.0).abs() < 6.0, + "ohne die hohe Spur sollten 220 Hz übrig bleiben, gemessen {freq:.1} Hz" + ); + + // Und umgekehrt. + state.set_stem_pegel(1, 1.0); + state.set_stem_pegel(0, 0.0); + let nur_hoch = spielen(track_mit_zwei_stems(4.0), Arc::clone(&state), 2.0); + let freq = dominant_freq(&nur_hoch, RATE); + assert!( + (freq - 880.0).abs() < 12.0, + "ohne die tiefe Spur sollten 880 Hz übrig bleiben, gemessen {freq:.1} Hz" + ); + } + + /// Ein Track ohne Stems klingt genau wie vorher — und einer mit Stems, bei + /// dem alles offen steht, klingt wie seine Summe. + #[test] + fn voll_aufgedrehte_stems_klingen_wie_die_summe() { + let state = Arc::new(DeckState::new()); + state.set_playing(true); + state.set_keylock(false); + let mit = spielen(track_mit_zwei_stems(4.0), Arc::clone(&state), 1.0); + + let track = track_mit_zwei_stems(4.0); + let ohne = Arc::new(Track { + samples: track.samples.clone(), + sample_rate: RATE, + stems: Vec::new(), + }); + let state2 = Arc::new(DeckState::new()); + state2.set_playing(true); + state2.set_keylock(false); + let ohne = spielen(ohne, state2, 1.0); + + let groesster: f32 = mit + .iter() + .zip(&ohne) + .map(|(a, b)| (a - b).abs()) + .fold(0.0, f32::max); + assert!( + groesster < 1e-6, + "Stems und Summe klingen verschieden, größter Unterschied {groesster:.2e}" + ); + } + + /// **Die Zeitstreckung entscheidet einmal für alle Spuren.** + /// + /// Sie sucht sich in jedem Hop die Stelle, an der die Wellenform am besten + /// anschließt. Liefe diese Suche je Spur getrennt, fände jede eine andere, + /// und was vorher zusammen klang, klänge verwaschen. Geprüft wird das am + /// Ergebnis: Mit Keylock und vollen Pegeln muss dasselbe herauskommen wie + /// beim Strecken der Summe — Ton für Ton. + #[test] + fn die_zeitstreckung_entscheidet_einmal_fuer_alle_spuren() { + let state = Arc::new(DeckState::new()); + state.set_playing(true); + state.set_keylock(true); + state.set_tempo(1.06); + let mit = spielen(track_mit_zwei_stems(6.0), Arc::clone(&state), 2.0); + + let track = track_mit_zwei_stems(6.0); + let ohne = Arc::new(Track { + samples: track.samples.clone(), + sample_rate: RATE, + stems: Vec::new(), + }); + let state2 = Arc::new(DeckState::new()); + state2.set_playing(true); + state2.set_keylock(true); + state2.set_tempo(1.06); + let ohne = spielen(ohne, state2, 2.0); + + let groesster: f32 = mit + .iter() + .zip(&ohne) + .map(|(a, b)| (a - b).abs()) + .fold(0.0, f32::max); + assert!( + groesster < 1e-5, + "gestreckte Stems weichen von der gestreckten Summe ab, größter Unterschied {groesster:.2e}" + ); + } +} + +#[cfg(test)] +mod einstieg_tests { + use super::*; + use crate::testing::sine; + + const RATE: u32 = 44_100; + + fn voice(state: &Arc) -> Voice { + let track = Arc::new(Track { + samples: sine(440.0, RATE, 10.0), + sample_rate: RATE, + stems: Vec::new(), + }); + Voice::new(Arc::clone(&track), Arc::clone(state)) + } + + /// **Ein vorgemerkter Sprung gehört der Stimme, die als Nächstes rendert.** + /// + /// Das klingt selbstverständlich und ist der Grund, warum ein frisch + /// geladenes Deck wieder auf Sekunde 0 stand: Der Lade-Arbeiter setzte den + /// Sprung auf den ersten Downbeat, während im Mixer noch die *alte* Stimme + /// lag. Die hat ihn verbraucht — auf ihrem eigenen Track —, und die neue + /// fing bei null an. Am laufenden Programm war der Unterschied + /// `beat -1.02` gegen `beat -0.03`. + /// + /// Deshalb wird jetzt erst getauscht und dann gesprungen. Dieser Test hält + /// fest, warum die Reihenfolge nicht beliebig ist. + #[test] + fn einen_vorgemerkten_sprung_verbraucht_die_stimme_die_gerade_liegt() { + let state = Arc::new(DeckState::new()); + let mut alt = voice(&state); + let mut aus = vec![0.0; 256 * CHANNELS]; + + // Der Sprung wird gesetzt, während die alte Stimme noch rendert. + state.seek_frames(88_200); + alt.render_stereo(&mut aus); + assert_eq!( + state.position_frames(), + 88_200, + "die alte Stimme hat den Sprung nicht genommen" + ); + + // Die neue Stimme fängt bei null an — der Sprung ist verbraucht. + let mut neu = voice(&state); + neu.render_stereo(&mut aus); + assert_eq!( + state.position_frames(), + 0, + "der Sprung überlebt den Stimmentausch — dann wäre die Reihenfolge egal" + ); + + // Erst nach dem Tausch gesetzt kommt er an. + state.seek_frames(88_200); + neu.render_stereo(&mut aus); + assert_eq!(state.position_frames(), 88_200); + } +} diff --git a/crates/audio-core/src/error.rs b/crates/audio-core/src/error.rs new file mode 100644 index 0000000..4349132 --- /dev/null +++ b/crates/audio-core/src/error.rs @@ -0,0 +1,24 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AudioError { + #[error("Datei konnte nicht gelesen werden: {0}")] + Io(#[from] std::io::Error), + + #[error("Dekodierung fehlgeschlagen: {0}")] + Decode(#[from] symphonia::core::errors::Error), + + #[error("keine Audiospur in der Datei gefunden")] + NoAudioTrack, + + #[error("kein Ausgabegerät gefunden")] + NoOutputDevice, + + #[error("Audio-Gerät: {0}")] + Device(#[from] cpal::Error), + + #[error("Sample-Format {0} wird nicht unterstützt")] + UnsupportedSampleFormat(String), +} + +pub type Result = std::result::Result; diff --git a/crates/audio-core/src/grid.rs b/crates/audio-core/src/grid.rs new file mode 100644 index 0000000..3b658c5 --- /dev/null +++ b/crates/audio-core/src/grid.rs @@ -0,0 +1,141 @@ +//! Beatgrid: die Umrechnung zwischen Sample-Position und Taktzählung. +//! +//! Ein Tempo allein reicht für Sync nicht. Zwei Decks mit identischem BPM, +//! deren Beats aber versetzt liegen, klingen genauso falsch wie zwei mit +//! verschiedenem Tempo. Gebraucht werden immer beide Größen: **Tempo** und +//! **Phase** — und die Phase kommt aus dem Anker. +//! +//! Alle Angaben beziehen sich auf *Quell-Frames*, nicht auf Ausgabezeit. Ein +//! Deck, das mit 1,03-fachem Tempo läuft, wandert schneller durch das Grid, +//! aber das Grid selbst bleibt, wie es beim Analysieren gemessen wurde. + +/// Tempo und Lage der Beats eines Tracks. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Beatgrid { + pub bpm: f32, + /// Position eines Beats in Sample-Frames. Welcher Beat, ist gleichgültig — + /// das Raster wiederholt sich. + pub anchor_frames: u64, + /// 0..1, wie verlässlich die Erkennung war. + pub confidence: f32, +} + +impl Beatgrid { + pub fn new(bpm: f32, anchor_frames: u64, confidence: f32) -> Self { + Beatgrid { + bpm, + anchor_frames, + confidence, + } + } + + pub fn is_usable(&self) -> bool { + self.bpm.is_finite() && self.bpm > 1.0 + } + + /// Länge eines Beats in Sample-Frames. + pub fn frames_per_beat(&self, sample_rate: u32) -> f64 { + 60.0 / self.bpm as f64 * sample_rate as f64 + } + + /// Fortlaufende Beat-Nummer an einer Position. Kann negativ sein, wenn die + /// Position vor dem Anker liegt. + pub fn beat_at(&self, frame: f64, sample_rate: u32) -> f64 { + (frame - self.anchor_frames as f64) / self.frames_per_beat(sample_rate) + } + + /// Umkehrung: Position einer Beat-Nummer. + pub fn frame_of_beat(&self, beat: f64, sample_rate: u32) -> f64 { + self.anchor_frames as f64 + beat * self.frames_per_beat(sample_rate) + } + + /// Lage innerhalb des laufenden Beats, 0.0 bis 1.0. + pub fn phase_at(&self, frame: f64, sample_rate: u32) -> f64 { + self.beat_at(frame, sample_rate).rem_euclid(1.0) + } + + /// Nächstgelegene Beat-Position zu `frame`. + pub fn snap(&self, frame: f64, sample_rate: u32) -> f64 { + let beat = self.beat_at(frame, sample_rate).round(); + self.frame_of_beat(beat, sample_rate) + } +} + +/// Kürzester Weg von `from` nach `to` auf einem Kreis der Länge 1. +/// +/// Liegt ein Deck bei Phase 0,9 und das andere bei 0,1, ist der richtige Weg +/// +0,2 vorwärts und nicht −0,8 rückwärts. Ohne diese Wicklung würde Sync +/// einen fast ganzen Beat springen, wo ein Zehntel genügt. +pub fn shortest_phase_delta(from: f64, to: f64) -> f64 { + let raw = (to - from).rem_euclid(1.0); + if raw > 0.5 { raw - 1.0 } else { raw } +} + +#[cfg(test)] +mod tests { + use super::*; + + const RATE: u32 = 48_000; + + fn grid() -> Beatgrid { + // 120 BPM: ein Beat sind exakt 24 000 Frames bei 48 kHz. + Beatgrid::new(120.0, 10_000, 1.0) + } + + #[test] + fn beat_laenge_stimmt() { + assert!((grid().frames_per_beat(RATE) - 24_000.0).abs() < 1e-6); + } + + #[test] + fn anker_liegt_auf_beat_null() { + let g = grid(); + assert!((g.beat_at(10_000.0, RATE) - 0.0).abs() < 1e-9); + assert!((g.beat_at(34_000.0, RATE) - 1.0).abs() < 1e-9); + assert!((g.beat_at(-14_000.0, RATE) + 1.0).abs() < 1e-9); + } + + #[test] + fn hin_und_zurueck_ist_verlustfrei() { + let g = grid(); + for beat in [-3.5, 0.0, 1.25, 17.75] { + let frame = g.frame_of_beat(beat, RATE); + assert!((g.beat_at(frame, RATE) - beat).abs() < 1e-9); + } + } + + #[test] + fn phase_laeuft_von_null_bis_eins() { + let g = grid(); + assert!((g.phase_at(10_000.0, RATE) - 0.0).abs() < 1e-9); + assert!((g.phase_at(22_000.0, RATE) - 0.5).abs() < 1e-9); + assert!((g.phase_at(33_999.0, RATE) - 0.99995).abs() < 1e-4); + + // Auch vor dem Anker muss die Phase im Bereich bleiben. + let vorher = g.phase_at(-1_000.0, RATE); + assert!((0.0..1.0).contains(&vorher), "Phase {vorher} außerhalb"); + } + + #[test] + fn snap_zieht_auf_den_naechsten_beat() { + let g = grid(); + assert!((g.snap(11_000.0, RATE) - 10_000.0).abs() < 1e-6); + assert!((g.snap(33_000.0, RATE) - 34_000.0).abs() < 1e-6); + } + + #[test] + fn phasendifferenz_nimmt_den_kurzen_weg() { + // 0.9 → 0.1 sind 0.2 vorwärts, nicht 0.8 rückwärts. + assert!((shortest_phase_delta(0.9, 0.1) - 0.2).abs() < 1e-9); + assert!((shortest_phase_delta(0.1, 0.9) + 0.2).abs() < 1e-9); + assert!((shortest_phase_delta(0.25, 0.75) - 0.5).abs() < 1e-9); + assert!(shortest_phase_delta(0.5, 0.5).abs() < 1e-9); + } + + #[test] + fn unbrauchbare_grids_werden_erkannt() { + assert!(grid().is_usable()); + assert!(!Beatgrid::new(0.0, 0, 0.0).is_usable()); + assert!(!Beatgrid::new(f32::NAN, 0, 0.0).is_usable()); + } +} diff --git a/crates/audio-core/src/lib.rs b/crates/audio-core/src/lib.rs new file mode 100644 index 0000000..6726fad --- /dev/null +++ b/crates/audio-core/src/lib.rs @@ -0,0 +1,25 @@ +//! Audio-Kern für ein Traktor-artiges DJ-Tool. +//! +//! Phase 0: ein Deck, Wiedergabe, Tempo mit und ohne Keylock. Der Zweck ist, +//! die Stack-Entscheidung nativ vs. Browser empirisch zu belegen — siehe +//! `docs/BAUSTEINE.md`. + +pub mod deck; +pub mod error; +pub mod grid; +pub mod player; +pub mod stretch; +pub mod struktur; +pub mod tonart; +pub mod track; + +#[cfg(test)] +mod testing; + +pub use deck::DeckState; +pub use error::{AudioError, Result}; +pub use grid::Beatgrid; +pub use player::Player; +pub use struktur::{Abschnitt, Art, PHRASE_BEATS, Struktur}; +pub use tonart::Tonart; +pub use track::{MAX_STEMS, Stem, Track}; diff --git a/crates/audio-core/src/player.rs b/crates/audio-core/src/player.rs new file mode 100644 index 0000000..88759fd --- /dev/null +++ b/crates/audio-core/src/player.rs @@ -0,0 +1,115 @@ +//! Anbindung an das Ausgabegerät via CPAL. +//! +//! Der Track wird beim Öffnen einmalig auf die Geräte-Samplerate gebracht. +//! Dadurch muss der Callback nur noch Tempo behandeln und keine +//! Ratenkonvertierung — eine Rechenstufe weniger im zeitkritischen Pfad. + +use std::sync::Arc; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{FromSample, SampleFormat, SizedSample, StreamConfig}; + +use crate::deck::{DeckState, Voice}; +use crate::error::{AudioError, Result}; +use crate::track::Track; + +pub struct Player { + state: Arc, + sample_rate: u32, + device_name: String, + // Das Abreißen des Streams stoppt die Wiedergabe — er muss am Leben bleiben. + _stream: cpal::Stream, +} + +impl Player { + /// Öffnet das Standard-Ausgabegerät und legt den Track auf ein Deck. + pub fn open(track: Track) -> Result { + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or(AudioError::NoOutputDevice)?; + let device_name = device + .id() + .map(|id| id.to_string()) + .unwrap_or_else(|_| "unbekannt".to_string()); + + let supported = device.default_output_config()?; + let sample_format = supported.sample_format(); + let config: StreamConfig = supported.into(); + let sample_rate = config.sample_rate; + + let track = Arc::new(track.resampled_to(sample_rate)); + let state = Arc::new(DeckState::new()); + let voice = Voice::new(Arc::clone(&track), Arc::clone(&state)); + + let stream = match sample_format { + SampleFormat::F32 => build::(&device, config, voice)?, + SampleFormat::I16 => build::(&device, config, voice)?, + SampleFormat::I32 => build::(&device, config, voice)?, + SampleFormat::U16 => build::(&device, config, voice)?, + SampleFormat::U8 => build::(&device, config, voice)?, + SampleFormat::F64 => build::(&device, config, voice)?, + other => { + return Err(AudioError::UnsupportedSampleFormat(other.to_string())); + } + }; + + stream.play()?; + + Ok(Player { + state, + sample_rate, + device_name, + _stream: stream, + }) + } + + pub fn state(&self) -> &Arc { + &self.state + } + + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + pub fn device_name(&self) -> &str { + &self.device_name + } + + pub fn position_secs(&self) -> f64 { + self.state.position_frames() as f64 / self.sample_rate as f64 + } + + pub fn seek_secs(&self, secs: f64) { + let frame = (secs.max(0.0) * self.sample_rate as f64) as u64; + self.state.seek_frames(frame); + } +} + +fn build(device: &cpal::Device, config: StreamConfig, mut voice: Voice) -> Result +where + T: SizedSample + FromSample, +{ + let channels = config.channels as usize; + // Zwischenpuffer in f32; der Callback allokiert dadurch nicht. + let mut block: Vec = Vec::new(); + + let stream = device.build_output_stream( + config, + move |data: &mut [T], _: &cpal::OutputCallbackInfo| { + if block.len() < data.len() { + block.resize(data.len(), 0.0); + } + let scratch = &mut block[..data.len()]; + voice.render(scratch, channels); + + for (dst, src) in data.iter_mut().zip(scratch.iter()) { + *dst = T::from_sample(*src); + } + }, + |err| eprintln!("Audio-Stream: {err}"), + None, + )?; + + Ok(stream) +} diff --git a/crates/audio-core/src/stretch.rs b/crates/audio-core/src/stretch.rs new file mode 100644 index 0000000..2f6b470 --- /dev/null +++ b/crates/audio-core/src/stretch.rs @@ -0,0 +1,279 @@ +//! WSOLA-Zeitstreckung — Tempo ändern, Tonhöhe behalten (Keylock). +//! +//! Das Verfahren schneidet überlappende Frames aus dem Quellmaterial und +//! überblendet sie mit einem Hann-Fenster. Damit an der Nahtstelle keine +//! Phasensprünge hörbar werden, wird die Schnittposition lokal um bis zu +//! `SEARCH` Samples verschoben — dorthin, wo die Kreuzkorrelation mit dem +//! bereits ausgegebenen Übergang am größten ist. +//! +//! Wichtig: Die *ideale* Leseposition läuft ungestört weiter, die Suche ist nur +//! eine lokale Korrektur. Würde man den gefundenen Versatz aufaddieren, driftete +//! die Wiedergabe über die Zeit aus dem Takt. +//! +//! Qualität: brauchbar für einen Prototyp und für die üblichen ±8 % im DJ-Betrieb. +//! Für extreme Faktoren oder Produktionsqualität siehe `docs/BAUSTEINE.md` — +//! Rubber Band oder signalsmith-stretch spielen in einer anderen Liga. + +use crate::track::CHANNELS; + +const FRAME: usize = 2048; +const HOP: usize = FRAME / 2; +const OVERLAP: usize = FRAME - HOP; +const SEARCH: usize = 256; +const CORR_LEN: usize = 256; + +pub struct Wsola { + window: Vec, + /// Overlap-Add-Akkumulator, `FRAME` Frames à `CHANNELS`. + accum: Vec, + /// Fertig produzierter, noch nicht abgeholter Hop. + fifo: Vec, + fifo_read: usize, +} + +impl Default for Wsola { + fn default() -> Self { + Self::new() + } +} + +impl Wsola { + pub fn new() -> Self { + // Periodisches Hann-Fenster: bei 50 % Überlappung summieren sich zwei + // benachbarte Fenster exakt zu 1.0, es braucht also keine Normierung. + let window = (0..FRAME) + .map(|i| 0.5 - 0.5 * (2.0 * std::f32::consts::PI * i as f32 / FRAME as f32).cos()) + .collect(); + + Wsola { + window, + accum: vec![0.0; FRAME * CHANNELS], + fifo: vec![0.0; HOP * CHANNELS], + fifo_read: HOP * CHANNELS, + } + } + + /// Verwirft den internen Zustand — nach einem Sprung oder Moduswechsel. + pub fn reset(&mut self) { + self.accum.fill(0.0); + self.fifo_read = self.fifo.len(); + } + + /// Füllt `out` (interleaved Stereo) aus `src`. + /// + /// `pos` ist die Leseposition in Quell-Frames und wird fortgeschrieben, + /// `ratio` das Tempoverhältnis (1.0 = Original). Gibt `false` zurück, wenn + /// die Quelle erschöpft ist; der Rest von `out` ist dann Stille. + pub fn render(&mut self, src: &[f32], ratio: f64, pos: &mut f64, out: &mut [f32]) -> bool { + self.render_gemischt(src, std::slice::from_ref(&src), &[1.0], ratio, pos, out) + } + + /// Dasselbe aus mehreren Spuren, jede mit ihrem Pegel. + /// + /// **Gesucht wird auf der Summe, angewandt auf jede Spur.** Das ist der + /// Kern: Die Zeitstreckung sucht sich in jedem Hop die Stelle, an der die + /// Wellenform am besten anschließt. Liefe diese Suche je Spur getrennt, + /// fände jede eine andere — vier Spuren desselben Stücks liefen um + /// Millisekunden auseinander, und was vorher zusammen klang, klänge + /// verwaschen. Eine Entscheidung für alle, und `referenz` ist die Summe, + /// die ohnehin schon dasteht. + /// + /// Gemischt wird beim Lesen und nicht danach. Das ist dasselbe Ergebnis — + /// dieselbe Stelle, dasselbe Fenster —, kostet aber einen statt vier + /// Akkumulatoren, und im Audio-Callback zählt das. + pub fn render_gemischt( + &mut self, + referenz: &[f32], + spuren: &[&[f32]], + pegel: &[f32], + ratio: f64, + pos: &mut f64, + out: &mut [f32], + ) -> bool { + let mut written = 0; + + while written < out.len() { + if self.fifo_read < self.fifo.len() { + let n = (out.len() - written).min(self.fifo.len() - self.fifo_read); + out[written..written + n] + .copy_from_slice(&self.fifo[self.fifo_read..self.fifo_read + n]); + written += n; + self.fifo_read += n; + continue; + } + + if !self.produce_hop(referenz, spuren, pegel, ratio, pos) { + out[written..].fill(0.0); + return false; + } + } + + true + } + + /// Erzeugt genau einen Hop Ausgangsmaterial in den FIFO. + fn produce_hop( + &mut self, + referenz: &[f32], + spuren: &[&[f32]], + pegel: &[f32], + ratio: f64, + pos: &mut f64, + ) -> bool { + let src_frames = spuren + .iter() + .map(|s| s.len() / CHANNELS) + .chain(std::iter::once(referenz.len() / CHANNELS)) + .min() + .unwrap_or(0); + if src_frames < FRAME { + return false; + } + + let ideal = pos.round(); + if ideal < 0.0 { + return false; + } + let max_start = src_frames - FRAME; + if ideal as usize > max_start { + return false; + } + + let start = self.best_offset(referenz, ideal as usize, max_start); + + for i in 0..FRAME { + let w = self.window[i]; + let s = (start + i) * CHANNELS; + let a = i * CHANNELS; + for c in 0..CHANNELS { + let mut summe = 0.0; + for (spur, p) in spuren.iter().zip(pegel) { + summe += spur[s + c] * p; + } + self.accum[a + c] += summe * w; + } + } + + self.fifo.copy_from_slice(&self.accum[..HOP * CHANNELS]); + self.fifo_read = 0; + + self.accum.copy_within(HOP * CHANNELS.., 0); + self.accum[OVERLAP * CHANNELS..].fill(0.0); + + // Ideale Position fortschreiben, nicht die gefundene — sonst driftet es. + *pos += HOP as f64 * ratio; + true + } + + /// Sucht im Fenster ±`SEARCH` um `ideal` die Position, deren Wellenform am + /// besten an den bereits ausgegebenen Übergang anschließt. + fn best_offset(&self, src: &[f32], ideal: usize, max_start: usize) -> usize { + let lo = ideal.saturating_sub(SEARCH); + let hi = (ideal + SEARCH).min(max_start); + if lo >= hi { + return ideal.min(max_start); + } + + let mut best = ideal.min(max_start); + let mut best_score = f32::NEG_INFINITY; + + for cand in lo..=hi { + let mut dot = 0.0f32; + let mut energy = 0.0f32; + + for i in 0..CORR_LEN { + let a = i * CHANNELS; + let reference = self.accum[a] + self.accum[a + 1]; + let s = (cand + i) * CHANNELS; + let sample = src[s] + src[s + 1]; + dot += reference * sample; + energy += sample * sample; + } + + // Normieren, damit nicht einfach die lauteste Stelle gewinnt. + let score = dot / (energy.sqrt() + 1e-9); + if score > best_score { + best_score = score; + best = cand; + } + } + + best + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{dominant_freq, sine}; + + const RATE: u32 = 44_100; + + /// Der Kern von Keylock: Tempo ändern, Tonhöhe halten. + #[test] + fn keylock_haelt_die_tonhoehe() { + for ratio in [0.92_f64, 1.06, 1.20] { + let src = sine(440.0, RATE, 10.0); + let mut pos = 0.0; + let mut wsola = Wsola::new(); + let mut out = vec![0.0; 4 * RATE as usize * CHANNELS]; + + assert!( + wsola.render(&src, ratio, &mut pos, &mut out), + "Quelle war zu früh erschöpft bei ratio={ratio}" + ); + + let freq = dominant_freq(&out, RATE); + assert!( + (freq - 440.0).abs() < 13.0, + "ratio={ratio}: erwartet ~440 Hz, gemessen {freq:.1} Hz" + ); + } + } + + /// Bei ratio=1.06 müssen 4 s Ausgabe rund 4,24 s Quelle verbrauchen. + #[test] + fn zeitachse_stimmt() { + let src = sine(440.0, RATE, 20.0); + let mut pos = 0.0; + let mut wsola = Wsola::new(); + let out_frames = 4 * RATE as usize; + let mut out = vec![0.0; out_frames * CHANNELS]; + + assert!(wsola.render(&src, 1.06, &mut pos, &mut out)); + + let erwartet = out_frames as f64 * 1.06; + // Toleranz: ein Hop, der wegen des FIFO noch nicht ausgegeben wurde. + assert!( + (pos - erwartet).abs() < (FRAME as f64), + "Position {pos:.0} weicht von erwarteten {erwartet:.0} ab" + ); + } + + /// Läuft die Quelle aus, meldet render() das und liefert Stille statt Müll. + #[test] + fn quellenende_wird_gemeldet() { + let src = sine(440.0, RATE, 0.5); + let mut pos = 0.0; + let mut wsola = Wsola::new(); + let mut out = vec![0.123; 2 * RATE as usize * CHANNELS]; + + assert!(!wsola.render(&src, 1.0, &mut pos, &mut out)); + assert_eq!( + out.last().copied(), + Some(0.0), + "nach Quellenende muss Stille stehen" + ); + } + + /// Ein zu kurzer Puffer darf nicht paniken. + #[test] + fn zu_kurze_quelle_paniert_nicht() { + let src = sine(440.0, RATE, 0.001); + let mut pos = 0.0; + let mut wsola = Wsola::new(); + let mut out = vec![0.0; 512 * CHANNELS]; + + assert!(!wsola.render(&src, 1.0, &mut pos, &mut out)); + } +} diff --git a/crates/audio-core/src/struktur.rs b/crates/audio-core/src/struktur.rs new file mode 100644 index 0000000..bba5e21 --- /dev/null +++ b/crates/audio-core/src/struktur.rs @@ -0,0 +1,223 @@ +//! Die Gliederung eines Tracks als Wert. +//! +//! Nur die Begriffe, nicht ihre Erkennung — die steht in `analysis::struktur` +//! und braucht FFT, Hüllkurven und Schwellen. Hier liegt, was jeder lesen +//! muss, der mit einem Track umgeht: das Deck, das Pult, die Oberfläche. +//! +//! Dieselbe Aufteilung wie bei [`crate::Tonart`], und aus demselben Grund: +//! `control` soll wissen, was ein Outro ist, ohne den halben Analysestapel +//! mitzuziehen. + +/// Wie lang eine Phrase standardmäßig ist. +/// +/// Sechzehn Beats sind vier Takte — die Gruppe, in der House und Techno gebaut +/// sind und an deren Grenzen ein Übergang sitzt. Wer anderes auflegt, schreibt +/// `deckN.phrase_beats`. +/// +/// Steht hier und nicht in `control` oder `analysis`, weil beide dieselbe Zahl +/// meinen müssen: Die Gliederung schneidet auf Phrasengrenzen, und `in phrase` +/// zielt darauf. Zwei Konstanten wären zwei Stellen zum Auseinanderlaufen. +pub const PHRASE_BEATS: f64 = 16.0; + +/// Was für ein Teil eines Tracks das ist. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Art { + Intro, + Aufbau, + Drop, + Break, + Outro, + /// Läuft, ohne sich als eines der anderen zu erkennen zu geben. + /// + /// Kein Verlegenheitswert: Die meisten Tracks haben Strecken, die schlicht + /// laufen. Sie „Drop" zu nennen, weil ein Name dastehen soll, wäre die + /// schlechtere Antwort. + Teil, +} + +impl Art { + pub fn name(&self) -> &'static str { + match self { + Art::Intro => "intro", + Art::Aufbau => "aufbau", + Art::Drop => "drop", + Art::Break => "break", + Art::Outro => "outro", + Art::Teil => "teil", + } + } + + pub fn parse(text: &str) -> Option { + match text { + "intro" => Some(Art::Intro), + "aufbau" => Some(Art::Aufbau), + "drop" => Some(Art::Drop), + "break" => Some(Art::Break), + "outro" => Some(Art::Outro), + "teil" => Some(Art::Teil), + _ => None, + } + } + + /// Alle Arten, für Katalog und Auswahl. + pub const ALLE: [Art; 6] = [ + Art::Intro, + Art::Aufbau, + Art::Drop, + Art::Break, + Art::Outro, + Art::Teil, + ]; +} + +/// Ein Abschnitt des Tracks. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Abschnitt { + /// Anfang in Sample-Frames. **Die maßgebliche Angabe** — der Beat ist + /// daraus gerechnet und dient dem Lesen. + pub von_frames: u64, + /// Ende in Sample-Frames, ausschließlich. + pub bis_frames: u64, + pub von_beat: f64, + pub bis_beat: f64, + pub art: Art, + /// Mittlerer Pegel, 0..1 als Anteil am lautesten Abschnitt des Tracks. + pub pegel: f32, + /// Mittlerer Bassanteil, ebenso relativ. + pub bass: f32, + /// Mittlere Onset-Dichte, ebenso relativ. + pub dichte: f32, +} + +impl Abschnitt { + pub fn beats(&self) -> f64 { + self.bis_beat - self.von_beat + } +} + +/// Die Gliederung eines Tracks. +#[derive(Debug, Clone, PartialEq)] +pub struct Struktur { + pub abschnitte: Vec, + pub phrase_beats: f64, +} + +impl Struktur { + /// Wo der eingehende Track einsetzen sollte. + /// + /// Der Anfang des ersten Abschnitts — und der liegt bauartbedingt auf einer + /// Phrasengrenze. Das ist der ganze Unterschied zu „Sekunde 0": Was davor + /// liegt, ist Vorlauf und gehört nicht in den Mix. + pub fn einstieg_frames(&self) -> Option { + self.abschnitte.first().map(|a| a.von_frames) + } + + /// Wo das Outro anfängt — die Stelle, an der ein Übergang liegen darf. + /// + /// `None`, wenn der Track keines hat. Das ist eine Auskunft und kein + /// Mangel: Nicht jede Produktion blendet aus. + pub fn outro_frames(&self) -> Option { + self.abschnitte + .iter() + .find(|a| a.art == Art::Outro) + .map(|a| a.von_frames) + } + + /// Wie lang das Intro ist, in Beats. `None` ohne Intro. + /// + /// Damit wird „ein Track mit langem Intro" eine Anforderung, die ein Agent + /// stellen kann. + pub fn intro_beats(&self) -> Option { + self.abschnitte + .iter() + .find(|a| a.art == Art::Intro) + .map(|a| a.beats()) + } + + /// In welchem Abschnitt ein Frame liegt. + pub fn bei_frames(&self, frames: u64) -> Option<&Abschnitt> { + self.abschnitte + .iter() + .find(|a| frames >= a.von_frames && frames < a.bis_frames) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn abschnitt(art: Art, von: u64, bis: u64) -> Abschnitt { + Abschnitt { + von_frames: von, + bis_frames: bis, + von_beat: von as f64 / 100.0, + bis_beat: bis as f64 / 100.0, + art, + pegel: 0.5, + bass: 0.5, + dichte: 0.5, + } + } + + fn beispiel() -> Struktur { + Struktur { + phrase_beats: PHRASE_BEATS, + abschnitte: vec![ + abschnitt(Art::Intro, 100, 1_000), + abschnitt(Art::Drop, 1_000, 3_000), + abschnitt(Art::Outro, 3_000, 4_000), + ], + } + } + + #[test] + fn jede_art_ueberlebt_ihren_namen() { + for art in Art::ALLE { + assert_eq!(Art::parse(art.name()), Some(art)); + } + assert_eq!(Art::parse("refrain"), None); + } + + /// Der Einstieg ist der Anfang des ersten Abschnitts, nicht Frame 0. + #[test] + fn der_einstieg_ist_der_anfang_des_ersten_abschnitts() { + assert_eq!(beispiel().einstieg_frames(), Some(100)); + assert_eq!( + Struktur { + phrase_beats: PHRASE_BEATS, + abschnitte: Vec::new() + } + .einstieg_frames(), + None + ); + } + + #[test] + fn outro_und_intro_lassen_sich_finden() { + let s = beispiel(); + assert_eq!(s.outro_frames(), Some(3_000)); + assert_eq!(s.intro_beats(), Some(9.0)); + } + + /// Ohne Outro wird keines erfunden. Nicht jede Produktion blendet aus. + #[test] + fn ohne_outro_kommt_nichts_zurueck() { + let s = Struktur { + phrase_beats: PHRASE_BEATS, + abschnitte: vec![abschnitt(Art::Drop, 0, 1_000)], + }; + assert_eq!(s.outro_frames(), None); + assert_eq!(s.intro_beats(), None); + } + + #[test] + fn ein_abschnitt_laesst_sich_ueber_den_frame_finden() { + let s = beispiel(); + assert_eq!(s.bei_frames(100).map(|a| a.art), Some(Art::Intro)); + assert_eq!(s.bei_frames(999).map(|a| a.art), Some(Art::Intro)); + assert_eq!(s.bei_frames(1_000).map(|a| a.art), Some(Art::Drop)); + // Vor dem ersten Abschnitt liegt der Vorlauf, dahinter nichts mehr. + assert!(s.bei_frames(0).is_none()); + assert!(s.bei_frames(4_000).is_none()); + } +} diff --git a/crates/audio-core/src/testing.rs b/crates/audio-core/src/testing.rs new file mode 100644 index 0000000..a31057c --- /dev/null +++ b/crates/audio-core/src/testing.rs @@ -0,0 +1,37 @@ +//! Hilfsmittel für die Tests des Audio-Kerns. + +use crate::track::CHANNELS; + +/// Erzeugt einen Sinus als interleaved Stereo. +pub fn sine(freq: f32, rate: u32, secs: f32) -> Vec { + let frames = (rate as f32 * secs) as usize; + let mut out = vec![0.0; frames * CHANNELS]; + let step = 2.0 * std::f32::consts::PI * freq / rate as f32; + + for i in 0..frames { + let v = (step * i as f32).sin(); + out[i * CHANNELS] = v; + out[i * CHANNELS + 1] = v; + } + out +} + +/// Schätzt die Grundfrequenz über positive Nulldurchgänge des linken Kanals. +/// +/// Für einen reinen Sinus genau genug — und anders als eine FFT ohne +/// zusätzliche Abhängigkeit zu haben. +pub fn dominant_freq(samples: &[f32], rate: u32) -> f32 { + let left: Vec = samples.iter().step_by(CHANNELS).copied().collect(); + if left.len() < 2 { + return 0.0; + } + + let mut cycles = 0u32; + for w in left.windows(2) { + if w[0] <= 0.0 && w[1] > 0.0 { + cycles += 1; + } + } + + cycles as f32 * rate as f32 / left.len() as f32 +} diff --git a/crates/audio-core/src/tonart.rs b/crates/audio-core/src/tonart.rs new file mode 100644 index 0000000..76e69db --- /dev/null +++ b/crates/audio-core/src/tonart.rs @@ -0,0 +1,243 @@ +//! Tonart als Wert — Name, Camelot-Notation und harmonische Nachbarschaft. +//! +//! Hier steht nur das Vokabular, nicht das Erkennen. Das gehört nach +//! `analysis`, weil es eine FFT braucht und offline läuft. Der Wert dagegen +//! wird überall gebraucht: die Sammlung filtert danach, das Steuerpult gibt +//! ihn aus, die Oberfläche zeigt ihn an. Läge er in `analysis`, müssten alle +//! drei den Analyseapparat mitschleppen, nur um „Am" schreiben zu können — +//! dasselbe Argument, aus dem [`crate::Beatgrid`] hier liegt und nicht dort. + +/// Die zwölf Halbtöne, beginnend bei C. +pub const TOENE: [&str; 12] = [ + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Tonart { + /// 0 = C, 1 = C#, … 11 = B. + pub grundton: u8, + pub dur: bool, +} + +impl Tonart { + pub fn neu(grundton: u8, dur: bool) -> Tonart { + Tonart { + grundton: grundton % 12, + dur, + } + } + + /// Übliche Schreibweise: `Am`, `F#`, `C`. + pub fn name(&self) -> String { + let ton = TOENE[self.grundton as usize % 12]; + if self.dur { + ton.to_string() + } else { + format!("{ton}m") + } + } + + /// Camelot-Notation, nach der DJs mischen: `8A`, `5B`. + /// + /// Auf dem Rad liegen verwandte Tonarten nebeneinander. A ist Moll, B ist + /// Dur; 8A (a-Moll) und 8B (C-Dur) sind Paralleltonarten. + pub fn camelot(&self) -> String { + let (zahl, seite) = self.camelot_teile(); + format!("{zahl}{seite}") + } + + /// Zahl und Seite getrennt — für Vergleiche, die nicht über Text gehen + /// sollen. + pub fn camelot_teile(&self) -> (u8, char) { + // Der Quintenzirkel in Camelot-Zählung. Index ist der Grundton. + const DUR_ZAHL: [u8; 12] = [8, 3, 10, 5, 12, 7, 2, 9, 4, 11, 6, 1]; + const MOLL_ZAHL: [u8; 12] = [5, 12, 7, 2, 9, 4, 11, 6, 1, 8, 3, 10]; + + let i = self.grundton as usize % 12; + if self.dur { + (DUR_ZAHL[i], 'B') + } else { + (MOLL_ZAHL[i], 'A') + } + } + + /// Liest `Am`, `F#`, `8A` oder `5B` zurück ein. + pub fn parse(text: &str) -> Option { + let text = text.trim(); + if text.is_empty() { + return None; + } + + // Camelot zuerst: fängt mit einer Ziffer an. + if text.chars().next().is_some_and(|c| c.is_ascii_digit()) { + let (zahl, buchstabe) = text.split_at(text.len() - 1); + let zahl: u8 = zahl.parse().ok()?; + let dur = match buchstabe { + "B" | "b" => true, + "A" | "a" => false, + _ => return None, + }; + return (0..12u8) + .map(|g| Tonart { grundton: g, dur }) + .find(|t| t.camelot_teile() == (zahl, if dur { 'B' } else { 'A' })); + } + + let moll = text.ends_with('m') && !text.ends_with("dim"); + let ton = if moll { &text[..text.len() - 1] } else { text }; + let grundton = TOENE.iter().position(|t| t.eq_ignore_ascii_case(ton))?; + + Some(Tonart { + grundton: grundton as u8, + dur: !moll, + }) + } + + /// Ob sich zwei Tonarten harmonisch vertragen. + /// + /// Die Regel vom Camelot-Rad: dieselbe Zahl (Parallele), eine Zahl weiter + /// oder zurück, oder dieselbe Tonart. Mehr ist eine Frage des Geschmacks + /// und gehört nicht in eine Bibliothek. + pub fn passt_zu(&self, andere: &Tonart) -> bool { + let (za, sa) = self.camelot_teile(); + let (zb, sb) = andere.camelot_teile(); + + if sa == sb { + // Auf dem Rad ist nach 12 wieder 1. + let abstand = (za as i32 - zb as i32).rem_euclid(12); + abstand <= 1 || abstand >= 11 + } else { + za == zb + } + } + + /// Alle Tonarten, die sich mit dieser vertragen — sie selbst eingeschlossen. + /// + /// Vier Stück: sie selbst, die Parallele, und die beiden Nachbarn auf dem + /// Rad. Wer harmonisch sucht, braucht genau diese Liste, und sie hier + /// auszurechnen ist verlässlicher, als sie an drei Stellen von Hand + /// aufzuzählen. + pub fn verwandte(&self) -> Vec { + (0..12u8) + .flat_map(|g| [Tonart::neu(g, true), Tonart::neu(g, false)]) + .filter(|t| self.passt_zu(t)) + .collect() + } +} + +impl std::fmt::Display for Tonart { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str(&self.name()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn camelot_und_name_gehen_hin_und_zurueck() { + for grundton in 0..12u8 { + for dur in [true, false] { + let t = Tonart { grundton, dur }; + assert_eq!(Tonart::parse(&t.name()), Some(t), "Name {}", t.name()); + assert_eq!( + Tonart::parse(&t.camelot()), + Some(t), + "Camelot {}", + t.camelot() + ); + } + } + assert_eq!(Tonart::parse("Quatsch"), None); + assert_eq!(Tonart::parse(""), None); + } + + #[test] + fn die_camelot_zahlen_stimmen_mit_der_ueblichen_tafel() { + // Stichproben aus dem Rad, wie es auf jedem Spickzettel steht. + let paare = [ + ("C", "8B"), + ("Am", "8A"), + ("G", "9B"), + ("Em", "9A"), + ("F", "7B"), + ("Dm", "7A"), + ("A#", "6B"), + ]; + for (name, camelot) in paare { + let t = Tonart::parse(name).unwrap(); + assert_eq!(t.camelot(), camelot, "{name}"); + } + } + + #[test] + fn harmonisch_passt_was_auf_dem_rad_benachbart_ist() { + let a_moll = Tonart::parse("Am").unwrap(); + + // Parallele und Nachbarn. + assert!(a_moll.passt_zu(&Tonart::parse("C").unwrap()), "8A zu 8B"); + assert!(a_moll.passt_zu(&Tonart::parse("Em").unwrap()), "8A zu 9A"); + assert!(a_moll.passt_zu(&Tonart::parse("Dm").unwrap()), "8A zu 7A"); + assert!(a_moll.passt_zu(&a_moll), "zu sich selbst"); + + // Und was nicht passt, passt nicht. + assert!(!a_moll.passt_zu(&Tonart::parse("D#m").unwrap()), "8A zu 2A"); + assert!(!a_moll.passt_zu(&Tonart::parse("F#").unwrap()), "8A zu 11B"); + } + + #[test] + fn das_rad_schliesst_sich_bei_zwoelf() { + // 12A und 1A sind Nachbarn, auch wenn die Zahlen weit auseinander + // aussehen — ein Modulo-Fehler fiele genau hier auf. + let zwoelf = Tonart::parse("12A").unwrap(); + let eins = Tonart::parse("1A").unwrap(); + assert!( + zwoelf.passt_zu(&eins), + "{} zu {}", + zwoelf.camelot(), + eins.camelot() + ); + } + + #[test] + fn die_verwandtschaft_hat_genau_vier_mitglieder() { + let a_moll = Tonart::parse("Am").unwrap(); + let verwandte: Vec = a_moll.verwandte().iter().map(|t| t.camelot()).collect(); + + // Sie selbst, die Parallele und die beiden Nachbarn — nicht mehr. + assert_eq!(verwandte.len(), 4, "{verwandte:?}"); + for erwartet in ["8A", "8B", "7A", "9A"] { + assert!(verwandte.contains(&erwartet.to_string()), "{verwandte:?}"); + } + + // Und die Beziehung ist gegenseitig: Wer in meiner Liste steht, hat + // mich in seiner. Sonst hinge das Suchergebnis davon ab, von welchem + // Deck aus man fragt. + for andere in a_moll.verwandte() { + assert!( + andere.passt_zu(&a_moll), + "{} hat {} nicht zurück", + andere.camelot(), + a_moll.camelot() + ); + } + } + + #[test] + fn parallele_tonarten_bleiben_mischbar_auch_wenn_man_sie_verwechselt() { + // Dur und die parallele Molltonart bestehen aus denselben Tönen; ob + // C-Dur oder a-Moll herauskommt, ist auch für Menschen manchmal + // Auslegungssache. Beim Mischen ist das verschmerzbar — auf dem Rad + // tragen beide dieselbe Zahl. + let dur = Tonart::parse("C").unwrap(); + let moll = Tonart::parse("Am").unwrap(); + + assert_ne!(dur, moll); + assert_eq!(dur.camelot(), "8B"); + assert_eq!(moll.camelot(), "8A"); + assert!( + dur.passt_zu(&moll), + "eine Verwechslung der Parallelen darf beim Mischen nicht schaden" + ); + } +} diff --git a/crates/audio-core/src/track.rs b/crates/audio-core/src/track.rs new file mode 100644 index 0000000..df59929 --- /dev/null +++ b/crates/audio-core/src/track.rs @@ -0,0 +1,445 @@ +//! Dekodieren einer Audiodatei in einen zusammenhängenden Stereo-Puffer. +//! +//! Bewusst „alles in den Speicher": Ein Deck darf im Audio-Callback nicht auf +//! die Platte warten, und ein 5-Minuten-Track kostet als f32-Stereo rund 100 MB +//! — vertretbar für Phase 0. Streaming von Platte kommt, wenn die Library steht. + +use std::fs::File; +use std::path::Path; + +use symphonia::core::formats::TrackType; +use symphonia::core::formats::probe::Hint; +use symphonia::core::io::MediaSourceStream; + +use crate::error::{AudioError, Result}; + +/// Ein vollständig dekodierter Track als interleaved Stereo (L, R, L, R, …). +pub struct Track { + pub samples: Vec, + pub sample_rate: u32, + /// Die Einzelspuren, falls es welche gibt — siehe [`Track::mit_stems`]. + /// + /// `samples` bleibt die Summe. Das kostet Speicher und ist es wert: Analyse, + /// Wellenform und die Suche der Zeitstreckung arbeiten weiter auf einer + /// einzigen Spur, und ein Deck ohne Stems ändert sich überhaupt nicht. + pub stems: Vec, +} + +/// Eine Einzelspur — Stimme, Drums, Bass, Rest. +pub struct Stem { + pub name: String, + pub samples: Vec, +} + +pub const CHANNELS: usize = 2; + +/// Wie viele Einzelspuren ein Deck höchstens führt. +/// +/// Vier ist die übliche Aufteilung (Stimme, Drums, Bass, Rest) und zugleich +/// die Grenze, die den Steuerraum statisch hält — dieselbe Entscheidung wie +/// bei den Hot Cues und den Signalplätzen. +pub const MAX_STEMS: usize = 4; + +impl Track { + pub fn frames(&self) -> usize { + self.samples.len() / CHANNELS + } + + pub fn duration_secs(&self) -> f64 { + self.frames() as f64 / self.sample_rate as f64 + } + + /// Dekodiert eine Datei. Format wird über Inhalt und Dateiendung erkannt. + pub fn decode_file(path: &Path) -> Result { + let file = File::open(path)?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let mut format = symphonia::default::get_probe().probe( + &hint, + mss, + Default::default(), + Default::default(), + )?; + + let track = format + .default_track(TrackType::Audio) + .ok_or(AudioError::NoAudioTrack)?; + let track_id = track.id; + let params = track + .codec_params + .as_ref() + .and_then(|p| p.audio()) + .ok_or(AudioError::NoAudioTrack)?; + + let mut decoder = + symphonia::default::get_codecs().make_audio_decoder(params, &Default::default())?; + + let mut samples: Vec = Vec::new(); + let mut scratch: Vec = Vec::new(); + let mut sample_rate = 0u32; + + while let Some(packet) = format.next_packet()? { + if packet.track_id != track_id { + continue; + } + + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + // Einzelne defekte Pakete überspringen statt den ganzen Track + // zu verlieren — kommt bei geschnittenen MP3s regelmäßig vor. + Err(symphonia::core::errors::Error::DecodeError(_)) => continue, + Err(e) => return Err(e.into()), + }; + + let frames = decoded.frames(); + if frames == 0 { + continue; + } + sample_rate = decoded.spec().rate(); + let src_channels = decoded.samples_interleaved() / frames; + + decoded.copy_to_vec_interleaved(&mut scratch); + append_as_stereo(&mut samples, &scratch, src_channels, frames); + } + + if samples.is_empty() || sample_rate == 0 { + return Err(AudioError::NoAudioTrack); + } + + Ok(Track { + samples, + sample_rate, + stems: Vec::new(), + }) + } + + /// Lädt einen Track samt seiner Einzelspuren. + /// + /// # Wo die Stems liegen + /// + /// Neben der Datei in einem Ordner gleichen Namens mit der Endung + /// `.stems`: zu `nachtschicht.wav` also `nachtschicht.stems/` mit + /// `vocals.wav`, `drums.wav`, `bass.wav`, `other.wav`. Kein neues + /// Dateiformat, keine neue Abhängigkeit — und genau die Form, in der die + /// gängigen Trennwerkzeuge ihr Ergebnis ablegen. + /// + /// Ein eigenes Stem-Format zu lesen wäre die Alternative gewesen. Es ist + /// eine MP4-Datei mit fünf AAC-Spuren und gehört einem Hersteller; ein + /// Ordner mit vier WAV-Dateien gehört niemandem. + /// + /// # Was hier *nicht* passiert + /// + /// **Getrennt wird nicht.** Eine Stimme aus einer Mischung zu lösen ist + /// Arbeit für ein neuronales Netz, ein eigenes Werkzeug und eine eigene + /// Lizenzfrage. Hier wird gelesen, was jemand anders getrennt hat. + /// + /// Ohne Ordner ist das Ergebnis dasselbe wie [`Track::decode_file`] — kein + /// Fehler, sondern der Normalfall. + /// + /// # Speicher + /// + /// Vier Stems kosten das Fünffache eines Tracks, weil die Summe daneben + /// stehen bleibt: rund 500 MB je Deck bei fünf Minuten. Für zwei Decks + /// geht das; für die vier Decks, die der Plan vorsieht, braucht es + /// Streaming von Platte. Das steht bewusst noch aus — der Abspielpfad ist + /// der einzige Teil mit Echtzeitauflagen, und Platten-I/O gehört dort + /// zuletzt hinein. + pub fn decode_mit_stems(path: &Path) -> Result { + let mut track = Track::decode_file(path)?; + let ordner = stem_ordner(path); + if !ordner.is_dir() { + return Ok(track); + } + + let mut dateien: Vec = std::fs::read_dir(&ordner) + .map(|d| { + d.flatten() + .map(|e| e.path()) + .filter(|p| p.is_file()) + .collect() + }) + .unwrap_or_default(); + // Nach Namen, damit die Reihenfolge der Spuren reproduzierbar ist: + // `stem2_level` soll morgen dieselbe Spur meinen wie heute. + dateien.sort(); + + for datei in dateien.into_iter().take(MAX_STEMS) { + let Ok(stem) = Track::decode_file(&datei) else { + continue; + }; + let stem = if stem.sample_rate == track.sample_rate { + stem + } else { + stem.resampled_to(track.sample_rate) + }; + let name = datei + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + track.stems.push(Stem { + name, + samples: stem.samples, + }); + } + + // Eine einzelne Spur ist keine Trennung — sie zu führen kostete + // Speicher und brächte nichts, was `samples` nicht schon kann. + if track.stems.len() < 2 { + track.stems.clear(); + } + Ok(track) + } + + /// Bringt den Track auf die Rate des Ausgabegeräts. + /// + /// Läuft einmalig beim Laden, nicht im Audio-Callback — deshalb ist lineare + /// Interpolation hier unkritisch. Wandert das je in den Abspielpfad, gehört + /// ein richtiger Resampler her (siehe docs/BAUSTEINE.md). + pub fn resampled_to(self, target_rate: u32) -> Track { + if target_rate == self.sample_rate { + return self; + } + + let ratio = self.sample_rate as f64 / target_rate as f64; + let src_frames = self.frames(); + + // Die Einzelspuren müssen mit. Sie hier fallen zu lassen wäre der + // stillste denkbare Fehler: Der Track klänge richtig, nur ließe sich + // nichts mehr herausnehmen — und zwar genau auf den Geräten, deren + // Rate nicht zur Datei passt. + let stems = self + .stems + .iter() + .map(|stem| Stem { + name: stem.name.clone(), + samples: umrechnen(&stem.samples, ratio, src_frames), + }) + .collect(); + + Track { + samples: umrechnen(&self.samples, ratio, src_frames), + sample_rate: target_rate, + stems, + } + } +} + +/// Rechnet eine Spur auf eine andere Rate um, linear interpoliert. +/// +/// `src_frames` ist die Länge der *Summe*: Alle Spuren eines Tracks werden auf +/// dieselbe Länge gebracht, auch wenn eine Datei ein paar Frames länger ist als +/// die andere. Sonst liefen sie am Ende auseinander. +fn umrechnen(samples: &[f32], ratio: f64, src_frames: usize) -> Vec { + let eigene = samples.len() / CHANNELS; + let dst_frames = ((src_frames as f64) / ratio).floor() as usize; + let mut out = vec![0.0f32; dst_frames * CHANNELS]; + if eigene == 0 { + return out; + } + + for i in 0..dst_frames { + let pos = i as f64 * ratio; + let idx = (pos.floor() as usize).min(eigene - 1); + let frac = (pos - idx as f64) as f32; + let next = (idx + 1).min(eigene - 1); + for c in 0..CHANNELS { + let a = samples[idx * CHANNELS + c]; + let b = samples[next * CHANNELS + c]; + out[i * CHANNELS + c] = a + (b - a) * frac; + } + } + out +} + +/// Wo die Einzelspuren zu einer Datei liegen: `nachtschicht.wav` → +/// `nachtschicht.stems/`. +pub fn stem_ordner(pfad: &Path) -> std::path::PathBuf { + let mut name = pfad + .file_stem() + .map(|s| s.to_os_string()) + .unwrap_or_default(); + name.push(".stems"); + pfad.with_file_name(name) +} + +/// Hängt einen dekodierten Block als Stereo an. Mono wird dupliziert, bei mehr +/// als zwei Kanälen werden die ersten beiden genommen. +fn append_as_stereo(dst: &mut Vec, src: &[f32], src_channels: usize, frames: usize) { + match src_channels { + 0 => {} + 1 => { + for &s in &src[..frames] { + dst.push(s); + dst.push(s); + } + } + n => { + for i in 0..frames { + dst.push(src[i * n]); + dst.push(src[i * n + 1]); + } + } + } +} + +/// Ein Umrechnen, das die Einzelspuren fallen ließe, wäre der stillste +/// denkbare Fehler: Der Track klänge richtig, nur ließe sich nichts mehr +/// herausnehmen — und zwar genau auf den Geräten, deren Rate nicht zur Datei +/// passt. +#[cfg(test)] +mod stem_tests { + use super::*; + + fn spur(wert: f32, frames: usize) -> Vec { + vec![wert; frames * CHANNELS] + } + + #[test] + fn beim_umrechnen_kommen_die_spuren_mit() { + let track = Track { + samples: spur(0.5, 1_000), + sample_rate: 44_100, + stems: vec![ + Stem { + name: "vocals".into(), + samples: spur(0.2, 1_000), + }, + Stem { + name: "drums".into(), + samples: spur(0.3, 1_000), + }, + ], + }; + + let neu = track.resampled_to(48_000); + assert_eq!(neu.stems.len(), 2, "die Spuren sind weg"); + assert_eq!(neu.stems[0].name, "vocals"); + // Gleiche Länge wie die Summe — sonst laufen sie am Ende auseinander. + for stem in &neu.stems { + assert_eq!( + stem.samples.len(), + neu.samples.len(), + "{} ist anders lang als die Summe", + stem.name + ); + } + assert!((neu.stems[1].samples[0] - 0.3).abs() < 1e-6); + } + + /// Der Ordner heißt wie die Datei, nur mit `.stems` — und liegt daneben, + /// nicht darin. + #[test] + fn der_stem_ordner_liegt_neben_der_datei() { + assert_eq!( + stem_ordner(Path::new("/musik/nachtschicht.wav")), + Path::new("/musik/nachtschicht.stems") + ); + assert_eq!( + stem_ordner(Path::new("nachtschicht.mp3")), + Path::new("nachtschicht.stems") + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::dominant_freq; + use std::path::PathBuf; + + const RATE: u32 = 44_100; + + fn wav_pcm16(samples: &[i16], channels: u16, rate: u32) -> Vec { + let data_len = (samples.len() * 2) as u32; + let mut b = Vec::with_capacity(44 + data_len as usize); + b.extend_from_slice(b"RIFF"); + b.extend_from_slice(&(36 + data_len).to_le_bytes()); + b.extend_from_slice(b"WAVE"); + b.extend_from_slice(b"fmt "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); // PCM + b.extend_from_slice(&channels.to_le_bytes()); + b.extend_from_slice(&rate.to_le_bytes()); + b.extend_from_slice(&(rate * channels as u32 * 2).to_le_bytes()); + b.extend_from_slice(&(channels * 2).to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&data_len.to_le_bytes()); + for s in samples { + b.extend_from_slice(&s.to_le_bytes()); + } + b + } + + /// Schreibt eine Mono-WAV mit 1 kHz Sinus und gibt den Pfad zurück. + fn temp_wav(name: &str, secs: f32) -> PathBuf { + let frames = (RATE as f32 * secs) as usize; + let step = 2.0 * std::f32::consts::PI * 1000.0 / RATE as f32; + let pcm: Vec = (0..frames) + .map(|i| ((step * i as f32).sin() * 20_000.0) as i16) + .collect(); + + let path = std::env::temp_dir().join(name); + std::fs::write(&path, wav_pcm16(&pcm, 1, RATE)).expect("WAV schreiben"); + path + } + + #[test] + fn mono_wird_zu_stereo_dekodiert() { + let path = temp_wav("audio-core-mono.wav", 0.5); + let track = Track::decode_file(&path).expect("dekodieren"); + std::fs::remove_file(&path).ok(); + + assert_eq!(track.sample_rate, RATE); + assert_eq!(track.samples.len(), track.frames() * CHANNELS); + assert!( + (track.frames() as i64 - (RATE as f32 * 0.5) as i64).abs() < 64, + "unerwartete Länge: {} Frames", + track.frames() + ); + + // Mono muss auf beide Kanäle gelegt worden sein. + for i in (0..track.samples.len()).step_by(CHANNELS) { + assert_eq!(track.samples[i], track.samples[i + 1]); + } + } + + #[test] + fn resampling_haelt_die_tonhoehe() { + let path = temp_wav("audio-core-resample.wav", 1.0); + let track = Track::decode_file(&path).expect("dekodieren"); + std::fs::remove_file(&path).ok(); + + let resampled = track.resampled_to(48_000); + + assert_eq!(resampled.sample_rate, 48_000); + assert!( + (resampled.frames() as i64 - 48_000).abs() < 128, + "unerwartete Länge nach Resampling: {} Frames", + resampled.frames() + ); + + let freq = dominant_freq(&resampled.samples, 48_000); + assert!( + (freq - 1000.0).abs() < 10.0, + "Tonhöhe verschoben: {freq:.1} Hz statt 1000 Hz" + ); + } + + #[test] + fn resampling_auf_gleiche_rate_ist_ein_no_op() { + let path = temp_wav("audio-core-noop.wav", 0.2); + let track = Track::decode_file(&path).expect("dekodieren"); + std::fs::remove_file(&path).ok(); + + let frames_vorher = track.frames(); + let gleich = track.resampled_to(RATE); + + assert_eq!(gleich.frames(), frames_vorher); + } +} diff --git a/crates/audio-engine/Cargo.toml b/crates/audio-engine/Cargo.toml new file mode 100644 index 0000000..98a880a --- /dev/null +++ b/crates/audio-engine/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "audio-engine" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +audio-core = { version = "0.1.0", path = "../audio-core" } +cpal = "0.18.1" +rtrb = "0.3.4" diff --git a/crates/audio-engine/src/aufnahme.rs b/crates/audio-engine/src/aufnahme.rs new file mode 100644 index 0000000..0c8a53e --- /dev/null +++ b/crates/audio-engine/src/aufnahme.rs @@ -0,0 +1,908 @@ +//! Mitschnitt der Summe. +//! +//! Der Audio-Callback darf nicht auf die Platte schreiben. Ein `write` ist ein +//! Syscall, er kann Millisekunden dauern, und in dieser Zeit steht die +//! Wiedergabe — ein Aussetzer mitten im Set, und zwar genau dann, wenn man +//! mitschneidet. +//! +//! Also derselbe Weg wie beim AUX-Eingang, nur andersherum: ein lock-freier +//! Ringpuffer aus dem Audio-Thread heraus, ein eigener Thread, der ihn leert +//! und in eine WAV-Datei schreibt. +//! +//! **Wenn der Schreiber nicht hinterherkommt, gehen Frames verloren.** Das ist +//! die einzige Möglichkeit, die bleibt — blockieren wäre schlimmer. Verloren +//! heißt hier aber nicht verschwiegen: Sie werden gezählt und sind über +//! [`Aufnahme::verworfen`] abfragbar. Eine Aufnahme mit Lücken, die aussieht +//! wie eine ohne, wäre das schlechteste von allem. + +use std::fs::File; +use std::io::{BufWriter, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::Arc; + +use audio_core::track::CHANNELS; + +/// Wie viel Vorrat der Ringpuffer fasst, in Sekunden. +/// +/// Zwei Sekunden überbrücken jede normale Schreibpause. Größer hieße nur, dass +/// ein tatsächlich überlasteter Rechner den Verlust später merkt. +pub const PUFFER_SEKUNDEN: f32 = 2.0; + +/// Der Griff, den der Audio-Thread hält. +/// +/// Schreibt in den Ring und zählt, was nicht mehr hineinpasst. Allokiert nie. +pub struct Mitschnitt { + tx: rtrb::Producer, + aktiv: Arc, + verworfen: Arc, + geschrieben: Arc, +} + +impl Mitschnitt { + /// Nimmt einen Block verschränktes Stereo auf, falls die Aufnahme läuft. + pub fn nimm_auf(&mut self, buffer: &[f32]) { + if !self.aktiv.load(Ordering::Relaxed) { + return; + } + + let mut abgelegt = 0usize; + for sample in buffer { + if self.tx.push(*sample).is_err() { + break; + } + abgelegt += 1; + } + + let frames = (abgelegt / CHANNELS) as u64; + self.geschrieben.fetch_add(frames, Ordering::Relaxed); + + let fehlend = buffer.len() - abgelegt; + if fehlend > 0 { + self.verworfen + .fetch_add((fehlend / CHANNELS) as u64, Ordering::Relaxed); + } + } +} + +enum Befehl { + Start { + pfad: PathBuf, + sample_rate: u32, + }, + /// Schluss — und wie viele Frames noch in diese Datei gehören. + /// + /// Die Zahl kommt von der Seite, die aufnimmt, und ist die einzige, mit der + /// sich die Grenze zwischen zwei Mitschnitten bestimmen lässt. Ohne sie + /// müsste der Schreiber „alles, was noch im Ring liegt" nehmen — und darin + /// steckt womöglich schon der Anfang des nächsten. + Stop { + frames: u64, + }, + Ende, +} + +/// Der Griff für alle anderen: starten, stoppen, nachsehen. +pub struct Aufnahme { + befehle: Sender, + aktiv: Arc, + verworfen: Arc, + geschrieben: Arc, + pfad: Option, + sample_rate: u32, +} + +impl Aufnahme { + pub fn starten(&mut self, pfad: &Path) -> Result<(), String> { + if self.laeuft() { + return Err(format!( + "es läuft schon ein Mitschnitt nach {}", + self.pfad + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default() + )); + } + + // Höchstens **eine** Ebene anlegen. `~/sets/heute.wav` in einem noch + // leeren Ordner soll gehen; ein Tippfehler soll dagegen nicht einen + // ganzen Verzeichnisbaum irgendwo hinterlassen. Genau das ist beim + // Testen passiert — als root legte `create_dir_all` klaglos vier + // Ebenen unter `/` an. + if let Some(ordner) = pfad.parent().filter(|o| !o.as_os_str().is_empty()) { + if !ordner.is_dir() { + let da = ordner + .parent() + .is_none_or(|o| o.as_os_str().is_empty() || o.is_dir()); + if !da { + return Err(format!("{} gibt es nicht", ordner.display())); + } + std::fs::create_dir(ordner).map_err(|e| format!("{}: {e}", ordner.display()))?; + } + } + + // Erst die Datei öffnen lassen, dann den Audio-Thread losschreiben — + // andersherum liefen die ersten Frames ins Leere. + // Probehalber anlegen: Ein unschreibbarer Pfad soll sofort auffallen + // und nicht erst nach dem Set. + File::create(pfad).map_err(|e| format!("{}: {e}", pfad.display()))?; + + self.verworfen.store(0, Ordering::Relaxed); + self.geschrieben.store(0, Ordering::Relaxed); + self.befehle + .send(Befehl::Start { + pfad: pfad.to_path_buf(), + sample_rate: self.sample_rate, + }) + .map_err(|_| "der Schreiber läuft nicht mehr".to_string())?; + + self.pfad = Some(pfad.to_path_buf()); + self.aktiv.store(true, Ordering::Relaxed); + Ok(()) + } + + /// Beendet den Mitschnitt und gibt zurück, wohin er ging. + pub fn stoppen(&mut self) -> Option { + if !self.laeuft() { + return None; + } + + // Erst der Audio-Thread, dann der Schreiber: Was noch im Ring liegt, + // soll er zu Ende schreiben. + self.aktiv.store(false, Ordering::Relaxed); + let _ = self.befehle.send(Befehl::Stop { + frames: self.geschrieben.load(Ordering::Relaxed), + }); + self.pfad.take() + } + + pub fn laeuft(&self) -> bool { + self.aktiv.load(Ordering::Relaxed) + } + + pub fn pfad(&self) -> Option<&Path> { + self.pfad.as_deref() + } + + /// Aufgenommene Frames — durch die Samplerate geteilt ergibt das Sekunden. + pub fn frames(&self) -> u64 { + self.geschrieben.load(Ordering::Relaxed) + } + + pub fn sekunden(&self) -> f64 { + self.frames() as f64 / self.sample_rate.max(1) as f64 + } + + /// Mit welcher Rate mitgeschnitten wird. + /// + /// Wer Frames aufhebt, um sie später einer Stelle im Mitschnitt zuzuordnen, + /// braucht sie dazu — sonst sind es Zahlen ohne Zeit. + pub fn rate(&self) -> u32 { + self.sample_rate + } + + /// Frames, die nicht mehr in den Ring passten. + /// + /// Alles über null heißt: Der Mitschnitt hat Lücken. + pub fn verworfen(&self) -> u64 { + self.verworfen.load(Ordering::Relaxed) + } +} + +impl Drop for Aufnahme { + fn drop(&mut self) { + self.aktiv.store(false, Ordering::Relaxed); + let _ = self.befehle.send(Befehl::Ende); + } +} + +/// Legt Ringpuffer und Schreiber-Thread an. +pub fn mitschnitt(sample_rate: u32) -> (Mitschnitt, Aufnahme) { + let (tap, aufnahme, _nummer) = mitschnitt_intern(sample_rate); + (tap, aufnahme) +} + +/// Wie [`mitschnitt`], gibt aber die Nummer des Schreibers mit heraus. Nur der +/// Test braucht sie — um genau diesen einen Schreiber anzuhalten. +fn mitschnitt_intern(sample_rate: u32) -> (Mitschnitt, Aufnahme, u64) { + let kapazitaet = (sample_rate as f32 * PUFFER_SEKUNDEN) as usize * CHANNELS; + let (tx, rx) = rtrb::RingBuffer::new(kapazitaet); + + let aktiv = Arc::new(AtomicBool::new(false)); + let verworfen = Arc::new(AtomicU64::new(0)); + let geschrieben = Arc::new(AtomicU64::new(0)); + let (befehle, eingang) = std::sync::mpsc::channel(); + + let id = schreiber_id(); + let mitzaehlen = Arc::clone(&geschrieben); + std::thread::Builder::new() + .name("mitschnitt".into()) + .spawn(move || schreiben(rx, eingang, mitzaehlen, id)) + .expect("Schreiber-Thread ließ sich nicht starten"); + + ( + Mitschnitt { + tx, + aktiv: Arc::clone(&aktiv), + verworfen: Arc::clone(&verworfen), + geschrieben: Arc::clone(&geschrieben), + }, + Aufnahme { + befehle, + aktiv, + verworfen, + geschrieben, + pfad: None, + sample_rate, + }, + id, + ) +} + +/// Der Schreiber-Thread. +/// +/// Leert den Ring **immer**, auch ohne offene Datei. Täte er das nicht, liefe +/// der Puffer nach einem Stopp voll, und der nächste Start begänne mit den +/// Resten des letzten. Er leert ihn aber nur so weit, wie sich die Frames +/// zuordnen lassen — dazu unten beim Deckel mehr. +/// +/// Beim `Start` wird dagegen **nicht** geleert, und das ist wichtiger, als es +/// aussieht. Der Aufnehmende schreibt in den Ring, sobald `starten` zurück ist; +/// dieser Thread bekommt den Befehl erst, wenn er das nächste Mal an der Reihe +/// ist. Auf einem ausgelasteten Rechner liegen dann schon Samples im Ring, und +/// ein Leeren an dieser Stelle würfe genau den Anfang des Mitschnitts weg. +/// +/// # Befehle werden der Reihe nach ausgeführt +/// +/// Er holt sich seine Befehle in Häppchen ab, und in einem Häppchen können +/// `Stop` und der nächste `Start` zusammen liegen — zwischen zwei Stücken tut +/// das jeder. Sie zu Merkern zusammenzufassen und erst danach zu handeln war +/// falsch: Dann öffnete der `Start` die neue Datei, und der `Stop` schrieb den +/// *alten* Mitschnitt hinein und schloss sie. Die erste Datei blieb bei 44 +/// Bytes, die zweite bekam fremdes Material, und der zweite Mitschnitt landete +/// nirgends. +/// +/// Aufgefallen ist das nicht hier, sondern in der CI: Auf einem freien Rechner +/// kommt der Schreiber zwischen Stopp und Start dran, auf einem ausgelasteten +/// nicht. +/// +/// # Wo ein Mitschnitt aufhört +/// +/// Der `Stop` bringt die Zahl der Frames mit, die die aufnehmende Seite +/// abgelegt hat. Der Schreiber füllt bis dorthin und lässt liegen, was danach +/// kommt — das gehört schon zum nächsten. „Alles, was noch im Ring ist" wäre +/// die naheliegende Regel und die falsche: Genau darin steckt der Anfang des +/// nächsten Mitschnitts. +/// Ein Haltepunkt im Schreiber, den nur der Test kennt. +/// +/// Die Lücke, um die es geht, ist ein paar Maschinenbefehle breit — zwischen +/// „Befehle abholen" und „Ring leeren". Sie durch Wiederholen zu treffen ging +/// nicht: vierzig Läufe mit zwanzig Runden blieben grün. Also wird der +/// Schreiber an genau dieser Stelle angehalten, während der Test in Ruhe +/// stoppt, neu startet und nachlegt. +/// +/// Außerhalb von `cfg(test)` bleibt davon nichts übrig — beide Funktionen sind +/// leer und werden wegoptimiert. +#[cfg(not(test))] +#[inline(always)] +fn schreiber_id() -> u64 { + 0 +} + +#[cfg(not(test))] +#[inline(always)] +fn haltepunkt(_id: u64) {} + +#[cfg(test)] +static NAECHSTE_ID: AtomicU64 = AtomicU64::new(1); +#[cfg(test)] +static ANGEHALTEN: AtomicU64 = AtomicU64::new(0); +#[cfg(test)] +static PARKT: AtomicU64 = AtomicU64::new(0); + +#[cfg(test)] +fn schreiber_id() -> u64 { + NAECHSTE_ID.fetch_add(1, Ordering::Relaxed) +} + +/// Hält **nur** den Schreiber an, dessen Nummer der Test angemeldet hat. Alle +/// anderen laufen weiter — die Tests dieser Datei laufen nebeneinander. +#[cfg(test)] +fn haltepunkt(id: u64) { + if ANGEHALTEN.load(Ordering::Acquire) != id { + return; + } + PARKT.store(id, Ordering::Release); + while ANGEHALTEN.load(Ordering::Acquire) == id { + std::hint::spin_loop(); + } + PARKT.store(0, Ordering::Release); +} + +fn schreiben( + mut rx: rtrb::Consumer, + befehle: Receiver, + geschrieben: Arc, + id: u64, +) { + const TAKT: std::time::Duration = std::time::Duration::from_millis(20); + let mut datei: Option = None; + let mut frames: u64 = 0; + let mut block: Vec = Vec::with_capacity(8_192); + + /// Holt bis zu `hoechstens` Frames aus dem Ring. + fn holen(rx: &mut rtrb::Consumer, block: &mut Vec, hoechstens: u64) { + block.clear(); + let grenze = hoechstens.saturating_mul(CHANNELS as u64).min(8_192) as usize; + while block.len() < grenze { + match rx.pop() { + Ok(sample) => block.push(sample), + Err(_) => break, + } + } + } + + loop { + let mut beenden = false; + // Der Stand **vor** dem Abholen der Befehle. Alles, was danach in den + // Ring kommt, bleibt bis zur nächsten Runde liegen — siehe unten. + let stand = geschrieben.load(Ordering::Relaxed); + let mut frisch_gestartet = false; + + while let Ok(befehl) = befehle.try_recv() { + match befehl { + Befehl::Start { pfad, sample_rate } => { + // Eine noch offene Datei zuerst ordentlich schließen. Ohne + // das bliebe ihr Kopf ungeschrieben, und eine WAV-Datei mit + // falschem Kopf lesen manche Programme gar nicht. + if let Some(mut d) = datei.take() { + let _ = d.abschliessen(); + } + frames = 0; + frisch_gestartet = true; + match WavDatei::anlegen(&pfad, sample_rate) { + Ok(d) => datei = Some(d), + Err(e) => eprintln!("Mitschnitt: {e}"), + } + } + Befehl::Stop { frames: soll } => { + abschliessen(&mut rx, &mut datei, &mut block, soll.saturating_sub(frames)); + frames = 0; + } + Befehl::Ende => { + // Beim Beenden gibt es kein Danach — alles, was noch da + // ist, gehört in diese Datei. + abschliessen(&mut rx, &mut datei, &mut block, u64::MAX); + beenden = true; + } + } + } + + haltepunkt(id); + + // **Nur so weit leeren, wie es sich zuordnen lässt.** + // + // `stand` wurde abgelesen, bevor die Befehle drankamen. Ein Frame, der + // darin mitzählt, wurde also vorher abgelegt — und wenn er zu einem + // *neueren* Mitschnitt gehört, dann wurde dessen `Start` ebenfalls + // vorher abgeschickt und ist in dieser Runde schon abgeholt. Anders + // gesagt: Was hier gezählt wird, gehört nie zu einer Datei, die noch + // kommt. + // + // Nach einem frischen `Start` gilt der Stand nicht mehr — er zählt + // dann womöglich noch den vorigen Mitschnitt. Eine Runde warten kostet + // eine Zwanzigstelsekunde und liegt weit innerhalb des Vorrats. + let deckel = if frisch_gestartet { + 0 + } else { + stand.saturating_sub(frames) + }; + holen(&mut rx, &mut block, deckel); + if let Some(d) = datei.as_mut() { + if !block.is_empty() { + if let Err(e) = d.schreiben(&block) { + eprintln!("Mitschnitt: {e}"); + } + frames += (block.len() / CHANNELS) as u64; + } + } + + if beenden { + return; + } + if block.is_empty() { + std::thread::sleep(TAKT); + } + } +} + +/// Schreibt den Rest eines Mitschnitts und schließt die Datei. +/// +/// `rest` ist, wie viele Frames noch fehlen. Alles darüber bleibt im Ring: Es +/// gehört zum nächsten Mitschnitt. +fn abschliessen( + rx: &mut rtrb::Consumer, + datei: &mut Option, + block: &mut Vec, + rest: u64, +) { + let Some(mut d) = datei.take() else { + return; + }; + let mut offen = rest; + while offen > 0 { + block.clear(); + let grenze = offen.saturating_mul(CHANNELS as u64).min(8_192) as usize; + while block.len() < grenze { + match rx.pop() { + Ok(sample) => block.push(sample), + Err(_) => break, + } + } + if block.is_empty() { + break; + } + if let Err(e) = d.schreiben(block) { + eprintln!("Mitschnitt: {e}"); + } + offen = offen.saturating_sub((block.len() / CHANNELS) as u64); + } + if let Err(e) = d.abschliessen() { + eprintln!("Mitschnitt: {e}"); + } +} + +/// Eine WAV-Datei, die wächst. +/// +/// Der Kopf einer WAV-Datei enthält die Länge, die man beim Anlegen noch nicht +/// kennt. Also erst Platzhalter schreiben und am Ende zurückspringen — der +/// übliche Weg, und der Grund, warum eine abgebrochene Aufnahme einen falschen +/// Kopf hat und von manchen Programmen nicht gelesen wird. +struct WavDatei { + schreiber: BufWriter, + daten_bytes: u32, +} + +const KOPF_BYTES: u32 = 44; + +impl WavDatei { + fn anlegen(pfad: &Path, sample_rate: u32) -> std::io::Result { + let mut schreiber = BufWriter::new(File::create(pfad)?); + let kanaele = CHANNELS as u16; + let bits = 16u16; + let block = kanaele * bits / 8; + let byte_rate = sample_rate * block as u32; + + schreiber.write_all(b"RIFF")?; + schreiber.write_all(&0u32.to_le_bytes())?; // später + schreiber.write_all(b"WAVE")?; + schreiber.write_all(b"fmt ")?; + schreiber.write_all(&16u32.to_le_bytes())?; + schreiber.write_all(&1u16.to_le_bytes())?; // PCM + schreiber.write_all(&kanaele.to_le_bytes())?; + schreiber.write_all(&sample_rate.to_le_bytes())?; + schreiber.write_all(&byte_rate.to_le_bytes())?; + schreiber.write_all(&block.to_le_bytes())?; + schreiber.write_all(&bits.to_le_bytes())?; + schreiber.write_all(b"data")?; + schreiber.write_all(&0u32.to_le_bytes())?; // später + + Ok(WavDatei { + schreiber, + daten_bytes: 0, + }) + } + + fn schreiben(&mut self, samples: &[f32]) -> std::io::Result<()> { + for sample in samples { + // Runden statt abschneiden: Abschneiden verschiebt jedes Sample in + // dieselbe Richtung und klingt als leise Verzerrung mit. + let wert = (sample.clamp(-1.0, 1.0) * i16::MAX as f32).round() as i16; + self.schreiber.write_all(&wert.to_le_bytes())?; + } + self.daten_bytes += (samples.len() * 2) as u32; + Ok(()) + } + + fn abschliessen(&mut self) -> std::io::Result<()> { + self.schreiber.flush()?; + let datei = self.schreiber.get_mut(); + + datei.seek(SeekFrom::Start(4))?; + datei.write_all(&(KOPF_BYTES - 8 + self.daten_bytes).to_le_bytes())?; + datei.seek(SeekFrom::Start(40))?; + datei.write_all(&self.daten_bytes.to_le_bytes())?; + datei.flush() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const RATE: u32 = 48_000; + + fn scratch(name: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!("musik-aufnahme-{}-{name}.wav", std::process::id())); + p + } + + fn warte_bis(bedingung: impl Fn() -> bool) -> bool { + for _ in 0..200 { + if bedingung() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + false + } + + #[test] + fn ein_mitschnitt_landet_als_lesbare_wav_datei() { + let pfad = scratch("grund"); + let (mut tap, mut aufnahme) = mitschnitt(RATE); + + aufnahme.starten(&pfad).expect("Start"); + assert!(aufnahme.laeuft()); + + // Eine halbe Sekunde Vollausschlag, damit man das Ergebnis messen kann. + let block: Vec = (0..RATE as usize / 2) + .flat_map(|_| [0.5f32, -0.5]) + .collect(); + tap.nimm_auf(&block); + + assert!(warte_bis(|| aufnahme.frames() > 0), "es kommt nichts an"); + aufnahme.stoppen(); + assert!(!aufnahme.laeuft()); + + assert!( + warte_bis(|| std::fs::metadata(&pfad) + .map(|m| m.len() > KOPF_BYTES as u64) + .unwrap_or(false)), + "die Datei bleibt leer" + ); + std::thread::sleep(std::time::Duration::from_millis(100)); + + let daten = std::fs::read(&pfad).expect("lesen"); + assert_eq!(&daten[0..4], b"RIFF"); + assert_eq!(&daten[8..12], b"WAVE"); + + // Der Kopf muss die wirkliche Länge nennen, sonst lesen manche + // Programme die Datei gar nicht. + let daten_bytes = u32::from_le_bytes(daten[40..44].try_into().unwrap()) as usize; + assert_eq!( + daten_bytes, + daten.len() - KOPF_BYTES as usize, + "die Länge im Kopf passt nicht zur Datei" + ); + assert!(daten_bytes > 0); + + // Und der Inhalt ist das, was hineinging: ±0.5 als 16 Bit. + let erstes = i16::from_le_bytes(daten[44..46].try_into().unwrap()); + assert!( + (erstes as f32 / i16::MAX as f32 - 0.5).abs() < 0.001, + "erstes Sample ist {erstes}" + ); + + let _ = std::fs::remove_file(&pfad); + } + + /// Der Fall, der auf einem ausgelasteten Läufer schiefging. + /// + /// Ohne Pause zwischen Start, Aufnehmen und Stopp bekommt der Schreiber + /// alle drei auf einmal. Leerte er dabei den Ring — was er tat, um Reste + /// loszuwerden —, landete kein einziges Sample in der Datei, und heraus + /// kam ein WAV mit nichts als einem Kopf. + #[test] + fn ein_sofortiger_stopp_verliert_den_anfang_nicht() { + let pfad = scratch("sofort"); + let (mut tap, mut aufnahme) = mitschnitt(RATE); + + aufnahme.starten(&pfad).expect("Start"); + let block: Vec = (0..1_000).flat_map(|_| [0.5f32, -0.5]).collect(); + tap.nimm_auf(&block); + aufnahme.stoppen(); + + let erwartet = KOPF_BYTES as u64 + block.len() as u64 * 2; + assert!( + warte_bis(|| std::fs::metadata(&pfad) + .map(|m| m.len() == erwartet) + .unwrap_or(false)), + "die Datei hat {:?} statt {erwartet} Bytes", + std::fs::metadata(&pfad).map(|m| m.len()) + ); + + let _ = std::fs::remove_file(&pfad); + } + + #[test] + fn ein_zweiter_mitschnitt_erbt_nichts_vom_ersten() { + let erster = scratch("erbe1"); + let zweiter = scratch("erbe2"); + let (mut tap, mut aufnahme) = mitschnitt(RATE); + + aufnahme.starten(&erster).unwrap(); + tap.nimm_auf(&vec![0.5; 2_000]); + aufnahme.stoppen(); + assert!(warte_bis(|| std::fs::metadata(&erster) + .map(|m| m.len() > KOPF_BYTES as u64) + .unwrap_or(false))); + + // Zwischen den Aufnahmen schreibt niemand — was jetzt käme, wäre ein + // Rest, und der gehört nicht in den zweiten Mitschnitt. + let block: Vec = (0..500).flat_map(|_| [0.25f32, -0.25]).collect(); + aufnahme.starten(&zweiter).unwrap(); + tap.nimm_auf(&block); + aufnahme.stoppen(); + + let erwartet = KOPF_BYTES as u64 + block.len() as u64 * 2; + assert!( + warte_bis(|| std::fs::metadata(&zweiter) + .map(|m| m.len() == erwartet) + .unwrap_or(false)), + "der zweite hat {:?} statt {erwartet} Bytes", + std::fs::metadata(&zweiter).map(|m| m.len()) + ); + + let _ = std::fs::remove_file(&erster); + let _ = std::fs::remove_file(&zweiter); + } + + /// **Zwanzig Mitschnitte hintereinander, ohne einmal Luft zu holen.** + /// + /// Zwanzig `Start`/`Stop`-Paare liegen dann auf einen Schlag im Briefkasten + /// und alle Frames zusammen im Ring; der Schreiber muss sie allein aus den + /// Zahlen im `Stop` auseinandersortieren. Verschiedene Längen sorgen dafür, + /// dass ein Verrutschen um einen einzigen Mitschnitt auffällt. + /// + /// **Den Fehler aus der CI trifft dieser Test nicht** — vierzig Läufe + /// blieben grün, auch mit dem Fehler drin. Dafür ist die Lücke zu schmal; + /// das leistet erst `der_ring_wird_nur_bis_zur_grenze_geleert` mit seinem + /// Haltepunkt. Hier bleibt eine billige Grundprüfung. + #[test] + fn viele_mitschnitte_hintereinander_verrutschen_nicht() { + const RUNDEN: usize = 20; + let (mut tap, mut aufnahme) = mitschnitt(RATE); + + // Verschiedene Längen: Rutscht etwas, passt keine einzige Größe mehr. + let laengen: Vec = (0..RUNDEN).map(|r| 200 + r * 13).collect(); + let dateien: Vec = (0..RUNDEN).map(|r| scratch(&format!("hetze{r}"))).collect(); + + for (r, pfad) in dateien.iter().enumerate() { + let block: Vec = (0..laengen[r]).flat_map(|_| [0.5f32, -0.5]).collect(); + aufnahme.starten(pfad).unwrap(); + tap.nimm_auf(&block); + aufnahme.stoppen(); + } + + for (r, pfad) in dateien.iter().enumerate() { + let soll = KOPF_BYTES as u64 + laengen[r] as u64 * CHANNELS as u64 * 2; + assert!( + warte_bis(|| std::fs::metadata(pfad) + .map(|m| m.len() == soll) + .unwrap_or(false)), + "Runde {r} hat {:?} statt {soll} Bytes", + std::fs::metadata(pfad).map(|m| m.len()) + ); + } + + for pfad in &dateien { + let _ = std::fs::remove_file(pfad); + } + } + + /// **Der Ring wird nur so weit geleert, wie es sich zuordnen lässt.** + /// + /// Der Fehler dahinter ist einmal in der CI aufgetaucht und danach nie + /// wieder: Die erste Datei hatte 5244 statt 4044 Bytes — also 1300 Frames + /// statt 1000. Genau 1000 + 300, die beiden Mitschnitte in einer Datei. + /// + /// Der Weg dorthin ließ sich lesen, aber nicht treffen. Der Schreiber holt + /// erst die Befehle ab und leert dann den Ring; wer in dieser Lücke stoppt, + /// neu startet und nachlegt, dessen zweiter Mitschnitt liegt schon im Ring, + /// während der `Stop` noch im Briefkasten steckt. Vierzig Läufe mit zwanzig + /// Runden blieben grün — die Lücke ist ein paar Maschinenbefehle breit. + /// + /// Also wird der Schreiber angehalten. Zweimal: einmal, damit er den + /// `Start` überhaupt verarbeitet und die erste Datei offen ist, und dann + /// noch einmal in genau der Lücke. Das zweite Parken kann erst in einer + /// Runde passieren, die den `Start` schon abgeholt hat — die Runde davor + /// hat ihren Haltepunkt bereits hinter sich. + /// + /// Dieser Test ist der einzige, der den [`haltepunkt`] benutzt, und der + /// Grund, dass es ihn gibt. + #[test] + fn der_ring_wird_nur_bis_zur_grenze_geleert() { + let erster = scratch("grenze1"); + let zweiter = scratch("grenze2"); + let (mut tap, mut aufnahme, nummer) = mitschnitt_intern(RATE); + + let eins: Vec = (0..1_000).flat_map(|_| [0.5f32, -0.5]).collect(); + let zwei: Vec = (0..300).flat_map(|_| [0.25f32, -0.25]).collect(); + + let parken = |nummer: u64| { + ANGEHALTEN.store(nummer, Ordering::Release); + assert!( + warte_bis(|| PARKT.load(Ordering::Acquire) == nummer), + "der Schreiber ist nicht stehen geblieben" + ); + }; + // Losfahren lassen und **abwarten, bis er wirklich weg ist**. Ohne das + // Abwarten wurde beim zweiten Mal gar nicht neu geparkt: Der Schreiber + // sah die Null nie, blieb stehen, wo er stand, und der Test hielt das + // alte Parken für das neue. Er prüfte dann etwas anderes als gedacht. + let weiterfahren = |nummer: u64| { + ANGEHALTEN.store(0, Ordering::Release); + assert!( + warte_bis(|| PARKT.load(Ordering::Acquire) != nummer), + "der Schreiber ist nicht weitergefahren" + ); + }; + + // Erstes Parken: den `Start` losschicken, während er steht. Er holt ihn + // in einer der nächsten Runden ab und legt die Datei an. + parken(nummer); + aufnahme.starten(&erster).unwrap(); + weiterfahren(nummer); + + // Zweites Parken: jetzt ist die erste Datei offen, und der Ring ist + // leer. Genau hier saß der Fehler. + parken(nummer); + tap.nimm_auf(&eins); + aufnahme.stoppen(); + aufnahme.starten(&zweiter).unwrap(); + tap.nimm_auf(&zwei); + weiterfahren(nummer); + + let soll_eins = KOPF_BYTES as u64 + eins.len() as u64 * 2; + let soll_zwei = KOPF_BYTES as u64 + zwei.len() as u64 * 2; + assert!( + warte_bis(|| std::fs::metadata(&erster) + .map(|m| m.len() == soll_eins) + .unwrap_or(false)), + "der erste hat {:?} statt {soll_eins} Bytes — hat er den zweiten mitgenommen?", + std::fs::metadata(&erster).map(|m| m.len()) + ); + aufnahme.stoppen(); + assert!( + warte_bis(|| std::fs::metadata(&zweiter) + .map(|m| m.len() == soll_zwei) + .unwrap_or(false)), + "der zweite hat {:?} statt {soll_zwei} Bytes", + std::fs::metadata(&zweiter).map(|m| m.len()) + ); + + let _ = std::fs::remove_file(&erster); + let _ = std::fs::remove_file(&zweiter); + } + + /// **Stopp und Start ohne Pause dazwischen.** + /// + /// Zwischen zwei Stücken tut das jeder: `record_stop`, dann sofort + /// `record naechstes.wav`. Der Schreiber holt sich seine Befehle in + /// Häppchen ab, und wenn beide im selben Häppchen liegen, muss er sie + /// trotzdem der Reihe nach ausführen — sonst landet der erste Mitschnitt + /// in der zweiten Datei und der zweite nirgends. + /// + /// Der Test wartet ausdrücklich **nicht** zwischen den Aufnahmen. Genau + /// dieses Warten hat den Fehler bisher versteckt: Auf einem freien Rechner + /// kommt der Schreiber zwischendurch dran, auf einem ausgelasteten nicht. + /// Aufgefallen ist er auch nicht hier, sondern in der CI. + #[test] + fn stopp_und_start_ohne_pause_landen_in_zwei_dateien() { + let erster = scratch("hektik1"); + let zweiter = scratch("hektik2"); + let (mut tap, mut aufnahme) = mitschnitt(RATE); + + let eins: Vec = (0..1_000).flat_map(|_| [0.5f32, -0.5]).collect(); + let zwei: Vec = (0..300).flat_map(|_| [0.25f32, -0.25]).collect(); + + aufnahme.starten(&erster).unwrap(); + tap.nimm_auf(&eins); + aufnahme.stoppen(); + // Kein Warten — das ist der Punkt. + aufnahme.starten(&zweiter).unwrap(); + tap.nimm_auf(&zwei); + aufnahme.stoppen(); + + let soll_eins = KOPF_BYTES as u64 + eins.len() as u64 * 2; + let soll_zwei = KOPF_BYTES as u64 + zwei.len() as u64 * 2; + assert!( + warte_bis(|| std::fs::metadata(&erster) + .map(|m| m.len() == soll_eins) + .unwrap_or(false)), + "der erste hat {:?} statt {soll_eins} Bytes", + std::fs::metadata(&erster).map(|m| m.len()) + ); + assert!( + warte_bis(|| std::fs::metadata(&zweiter) + .map(|m| m.len() == soll_zwei) + .unwrap_or(false)), + "der zweite hat {:?} statt {soll_zwei} Bytes", + std::fs::metadata(&zweiter).map(|m| m.len()) + ); + + let _ = std::fs::remove_file(&erster); + let _ = std::fs::remove_file(&zweiter); + } + + #[test] + fn ohne_laufende_aufnahme_wird_nichts_geschrieben() { + let (mut tap, aufnahme) = mitschnitt(RATE); + tap.nimm_auf(&vec![0.5; 4_096]); + assert_eq!(aufnahme.frames(), 0); + assert_eq!(aufnahme.verworfen(), 0); + } + + #[test] + fn ein_zweiter_start_wird_abgewiesen_statt_den_ersten_zu_verlieren() { + let pfad = scratch("doppelt"); + let (_tap, mut aufnahme) = mitschnitt(RATE); + + aufnahme.starten(&pfad).unwrap(); + let zweiter = aufnahme.starten(&scratch("doppelt2")); + assert!(zweiter.is_err(), "der zweite Start überschreibt den ersten"); + + aufnahme.stoppen(); + let _ = std::fs::remove_file(&pfad); + } + + #[test] + fn ein_unschreibbarer_pfad_faellt_sofort_auf() { + let (_tap, mut aufnahme) = mitschnitt(RATE); + let fehler = aufnahme.starten(Path::new("/gibt/es/nicht/und/geht/nicht.wav")); + assert!(fehler.is_err(), "{fehler:?}"); + assert!(!aufnahme.laeuft(), "eine gescheiterte Aufnahme läuft nicht"); + assert!( + !Path::new("/gibt").exists(), + "ein Tippfehler hat einen Verzeichnisbaum hinterlassen" + ); + } + + #[test] + fn ein_fehlender_ordner_wird_angelegt_aber_nur_einer() { + let mut ordner = std::env::temp_dir(); + ordner.push(format!("musik-neu-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&ordner); + + let (_tap, mut aufnahme) = mitschnitt(RATE); + assert!(aufnahme.starten(&ordner.join("set.wav")).is_ok()); + aufnahme.stoppen(); + + let _ = std::fs::remove_dir_all(&ordner); + } + + #[test] + fn stoppen_ohne_aufnahme_ist_kein_fehler() { + let (_tap, mut aufnahme) = mitschnitt(RATE); + assert_eq!(aufnahme.stoppen(), None); + } + + /// Verlorene Frames werden gezählt, nicht verschwiegen. + /// + /// Ein Mitschnitt mit Lücken, der aussieht wie einer ohne, wäre das + /// schlechteste Ergebnis von allen. + #[test] + fn was_nicht_mehr_hineinpasst_wird_gezaehlt() { + // Ein winziger Ring ohne Leser — so lässt sich der Überlauf + // herbeiführen, ohne auf eine überlastete Platte zu warten. + let (tx, _rx) = rtrb::RingBuffer::::new(200); + let aktiv = Arc::new(AtomicBool::new(true)); + let verworfen = Arc::new(AtomicU64::new(0)); + let geschrieben = Arc::new(AtomicU64::new(0)); + + let mut tap = Mitschnitt { + tx, + aktiv, + verworfen: Arc::clone(&verworfen), + geschrieben: Arc::clone(&geschrieben), + }; + + tap.nimm_auf(&vec![0.25; 1_000]); + + assert_eq!(geschrieben.load(Ordering::Relaxed), 100); + assert_eq!(verworfen.load(Ordering::Relaxed), 400); + } +} diff --git a/crates/audio-engine/src/aux.rs b/crates/audio-engine/src/aux.rs new file mode 100644 index 0000000..912d20b --- /dev/null +++ b/crates/audio-engine/src/aux.rs @@ -0,0 +1,163 @@ +//! AUX-Eingang: externes Audio in den Mixer. +//! +//! Mikrofon, Drum-Machine, ein zweiter Rechner, das Handy eines Gastes — alles, +//! was nicht aus einem Deck kommt, läuft hier herein. Im Mixer ist AUX ein +//! Kanal wie jeder andere: mit Trim, EQ, Filter, Fader und Cue. +//! +//! ## Warum ein Ringpuffer +//! +//! Die Aufnahme läuft in ihrem eigenen Callback, die Wiedergabe in einem +//! anderen. Zwei Callbacks heißt zwei Uhren, und Uhren laufen nie exakt gleich. +//! Ein lock-freier Ringpuffer entkoppelt beide, sodass keiner auf den anderen +//! wartet — im Audio-Pfad ist Warten dasselbe wie ein Aussetzer. +//! +//! Bleibt die Aufnahme zurück, wird Stille ausgegeben und ein Unterlauf +//! gezählt. Das ist eine Diagnose, kein Zufall: Häufige Unterläufe bedeuten, +//! dass der Puffer zu klein ist oder die beiden Uhren zu weit auseinander +//! driften. + +use rtrb::{Consumer, Producer, RingBuffer}; + +use crate::source::Source; + +/// Schreibende Seite — gehört dem Aufnahme-Callback. +pub struct AuxWriter { + producer: Producer, + overruns: u64, +} + +impl AuxWriter { + /// Schiebt interleaved Stereo hinein. Gibt zurück, wie viele Werte Platz + /// fanden; der Rest geht verloren und zählt als Überlauf. + pub fn write(&mut self, samples: &[f32]) -> usize { + let mut written = 0; + for sample in samples { + if self.producer.push(*sample).is_err() { + self.overruns += samples.len() as u64 - written as u64; + break; + } + written += 1; + } + written + } + + /// Wie viele Werte verworfen wurden, weil der Puffer voll war. + pub fn overruns(&self) -> u64 { + self.overruns + } +} + +/// Lesende Seite — hängt als Quelle im Mixer. +pub struct AuxSource { + consumer: Consumer, + underruns: u64, +} + +impl AuxSource { + /// Wie viele Werte als Stille geliefert wurden, weil nichts anlag. + pub fn underruns(&self) -> u64 { + self.underruns + } + + /// Wie viele Werte gerade bereitliegen. + pub fn available(&self) -> usize { + self.consumer.slots() + } +} + +impl Source for AuxSource { + fn render(&mut self, out: &mut [f32]) { + for (i, sample) in out.iter_mut().enumerate() { + match self.consumer.pop() { + Ok(value) => *sample = value, + Err(_) => { + self.underruns += (out.len() - i) as u64; + out[i..].fill(0.0); + return; + } + } + } + } +} + +/// Legt einen AUX-Kanal an. `capacity_frames` ist die Puffertiefe in +/// Stereo-Frames — größer heißt robuster gegen Uhrendrift, aber auch mehr +/// Latenz zwischen Eingang und Ausgang. +pub fn aux_channel(capacity_frames: usize) -> (AuxWriter, AuxSource) { + let (producer, consumer) = RingBuffer::new(capacity_frames.max(1) * 2); + ( + AuxWriter { + producer, + overruns: 0, + }, + AuxSource { + consumer, + underruns: 0, + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn geschriebenes_kommt_unveraendert_an() { + let (mut writer, mut source) = aux_channel(1_024); + + let eingang: Vec = (0..64).map(|i| i as f32 * 0.01).collect(); + assert_eq!(writer.write(&eingang), eingang.len()); + + let mut out = vec![0.0; 64]; + source.render(&mut out); + + assert_eq!(out, eingang); + assert_eq!(source.underruns(), 0); + } + + #[test] + fn unterlauf_liefert_stille_und_wird_gezaehlt() { + let (mut writer, mut source) = aux_channel(1_024); + writer.write(&[0.5; 8]); + + let mut out = vec![9.0; 32]; + source.render(&mut out); + + assert_eq!(&out[..8], &[0.5; 8]); + assert!( + out[8..].iter().all(|v| *v == 0.0), + "nach dem Unterlauf steht kein Nullpegel" + ); + assert_eq!(source.underruns(), 24); + } + + #[test] + fn ohne_zuspieler_ist_der_kanal_still() { + let (_writer, mut source) = aux_channel(64); + let mut out = vec![1.0; 16]; + source.render(&mut out); + + assert!(out.iter().all(|v| *v == 0.0)); + assert_eq!(source.underruns(), 16); + } + + #[test] + fn ueberlauf_wird_gezaehlt_statt_zu_blockieren() { + let (mut writer, _source) = aux_channel(8); + let geschrieben = writer.write(&[1.0; 64]); + + assert!( + geschrieben < 64, + "der Puffer hat mehr geschluckt als er fasst" + ); + assert!(writer.overruns() > 0, "Überlauf wurde nicht bemerkt"); + } + + #[test] + fn kapazitaet_null_paniert_nicht() { + let (mut writer, mut source) = aux_channel(0); + writer.write(&[1.0; 4]); + let mut out = vec![0.0; 4]; + source.render(&mut out); + } +} diff --git a/crates/audio-engine/src/channel.rs b/crates/audio-engine/src/channel.rs new file mode 100644 index 0000000..09d2105 --- /dev/null +++ b/crates/audio-engine/src/channel.rs @@ -0,0 +1,277 @@ +//! Ein Kanalzug. +//! +//! Reihenfolge der Kette: +//! +//! ```text +//! Quelle → Trim → EQ → Filter →┬→ Cue-Abgriff (pre-fader) +//! └→ Fader → FX → Crossfader → Summe +//! ``` +//! +//! **Der Cue-Abgriff liegt vor dem Fader, aber hinter EQ und Filter.** Beides +//! hat einen Grund und beides ist so auf jedem DJ-Mixer verdrahtet: +//! +//! - *Vor dem Fader*, weil man einen Track im Kopfhörer vorbereitet, während +//! sein Fader unten ist. Läge der Abgriff dahinter, hörte man genau dann +//! nichts, wenn man ihn braucht. +//! - *Hinter EQ und Filter*, weil man seine Klangeingriffe kontrollieren +//! können muss, bevor sie auf die Anlage gehen. +//! +//! **Die Effekte liegen hinter dem Fader.** Zieht man ihn zu, während ein +//! Delay klingt, soll die Fahne ausklingen statt abzureißen — genau dafür +//! sitzen Mixer-FX auf jedem Gerät an dieser Stelle. Im Kopfhörer hört man sie +//! deshalb nicht: Der Cue-Abgriff liegt davor. + +use crate::crossfader::Assign; +use crate::effects::{Effekt, FxUnit}; +use crate::eq::ThreeBandEq; +use crate::filter::DjFilter; +use crate::source::Source; + +pub struct Channel { + pub name: String, + source: Box, + eq: ThreeBandEq, + filter: DjFilter, + fx: FxUnit, + trim: f32, + fader: f32, + cue: bool, + assign: Assign, + buffer: Vec, +} + +impl Channel { + pub fn new(name: impl Into, source: Box, sample_rate: f32) -> Self { + Channel { + name: name.into(), + source, + eq: ThreeBandEq::new(sample_rate), + filter: DjFilter::new(sample_rate), + fx: FxUnit::new(sample_rate), + trim: 1.0, + fader: 0.0, + cue: false, + assign: Assign::Thru, + buffer: Vec::new(), + } + } + + pub fn set_source(&mut self, source: Box) -> Box { + self.eq.reset(); + self.filter.reset(); + // Der neue Track soll nicht die Hallfahne des alten erben. + self.fx.reset(); + std::mem::replace(&mut self.source, source) + } + + pub fn fx(&mut self) -> &mut FxUnit { + &mut self.fx + } + + pub fn fx_effekt(&self) -> Effekt { + self.fx.effekt() + } + + /// Ob der Kanal noch etwas zu sagen hat, obwohl er stumm ist. + /// + /// Der Mixer überspringt stumme Kanäle. Ohne diese Frage risse er jedem + /// Delay die Fahne ab, sobald der Fader unten ist — und damit genau das + /// weg, wofür die Effekte hinter dem Fader sitzen. + pub fn klingt_nach(&self) -> bool { + self.fx.klingt_nach() + } + + /// Eingangsverstärkung vor dem EQ. + pub fn set_trim(&mut self, trim: f32) { + self.trim = trim.clamp(0.0, 4.0); + } + + pub fn trim(&self) -> f32 { + self.trim + } + + /// Kanalfader, 0.0 bis 1.0. + pub fn set_fader(&mut self, fader: f32) { + self.fader = fader.clamp(0.0, 1.0); + } + + pub fn fader(&self) -> f32 { + self.fader + } + + pub fn set_eq(&mut self, low: f32, mid: f32, high: f32) { + self.eq.set_gains(low, mid, high); + } + + pub fn eq_gains(&self) -> (f32, f32, f32) { + self.eq.gains() + } + + pub fn set_filter(&mut self, position: f32) { + self.filter.set_position(position); + } + + pub fn filter_position(&self) -> f32 { + self.filter.position() + } + + /// Legt den Kanal auf den Kopfhörer. + pub fn set_cue(&mut self, cue: bool) { + self.cue = cue; + } + + pub fn is_cued(&self) -> bool { + self.cue + } + + pub fn set_assign(&mut self, assign: Assign) { + self.assign = assign; + } + + pub fn assign(&self) -> Assign { + self.assign + } + + /// Rendert die Kette bis einschließlich Filter und gibt das Ergebnis + /// zurück — also genau das Signal, das der Cue-Bus abgreift. + /// + /// Fader und Crossfader liegen bewusst *nicht* darin; die legt der Mixer + /// beim Summieren an. + /// Zieht die Quelle weiter, ohne etwas zu rechnen. + /// + /// Für Kanäle, die gerade nicht zu hören sind. **Ein Deck muss auch dann + /// laufen, wenn sein Fader unten ist** — sonst steht der Track, den man + /// zum Einsatz bringen will, und zwei gesyncte Decks liefen auseinander, + /// sobald eines weggeblendet ist. Das ist der ganze Sinn von Beatmatching. + /// + /// Gespart wird trotzdem: EQ und Filter sind das Teure, und die bleiben + /// aus. Ihr Zustand altert dabei, was beim Aufziehen einen kurzen + /// Einschwinger kostet. + pub fn quelle_weiterziehen(&mut self, frames: usize) { + let len = frames * 2; + if self.buffer.len() < len { + self.buffer.resize(len, 0.0); + } + self.source.render(&mut self.buffer[..len]); + } + + pub fn render_pre_fader(&mut self, frames: usize) -> &[f32] { + let len = frames * 2; + if self.buffer.len() < len { + self.buffer.resize(len, 0.0); + } + let buffer = &mut self.buffer[..len]; + + self.source.render(buffer); + + if (self.trim - 1.0).abs() > f32::EPSILON { + for sample in buffer.iter_mut() { + *sample *= self.trim; + } + } + + self.eq.process(buffer); + self.filter.process(buffer); + + &self.buffer[..len] + } + + /// Legt Fader und Effekte an — in dieser Reihenfolge. + /// + /// Getrennt von [`Channel::render_pre_fader`], weil der Cue-Bus das + /// Signal davor abgreift und der Mixer es sonst zweimal puffern müsste. + pub fn process_post_fader(&mut self, buffer: &mut [f32]) { + if (self.fader - 1.0).abs() > f32::EPSILON { + for sample in buffer.iter_mut() { + *sample *= self.fader; + } + } + self.fx.process(buffer); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::LoopSource; + use crate::testing::{rms, sine_stereo}; + + const RATE: f32 = 48_000.0; + + fn kanal_mit_ton(freq: f32) -> Channel { + let source = LoopSource::new(sine_stereo(freq, RATE as u32, 1.0)); + Channel::new("test", Box::new(source), RATE) + } + + #[test] + fn fader_startet_unten() { + // Ein Kanal, der beim Anlegen auf die Anlage geht, ist ein Unfall. + let kanal = kanal_mit_ton(440.0); + assert_eq!(kanal.fader(), 0.0); + assert!(!kanal.is_cued()); + assert_eq!(kanal.assign(), Assign::Thru); + } + + #[test] + fn trim_wirkt_vor_dem_eq() { + let mut kanal = kanal_mit_ton(440.0); + let voll = rms(kanal.render_pre_fader(4_800)).max(1e-9); + + kanal.set_trim(0.5); + let halb = rms(kanal.render_pre_fader(4_800)); + + assert!( + (halb / voll - 0.5).abs() < 0.05, + "Trim 0.5 ergibt Faktor {:.3}", + halb / voll + ); + } + + #[test] + fn der_fader_steckt_nicht_im_pre_fader_signal() { + let mut kanal = kanal_mit_ton(440.0); + kanal.set_fader(0.0); + + let pegel = rms(kanal.render_pre_fader(4_800)); + assert!( + pegel > 0.1, + "Fader unten dämpft schon das Cue-Signal: {pegel:.4}" + ); + } + + #[test] + fn eq_wirkt_auf_das_pre_fader_signal() { + let mut kanal = kanal_mit_ton(60.0); + let voll = rms(kanal.render_pre_fader(4_800)).max(1e-9); + + kanal.set_eq(0.0, 1.0, 1.0); + let gekillt = rms(kanal.render_pre_fader(4_800)); + + assert!( + gekillt / voll < 0.1, + "Bass-Kill wirkt nicht: Faktor {:.3}", + gekillt / voll + ); + } + + #[test] + fn quellenwechsel_gibt_die_alte_quelle_zurueck() { + let mut kanal = kanal_mit_ton(440.0); + let alt = kanal.set_source(Box::new(LoopSource::new(vec![0.0; 4]))); + + // Die alte Quelle darf hier fallen, nicht im Audio-Thread. + drop(alt); + assert!(rms(kanal.render_pre_fader(128)) < 1e-6); + } + + #[test] + fn werte_werden_geklemmt() { + let mut kanal = kanal_mit_ton(440.0); + kanal.set_fader(5.0); + assert_eq!(kanal.fader(), 1.0); + kanal.set_fader(-1.0); + assert_eq!(kanal.fader(), 0.0); + kanal.set_trim(-3.0); + assert_eq!(kanal.trim(), 0.0); + } +} diff --git a/crates/audio-engine/src/command.rs b/crates/audio-engine/src/command.rs new file mode 100644 index 0000000..b6f96f5 --- /dev/null +++ b/crates/audio-engine/src/command.rs @@ -0,0 +1,391 @@ +//! Kommandos von der Oberfläche zum Audio-Thread. +//! +//! Der Mixer gehört dem Audio-Callback allein. Die Oberfläche fasst ihn nicht +//! an, sondern schickt Kommandos durch eine lock-freie Schlange, die der +//! Callback zu Beginn jedes Blocks leert. +//! +//! Ein `Mutex` wäre bequemer und wäre falsch: Der Callback müsste dann +//! auf die Oberfläche warten, und Warten im Audio-Pfad heißt Aussetzer. Genau +//! deshalb steht diese Schlange in `docs/PLAN.md` als eine der drei +//! Entscheidungen, die die Architektur prägen. +//! +//! Transportbefehle (Play, Tempo, Springen) laufen **nicht** hierüber — die +//! liegen als Atomics im `DeckState` und sind von beiden Seiten direkt +//! erreichbar. Hier steht nur, was den Mixer selbst betrifft. + +use rtrb::{Consumer, Producer, RingBuffer}; + +use crate::crossfader::Assign; +use crate::mixer::Engine; +use crate::source::Source; + +/// Eine Änderung am Mixer. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Command { + Trim(usize, f32), + Fader(usize, f32), + Eq { + channel: usize, + low: f32, + mid: f32, + high: f32, + }, + Filter(usize, f32), + Cue(usize, bool), + Fx(usize, crate::effects::Effekt), + FxMix(usize, f32), + FxAmount(usize, f32), + /// Effektzeit in Sekunden. + FxTime(usize, f32), + Assign(usize, Assign), + Crossfader(f32), + CrossfaderCurve(f32), + MasterGain(f32), + CueGain(f32), + CueMix(f32), +} + +/// Die Seite der Oberfläche. +pub struct EngineHandle { + tx: Producer, + loads: Producer, + retired: Consumer>, + dropped: u64, +} + +impl EngineHandle { + /// Legt einen Track auf einen Kanal. Die alte Quelle kommt über + /// [`Self::collect_retired`] zurück und stirbt dort, nicht im Audio-Thread. + pub fn load(&mut self, channel: usize, source: Box) -> bool { + self.loads.push(LoadRequest { channel, source }).is_ok() + } + + /// Holt abgelöste Quellen ab und gibt sie frei. + /// + /// Muss regelmäßig aufgerufen werden — sonst verstopft der Rückweg, und + /// der Audio-Thread nimmt keine neuen Tracks mehr an. Einmal pro Bild der + /// Oberfläche reicht. + pub fn collect_retired(&mut self) -> usize { + let mut n = 0; + while let Ok(alt) = self.retired.pop() { + drop(alt); + n += 1; + } + n + } + /// Schickt ein Kommando. Ist die Schlange voll, geht es verloren und wird + /// gezählt — ein Reglerwert, der nicht ankommt, wird beim nächsten Zug + /// ohnehin überschrieben. Blockieren wäre hier schlimmer als verlieren. + pub fn send(&mut self, command: Command) { + if self.tx.push(command).is_err() { + self.dropped += 1; + } + } + + pub fn dropped(&self) -> u64 { + self.dropped + } +} + +/// Ein Track, der auf einen Kanal soll. +pub struct LoadRequest { + pub channel: usize, + pub source: Box, +} + +/// Die Seite des Audio-Threads. Besitzt den Mixer. +pub struct EngineRunner { + engine: Engine, + rx: Consumer, + loads: Consumer, + retire: Producer>, + /// Eine abgelöste Quelle, die noch nicht hinausgereicht werden konnte. + pending: Option>, +} + +impl EngineRunner { + /// Kommandos übernehmen und einen Block rendern. + pub fn render(&mut self, out: &mut [f32], out_channels: usize) { + self.drain(); + self.engine.render(out, out_channels); + } + + fn drain(&mut self) { + while let Ok(command) = self.rx.pop() { + self.apply(command); + } + self.drain_loads(); + } + + /// Nimmt neue Quellen entgegen und reicht die abgelösten hinaus. + /// + /// Der Kern: Ein abgelöster Track hält hundert Megabyte. Fällt er hier, + /// gibt der letzte `Arc` sie frei — ein Syscall mitten im Callback und + /// damit genau der Aussetzer, den die Architektur vermeidet. + /// + /// Deshalb wird nichts übernommen, solange der Rückweg verstopft ist. Die + /// Anfrage bleibt dann in der Schlange und kommt einen Block später dran; + /// das ist ein paar Millisekunden später und stört niemanden. + fn drain_loads(&mut self) { + if let Some(alt) = self.pending.take() { + if let Err(rtrb::PushError::Full(zurueck)) = self.retire.push(alt) { + self.pending = Some(zurueck); + return; + } + } + + while !self.retire.is_full() { + let Ok(request) = self.loads.pop() else { + return; + }; + if request.channel >= self.engine.channel_count() { + // Auch diese Quelle darf hier nicht fallen. Der Rückweg ist an + // dieser Stelle frei — die Schleifenbedingung hat es gerade + // geprüft —, also geht sie sofort zurück statt zu warten. + if let Err(rtrb::PushError::Full(zurueck)) = self.retire.push(request.source) { + self.pending = Some(zurueck); + return; + } + continue; + } + + let alt = self + .engine + .channel(request.channel) + .set_source(request.source); + if let Err(rtrb::PushError::Full(zurueck)) = self.retire.push(alt) { + self.pending = Some(zurueck); + return; + } + } + } + + fn apply(&mut self, command: Command) { + let channels = self.engine.channel_count(); + let gueltig = |index: usize| index < channels; + + match command { + Command::Trim(ch, value) if gueltig(ch) => self.engine.channel(ch).set_trim(value), + Command::Fader(ch, value) if gueltig(ch) => self.engine.channel(ch).set_fader(value), + Command::Eq { + channel, + low, + mid, + high, + } if gueltig(channel) => self.engine.channel(channel).set_eq(low, mid, high), + Command::Filter(ch, value) if gueltig(ch) => self.engine.channel(ch).set_filter(value), + Command::Cue(ch, on) if gueltig(ch) => self.engine.channel(ch).set_cue(on), + Command::Fx(ch, effekt) if gueltig(ch) => { + self.engine.channel(ch).fx().set_effekt(effekt) + } + Command::FxMix(ch, value) if gueltig(ch) => self.engine.channel(ch).fx().set_mix(value), + Command::FxAmount(ch, value) if gueltig(ch) => { + self.engine.channel(ch).fx().set_amount(value) + } + Command::FxTime(ch, value) if gueltig(ch) => { + self.engine.channel(ch).fx().set_zeit(value) + } + Command::Assign(ch, assign) if gueltig(ch) => { + self.engine.channel(ch).set_assign(assign) + } + Command::Crossfader(value) => self.engine.crossfader().set_position(value), + Command::CrossfaderCurve(value) => self.engine.crossfader().set_curve(value), + Command::MasterGain(value) => self.engine.set_master_gain(value), + Command::CueGain(value) => self.engine.set_cue_gain(value), + Command::CueMix(value) => self.engine.set_cue_mix(value), + // Kommandos für nicht vorhandene Kanäle werden verworfen statt zu + // paniken — die Oberfläche könnte einem Umbau hinterherhinken. + _ => {} + } + } + + pub fn engine(&self) -> &Engine { + &self.engine + } + + /// Ein Runner ohne Kanäle. + /// + /// Notnagel für den Fall, dass der echte Mixer in einem gescheiterten + /// Stream steckengeblieben ist. Er rendert Stille, damit der Aufrufer + /// weiterarbeiten kann statt zu paniken. + pub fn leer() -> EngineRunner { + let (_, runner) = channel(Engine::new(48_000.0), 8); + runner + } +} + +/// Verbindet Oberfläche und Audio-Thread. +pub fn channel(engine: Engine, capacity: usize) -> (EngineHandle, EngineRunner) { + let (tx, rx) = RingBuffer::new(capacity.max(8)); + // Der Rückweg ist etwas weiter als der Hinweg, damit er nie der Engpass ist. + let (load_tx, load_rx) = RingBuffer::new(8); + let (retire_tx, retire_rx) = RingBuffer::new(12); + + ( + EngineHandle { + tx, + loads: load_tx, + retired: retire_rx, + dropped: 0, + }, + EngineRunner { + engine, + rx, + loads: load_rx, + retire: retire_tx, + pending: None, + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::{LoopSource, SilentSource}; + use crate::testing::{rms_channel, sine_stereo}; + + const RATE: f32 = 48_000.0; + + fn aufbau() -> (EngineHandle, EngineRunner) { + let mut engine = Engine::new(RATE); + let samples = sine_stereo(220.0, RATE as u32, 1.0) + .into_iter() + .map(|v| v * 0.4) + .collect(); + engine.add_channel("A", Box::new(LoopSource::new(samples))); + engine.add_channel("leer", Box::new(SilentSource)); + channel(engine, 64) + } + + fn pegel(runner: &mut EngineRunner) -> f32 { + let mut out = vec![0.0; 4_800 * 4]; + runner.render(&mut out, 4); + rms_channel(&out, 0, 4) + } + + #[test] + fn kommandos_kommen_an() { + let (mut handle, mut runner) = aufbau(); + assert!(pegel(&mut runner) < 0.001, "Fader startet nicht unten"); + + handle.send(Command::Fader(0, 1.0)); + assert!(pegel(&mut runner) > 0.1, "Fader-Kommando wirkt nicht"); + } + + #[test] + fn kommandos_fuer_fehlende_kanaele_werden_verworfen() { + let (mut handle, mut runner) = aufbau(); + handle.send(Command::Fader(99, 1.0)); + handle.send(Command::Eq { + channel: 99, + low: 0.0, + mid: 0.0, + high: 0.0, + }); + + // Darf nicht paniken und darf nichts anderes verstellen. + pegel(&mut runner); + } + + #[test] + fn eine_volle_schlange_verliert_statt_zu_blockieren() { + let (mut handle, mut runner) = aufbau(); + for i in 0..10_000 { + handle.send(Command::Crossfader(i as f32 * 1e-4)); + } + + assert!(handle.dropped() > 0, "die Schlange ist nicht übergelaufen"); + pegel(&mut runner); + } + + #[test] + fn der_letzte_wert_gewinnt() { + let (mut handle, mut runner) = aufbau(); + handle.send(Command::Fader(0, 1.0)); + handle.send(Command::Fader(0, 0.0)); + + assert!( + pegel(&mut runner) < 0.001, + "die Reihenfolge der Kommandos stimmt nicht" + ); + } + + #[test] + fn ein_geladener_track_ist_zu_hoeren() { + let (mut handle, mut runner) = aufbau(); + handle.send(Command::Fader(1, 1.0)); + assert!(pegel(&mut runner) < 0.001, "der leere Kanal klingt"); + + let samples = sine_stereo(220.0, RATE as u32, 1.0) + .into_iter() + .map(|v| v * 0.4) + .collect(); + assert!(handle.load(1, Box::new(LoopSource::new(samples)))); + + assert!(pegel(&mut runner) > 0.1, "die neue Quelle kam nicht an"); + } + + #[test] + fn die_abgeloeste_quelle_kommt_zurueck_statt_im_callback_zu_sterben() { + let (mut handle, mut runner) = aufbau(); + + handle.load(0, Box::new(SilentSource)); + pegel(&mut runner); + + assert_eq!( + handle.collect_retired(), + 1, + "die alte Quelle wurde nicht herausgereicht" + ); + } + + #[test] + fn ohne_abholen_verstopft_der_rueckweg_statt_zu_verlieren() { + // Wird nie abgeholt, muss der Audio-Thread irgendwann aufhören, + // Tracks anzunehmen — und darf trotzdem nichts freigeben. + let (mut handle, mut runner) = aufbau(); + + for _ in 0..40 { + handle.load(0, Box::new(SilentSource)); + pegel(&mut runner); + } + + let zurueck = handle.collect_retired(); + assert!(zurueck > 0, "gar nichts kam zurück"); + assert!( + zurueck <= 12, + "mehr zurück als der Rückweg fasst: {zurueck}" + ); + } + + #[test] + fn laden_auf_einen_fehlenden_kanal_verliert_nichts() { + let (mut handle, mut runner) = aufbau(); + handle.load(99, Box::new(SilentSource)); + pegel(&mut runner); + + assert_eq!( + handle.collect_retired(), + 1, + "die Quelle für den falschen Kanal ist verschwunden" + ); + } + + #[test] + fn eq_kommando_traegt_alle_drei_baender() { + let (mut handle, mut runner) = aufbau(); + handle.send(Command::Fader(0, 1.0)); + let voll = pegel(&mut runner); + + handle.send(Command::Eq { + channel: 0, + low: 0.0, + mid: 0.0, + high: 0.0, + }); + let zu = pegel(&mut runner); + + assert!(voll > 0.1); + assert!(zu < voll * 0.1, "EQ-Kill kam nicht durch: {zu:.4}"); + } +} diff --git a/crates/audio-engine/src/crossfader.rs b/crates/audio-engine/src/crossfader.rs new file mode 100644 index 0000000..25c71de --- /dev/null +++ b/crates/audio-engine/src/crossfader.rs @@ -0,0 +1,157 @@ +//! Crossfader mit einstellbarer Kurve. +//! +//! Kanäle liegen auf Seite A, Seite B oder auf *Thru* — letztere sind vom +//! Crossfader unberührt. Thru ist kein Sonderfall, sondern der Normalfall für +//! alles, was nicht gemischt, sondern dazugelegt wird: ein Mikrofon, ein +//! AUX-Zuspieler, eine Drum-Machine. + +/// Wo ein Kanal am Crossfader hängt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Assign { + A, + B, + /// Unberührt vom Crossfader. + #[default] + Thru, +} + +/// Kurvenform von weich (0.0) bis schneidend (1.0). +/// +/// Weich ist eine Konstant-Leistungs-Kurve: In der Mitte liegen beide Seiten +/// bei etwa 0,707, sodass die Summe gleich laut bleibt. Schneidend hält beide +/// Seiten lange auf voll und blendet erst kurz vor dem Anschlag — das ist die +/// Einstellung zum Scratchen. +#[derive(Debug, Clone, Copy)] +pub struct Crossfader { + position: f32, + curve: f32, + gain_a: f32, + gain_b: f32, +} + +impl Default for Crossfader { + fn default() -> Self { + Self::new() + } +} + +impl Crossfader { + pub fn new() -> Self { + let mut xf = Crossfader { + position: 0.0, + curve: 0.0, + gain_a: 0.0, + gain_b: 0.0, + }; + xf.recompute(); + xf + } + + /// −1 = ganz auf A, 0 = Mitte, +1 = ganz auf B. + pub fn set_position(&mut self, position: f32) { + self.position = position.clamp(-1.0, 1.0); + self.recompute(); + } + + pub fn position(&self) -> f32 { + self.position + } + + /// 0.0 = weich (Konstant-Leistung), 1.0 = schneidend. + pub fn set_curve(&mut self, curve: f32) { + self.curve = curve.clamp(0.0, 1.0); + self.recompute(); + } + + pub fn gain(&self, assign: Assign) -> f32 { + match assign { + Assign::A => self.gain_a, + Assign::B => self.gain_b, + Assign::Thru => 1.0, + } + } + + fn recompute(&mut self) { + let t = (self.position + 1.0) * 0.5; + let angle = t * std::f32::consts::FRAC_PI_2; + + // Exponent < 1 drückt die Kurve nach oben: beide Seiten bleiben länger + // laut, der Übergang wird kürzer und härter. + let exponent = 1.0 - 0.85 * self.curve; + + self.gain_a = angle.cos().clamp(0.0, 1.0).powf(exponent); + self.gain_b = angle.sin().clamp(0.0, 1.0).powf(exponent); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anschlaege_isolieren_je_eine_seite() { + let mut xf = Crossfader::new(); + + xf.set_position(-1.0); + assert!((xf.gain(Assign::A) - 1.0).abs() < 1e-5); + assert!(xf.gain(Assign::B) < 1e-5); + + xf.set_position(1.0); + assert!(xf.gain(Assign::A) < 1e-5); + assert!((xf.gain(Assign::B) - 1.0).abs() < 1e-5); + } + + #[test] + fn mitte_haelt_die_leistung_konstant() { + let mut xf = Crossfader::new(); + xf.set_curve(0.0); + xf.set_position(0.0); + + let a = xf.gain(Assign::A); + let b = xf.gain(Assign::B); + + assert!((a - 0.707).abs() < 0.01, "A in der Mitte: {a:.3}"); + assert!((b - 0.707).abs() < 0.01, "B in der Mitte: {b:.3}"); + assert!( + (a * a + b * b - 1.0).abs() < 0.01, + "Leistungssumme weicht ab: {:.3}", + a * a + b * b + ); + } + + #[test] + fn thru_ignoriert_den_crossfader() { + let mut xf = Crossfader::new(); + for pos in [-1.0, -0.3, 0.0, 0.5, 1.0] { + xf.set_position(pos); + assert_eq!(xf.gain(Assign::Thru), 1.0, "bei Position {pos}"); + } + } + + #[test] + fn schneidende_kurve_haelt_laenger_voll() { + let mut weich = Crossfader::new(); + weich.set_curve(0.0); + weich.set_position(-0.5); + + let mut scharf = Crossfader::new(); + scharf.set_curve(1.0); + scharf.set_position(-0.5); + + assert!( + scharf.gain(Assign::A) > weich.gain(Assign::A), + "schneidend {:.3} sollte über weich {:.3} liegen", + scharf.gain(Assign::A), + weich.gain(Assign::A) + ); + } + + #[test] + fn position_wird_geklemmt() { + let mut xf = Crossfader::new(); + xf.set_position(-5.0); + assert_eq!(xf.position(), -1.0); + xf.set_position(5.0); + assert_eq!(xf.position(), 1.0); + } +} diff --git a/crates/audio-engine/src/effects.rs b/crates/audio-engine/src/effects.rs new file mode 100644 index 0000000..bec4ccc --- /dev/null +++ b/crates/audio-engine/src/effects.rs @@ -0,0 +1,608 @@ +//! Effekte für den Kanalzug. +//! +//! Vier statt vierzig. Traktor hat über 40, und die meisten benutzt niemand; +//! was ein DJ wirklich in den Fingern hat, ist ein tempo-synchrones Delay, ein +//! Gate, ein Flanger und etwas Schmutz. Lieber vier, die stimmen, als vierzig, +//! die man einmal ausprobiert. +//! +//! **Die Effekte liegen post-fader.** Das ist keine Kleinigkeit: Zieht man den +//! Fader zu, während ein Delay noch klingt, soll die Fahne ausklingen und +//! nicht abreißen. Genau dafür haben Mixer ihre Mixer-FX hinter dem Fader +//! sitzen, und genau deshalb muss der Mixer einen Kanal mit klingendem Effekt +//! weiterrechnen, auch wenn er stumm ist — siehe [`FxUnit::klingt_nach`]. +//! +//! Echtzeit: Alle Puffer werden beim Anlegen belegt, nie im Callback. Die +//! Länge des Delays ist deshalb nach oben begrenzt; [`MAX_DELAY_SEKUNDEN`] +//! sagt, wie weit. + +/// Längste einstellbare Verzögerung. Bei 60 BPM ist ein ganzer Takt vier +/// Sekunden — mehr als das braucht im Kanalzug niemand, und der Puffer kostet +/// Speicher, der beim Anlegen belegt sein muss. +pub const MAX_DELAY_SEKUNDEN: f32 = 4.0; + +/// Wie lange das Gate zum Öffnen und Schließen braucht. +/// +/// Ohne diese Rampe schaltet es hart von 1 auf 0 und knackt bei jedem +/// Durchgang — ein Sprung im Signal ist ein Klick, egal wie sauber der Rest +/// ist. +const GATE_RAMPE_MS: f32 = 1.5; + +/// Ab welchem Restpegel ein Effekt als ausgeklungen gilt. +const STILLE: f32 = 1e-5; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Effekt { + Aus, + /// Tempo-synchrones Echo mit Rückkopplung. + Delay, + /// Rhythmisches Tor. + Gater, + /// Kammfilter mit wanderndem Zahn. + Flanger, + /// Bit- und Ratenreduktion. + Crush, +} + +impl Effekt { + pub fn name(&self) -> &'static str { + match self { + Effekt::Aus => "off", + Effekt::Delay => "delay", + Effekt::Gater => "gater", + Effekt::Flanger => "flanger", + Effekt::Crush => "crush", + } + } + + pub fn aus_name(name: &str) -> Option { + Some(match name { + "off" | "aus" => Effekt::Aus, + "delay" => Effekt::Delay, + "gater" => Effekt::Gater, + "flanger" => Effekt::Flanger, + "crush" => Effekt::Crush, + _ => return None, + }) + } + + /// Alle Namen, für den Katalog. + pub const NAMEN: &'static [&'static str] = &["off", "delay", "gater", "flanger", "crush"]; +} + +/// Eine Verzögerungsleitung fester Länge. +struct Leitung { + puffer: Vec, + schreib: usize, +} + +impl Leitung { + fn neu(frames: usize) -> Leitung { + Leitung { + // Stereo, verschränkt. + puffer: vec![0.0; frames.max(1) * 2], + schreib: 0, + } + } + + fn leeren(&mut self) { + self.puffer.fill(0.0); + self.schreib = 0; + } + + fn frames(&self) -> usize { + self.puffer.len() / 2 + } + + /// Liest mit gebrochener Verzögerung, linear zwischen zwei Frames. + /// + /// Ohne die Zwischenwerte hörte man beim Flanger jede Stufe der + /// wandernden Verzögerung als Zwitschern. + fn lies(&self, verzoegerung: f32, kanal: usize) -> f32 { + let frames = self.frames(); + let d = verzoegerung.clamp(1.0, frames as f32 - 2.0); + let ganz = d.floor(); + let bruch = d - ganz; + + let a = (self.schreib + frames - ganz as usize) % frames; + let b = (a + frames - 1) % frames; + self.puffer[a * 2 + kanal] * (1.0 - bruch) + self.puffer[b * 2 + kanal] * bruch + } + + fn schreibe(&mut self, links: f32, rechts: f32) { + self.puffer[self.schreib * 2] = links; + self.puffer[self.schreib * 2 + 1] = rechts; + self.schreib = (self.schreib + 1) % self.frames(); + } +} + +/// Ein Effektgerät im Kanalzug. +pub struct FxUnit { + sample_rate: f32, + effekt: Effekt, + /// Trocken/nass, 0 bis 1. + mix: f32, + /// Bedeutung hängt am Effekt: Rückkopplung, Öffnungsdauer, Tiefe, Härte. + amount: f32, + /// Zeit in Sekunden: Delay-Länge, Gate-Periode, Flanger-Umlauf. + zeit: f32, + + delay: Leitung, + flanger: Leitung, + /// Phase des Gaters und des Flanger-LFO, 0 bis 1. + phase: f32, + /// Zuletzt ausgegebener Gate-Faktor, damit die Rampe stetig bleibt. + gate: f32, + /// Sample-and-Hold für den Crusher. + halt: [f32; 2], + halt_zaehler: f32, + /// Grobes Maß dafür, wie laut der Effekt zuletzt noch war. + rest: f32, +} + +impl FxUnit { + pub fn new(sample_rate: f32) -> FxUnit { + let max = (sample_rate * MAX_DELAY_SEKUNDEN) as usize; + FxUnit { + sample_rate, + effekt: Effekt::Aus, + mix: 0.0, + amount: 0.5, + zeit: 0.5, + delay: Leitung::neu(max), + // Der Flanger braucht Millisekunden, keine Sekunden. + flanger: Leitung::neu((sample_rate * 0.03) as usize), + phase: 0.0, + gate: 1.0, + halt: [0.0; 2], + halt_zaehler: 0.0, + rest: 0.0, + } + } + + pub fn set_effekt(&mut self, effekt: Effekt) { + if effekt == self.effekt { + return; + } + self.effekt = effekt; + // Ein Wechsel schleppt den alten Klang sonst mit hinüber. + self.reset(); + } + + pub fn effekt(&self) -> Effekt { + self.effekt + } + + pub fn set_mix(&mut self, mix: f32) { + self.mix = mix.clamp(0.0, 1.0); + } + + pub fn mix(&self) -> f32 { + self.mix + } + + pub fn set_amount(&mut self, amount: f32) { + self.amount = amount.clamp(0.0, 1.0); + } + + pub fn amount(&self) -> f32 { + self.amount + } + + /// Zeit in Sekunden. + pub fn set_zeit(&mut self, sekunden: f32) { + self.zeit = sekunden.clamp(0.001, MAX_DELAY_SEKUNDEN); + } + + pub fn zeit(&self) -> f32 { + self.zeit + } + + pub fn reset(&mut self) { + self.delay.leeren(); + self.flanger.leeren(); + self.phase = 0.0; + self.gate = 1.0; + self.halt = [0.0; 2]; + self.halt_zaehler = 0.0; + self.rest = 0.0; + } + + /// Ob der Effekt noch klingt, obwohl nichts mehr hineingeht. + /// + /// Der Mixer überspringt stumme Kanäle, um Rechenzeit zu sparen. Genau das + /// würde die Fahne eines Delays abschneiden, sobald man den Fader zuzieht — + /// also fragt er hier nach, bevor er überspringt. + pub fn klingt_nach(&self) -> bool { + matches!(self.effekt, Effekt::Delay | Effekt::Flanger) + && self.mix > 0.0 + && self.rest > STILLE + } + + /// Verarbeitet einen Block verschränktes Stereo, an Ort und Stelle. + pub fn process(&mut self, buffer: &mut [f32]) { + if self.effekt == Effekt::Aus || self.mix <= 0.0 { + // Trocken heißt wirklich trocken: keine Rundung, kein Rechnen. + return; + } + + match self.effekt { + Effekt::Delay => self.delay_process(buffer), + Effekt::Gater => self.gater_process(buffer), + Effekt::Flanger => self.flanger_process(buffer), + Effekt::Crush => self.crush_process(buffer), + Effekt::Aus => {} + } + } + + fn delay_process(&mut self, buffer: &mut [f32]) { + let verzoegerung = + (self.zeit * self.sample_rate).clamp(1.0, self.delay.frames() as f32 - 2.0); + // Bis 0.9: Bei 1.0 wächst die Rückkopplung ohne Ende, und ein Delay, + // das sich aufschaukelt, ist auf einer Anlage gefährlich. + let rueck = self.amount * 0.9; + let mut spitze = 0.0f32; + + for frame in buffer.as_chunks_mut::<2>().0 { + let nass_l = self.delay.lies(verzoegerung, 0); + let nass_r = self.delay.lies(verzoegerung, 1); + + self.delay + .schreibe(frame[0] + nass_l * rueck, frame[1] + nass_r * rueck); + + frame[0] = frame[0] * (1.0 - self.mix) + nass_l * self.mix; + frame[1] = frame[1] * (1.0 - self.mix) + nass_r * self.mix; + + spitze = spitze.max(nass_l.abs()).max(nass_r.abs()); + } + + self.rest = spitze; + } + + fn gater_process(&mut self, buffer: &mut [f32]) { + let periode = (self.zeit * self.sample_rate).max(2.0); + let schritt = 1.0 / periode; + // Öffnungsdauer von einem Viertel bis fast ganz — ein Gate, das immer + // offen ist, tut nichts, eines das kaum öffnet, ist nur ein Knacken. + let offen = 0.15 + self.amount * 0.7; + let rampe = (GATE_RAMPE_MS * 0.001 * self.sample_rate).max(1.0); + let pro_sample = 1.0 / rampe; + + for frame in buffer.as_chunks_mut::<2>().0 { + let ziel = if self.phase < offen { 1.0 } else { 0.0 }; + // Stetig auf das Ziel zulaufen statt springen. + if self.gate < ziel { + self.gate = (self.gate + pro_sample).min(ziel); + } else if self.gate > ziel { + self.gate = (self.gate - pro_sample).max(ziel); + } + + let g = 1.0 - self.mix + self.gate * self.mix; + frame[0] *= g; + frame[1] *= g; + + self.phase += schritt; + if self.phase >= 1.0 { + self.phase -= 1.0; + } + } + + // Ein Gate klingt nicht nach: Es dämpft nur, was hineingeht. + self.rest = 0.0; + } + + fn flanger_process(&mut self, buffer: &mut [f32]) { + // Ein bis zehn Millisekunden — darüber wird aus dem Flanger ein Chorus. + let mitte = 0.005 * self.sample_rate; + let tiefe = 0.004 * self.sample_rate * self.amount; + let schritt = 1.0 / (self.zeit * self.sample_rate).max(2.0); + let rueck = 0.4 * self.amount; + let mut spitze = 0.0f32; + + for frame in buffer.as_chunks_mut::<2>().0 { + let lfo = (std::f32::consts::TAU * self.phase).sin(); + let verzoegerung = mitte + tiefe * lfo; + + let nass_l = self.flanger.lies(verzoegerung, 0); + let nass_r = self.flanger.lies(verzoegerung, 1); + + self.flanger + .schreibe(frame[0] + nass_l * rueck, frame[1] + nass_r * rueck); + + frame[0] = frame[0] * (1.0 - self.mix) + nass_l * self.mix; + frame[1] = frame[1] * (1.0 - self.mix) + nass_r * self.mix; + + self.phase += schritt; + if self.phase >= 1.0 { + self.phase -= 1.0; + } + spitze = spitze.max(nass_l.abs()).max(nass_r.abs()); + } + + self.rest = spitze; + } + + fn crush_process(&mut self, buffer: &mut [f32]) { + // Zwölf Bit hinunter bis vier — darunter bleibt nur noch Krach. + let bits = 12.0 - self.amount * 8.0; + let stufen = (2.0f32).powf(bits); + // Und gleichzeitig die Rate herunter, bis auf ein Achtel. + let halten = 1.0 + self.amount * 7.0; + + for frame in buffer.as_chunks_mut::<2>().0 { + self.halt_zaehler += 1.0; + if self.halt_zaehler >= halten { + self.halt_zaehler -= halten; + self.halt[0] = (frame[0] * stufen).round() / stufen; + self.halt[1] = (frame[1] * stufen).round() / stufen; + } + + frame[0] = frame[0] * (1.0 - self.mix) + self.halt[0] * self.mix; + frame[1] = frame[1] * (1.0 - self.mix) + self.halt[1] * self.mix; + } + + self.rest = 0.0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const RATE: f32 = 48_000.0; + + fn sinus(freq: f32, frames: usize) -> Vec { + (0..frames) + .flat_map(|i| { + let v = (std::f32::consts::TAU * freq * i as f32 / RATE).sin() * 0.5; + [v, v] + }) + .collect() + } + + fn impuls(frames: usize) -> Vec { + let mut v = vec![0.0; frames * 2]; + v[0] = 1.0; + v[1] = 1.0; + v + } + + fn spitze(buffer: &[f32]) -> f32 { + buffer.iter().fold(0.0f32, |m, v| m.max(v.abs())) + } + + fn rms(buffer: &[f32]) -> f32 { + (buffer.iter().map(|v| v * v).sum::() / buffer.len().max(1) as f32).sqrt() + } + + /// Der wichtigste Test überhaupt: Trocken muss trocken heißen. + #[test] + fn bei_mix_null_bleibt_das_signal_unberuehrt() { + for effekt in [Effekt::Delay, Effekt::Gater, Effekt::Flanger, Effekt::Crush] { + let original = sinus(440.0, 2_000); + let mut buffer = original.clone(); + + let mut fx = FxUnit::new(RATE); + fx.set_effekt(effekt); + fx.set_mix(0.0); + fx.process(&mut buffer); + + assert_eq!( + buffer, + original, + "{} verändert das Signal bei Mix 0", + effekt.name() + ); + } + } + + #[test] + fn ausgeschaltet_wird_gar_nicht_gerechnet() { + let original = sinus(440.0, 500); + let mut buffer = original.clone(); + + let mut fx = FxUnit::new(RATE); + fx.set_mix(1.0); + fx.process(&mut buffer); + + assert_eq!(buffer, original); + } + + #[test] + fn das_delay_wiederholt_zur_richtigen_zeit() { + let mut fx = FxUnit::new(RATE); + fx.set_effekt(Effekt::Delay); + fx.set_mix(1.0); + fx.set_amount(0.0); + fx.set_zeit(0.1); + + let mut buffer = impuls(RATE as usize); + fx.process(&mut buffer); + + // Die Wiederholung muss auf 0.1 s liegen — ein Delay, das die Zeit + // nicht trifft, ist beim Mixen unbrauchbar. + let erwartet = (0.1 * RATE) as usize; + let gefunden = (0..RATE as usize) + .max_by(|a, b| { + buffer[a * 2] + .abs() + .partial_cmp(&buffer[b * 2].abs()) + .unwrap() + }) + .unwrap(); + + assert!( + (gefunden as i64 - erwartet as i64).abs() <= 2, + "Wiederholung bei {gefunden} statt {erwartet}" + ); + } + + #[test] + fn die_rueckkopplung_schaukelt_sich_nicht_auf() { + // Bei voller Rückkopplung darf das Delay lauter werden, aber nicht + // über alle Grenzen — ein Delay, das explodiert, ist auf einer + // Anlage gefährlich. + let mut fx = FxUnit::new(RATE); + fx.set_effekt(Effekt::Delay); + fx.set_mix(1.0); + fx.set_amount(1.0); + fx.set_zeit(0.05); + + let mut hoechster = 0.0f32; + for durchgang in 0..40 { + let mut buffer = if durchgang == 0 { + impuls(RATE as usize / 2) + } else { + vec![0.0; RATE as usize] + }; + fx.process(&mut buffer); + hoechster = hoechster.max(spitze(&buffer)); + assert!( + buffer.iter().all(|v| v.is_finite()), + "Durchgang {durchgang} liefert kein endliches Signal" + ); + } + + assert!(hoechster < 4.0, "Rückkopplung wächst auf {hoechster}"); + } + + #[test] + fn das_delay_klingt_nach_wenn_nichts_mehr_hineingeht() { + // Das ist die Voraussetzung dafür, dass der Mixer den Kanal + // weiterrechnet, wenn der Fader zu ist. + let mut fx = FxUnit::new(RATE); + fx.set_effekt(Effekt::Delay); + fx.set_mix(1.0); + fx.set_amount(0.7); + fx.set_zeit(0.05); + + let mut buffer = impuls(RATE as usize / 4); + fx.process(&mut buffer); + assert!(fx.klingt_nach(), "das Delay meldet sich nicht als klingend"); + + // Ein Gate dagegen klingt nie nach — es dämpft nur. + let mut gate = FxUnit::new(RATE); + gate.set_effekt(Effekt::Gater); + gate.set_mix(1.0); + let mut b = sinus(440.0, 1_000); + gate.process(&mut b); + assert!(!gate.klingt_nach()); + } + + #[test] + fn das_tor_schliesst_und_oeffnet_ohne_zu_knacken() { + let mut fx = FxUnit::new(RATE); + fx.set_effekt(Effekt::Gater); + fx.set_mix(1.0); + fx.set_amount(0.5); + fx.set_zeit(0.1); + + // Gleichspannung, damit jeder Sprung im Ausgang vom Tor kommt und + // nicht vom Signal. + let mut buffer = vec![0.5; RATE as usize * 2]; + fx.process(&mut buffer); + + let frames = buffer.as_chunks::<2>().0; + let groesster_sprung = frames + .iter() + .zip(frames.iter().skip(1)) + .fold(0.0f32, |m, (a, b)| m.max((b[0] - a[0]).abs())); + + // Über eine Rampe von 1,5 ms darf ein Schritt nur ein Bruchteil sein. + assert!( + groesster_sprung < 0.02, + "das Tor springt um {groesster_sprung} — das knackt" + ); + } + + #[test] + fn das_tor_macht_wirklich_leiser() { + let mut fx = FxUnit::new(RATE); + fx.set_effekt(Effekt::Gater); + fx.set_mix(1.0); + fx.set_amount(0.0); + fx.set_zeit(0.05); + + let original = sinus(440.0, RATE as usize); + let mut buffer = original.clone(); + fx.process(&mut buffer); + + let verhaeltnis = rms(&buffer) / rms(&original); + // Bei kleinstem Amount ist das Tor 15 % der Zeit offen. + assert!( + verhaeltnis < 0.55, + "Tor dämpft kaum: Faktor {verhaeltnis:.3}" + ); + } + + #[test] + fn der_crusher_reduziert_die_stufen() { + let mut fx = FxUnit::new(RATE); + fx.set_effekt(Effekt::Crush); + fx.set_mix(1.0); + fx.set_amount(1.0); + + let mut buffer = sinus(220.0, 4_000); + fx.process(&mut buffer); + + let mut werte: Vec = buffer.iter().map(|v| (v * 1e6) as i64).collect(); + werte.sort_unstable(); + werte.dedup(); + + // Vier Bit über eine halbe Amplitude sind eine Handvoll Stufen. + assert!( + werte.len() < 40, + "{} verschiedene Werte — da wird nichts reduziert", + werte.len() + ); + } + + #[test] + fn der_flanger_faerbt_ohne_zu_zerstoeren() { + let mut fx = FxUnit::new(RATE); + fx.set_effekt(Effekt::Flanger); + fx.set_mix(0.5); + fx.set_amount(0.8); + fx.set_zeit(2.0); + + let original = sinus(440.0, RATE as usize); + let mut buffer = original.clone(); + fx.process(&mut buffer); + + assert!(buffer.iter().all(|v| v.is_finite())); + assert_ne!(buffer, original, "der Flanger tut nichts"); + // Ein Flanger hebt und senkt, aber er darf nicht übersteuern. + assert!( + spitze(&buffer) < 1.5, + "Flanger übersteuert: {}", + spitze(&buffer) + ); + } + + #[test] + fn ein_wechsel_schleppt_den_alten_klang_nicht_mit() { + let mut fx = FxUnit::new(RATE); + fx.set_effekt(Effekt::Delay); + fx.set_mix(1.0); + fx.set_amount(0.8); + fx.set_zeit(0.2); + + let mut buffer = impuls(RATE as usize / 4); + fx.process(&mut buffer); + assert!(fx.klingt_nach()); + + fx.set_effekt(Effekt::Flanger); + assert!( + !fx.klingt_nach(), + "der alte Hall steckt noch im neuen Effekt" + ); + } + + #[test] + fn namen_gehen_hin_und_zurueck() { + for name in Effekt::NAMEN { + let e = Effekt::aus_name(name).unwrap_or_else(|| panic!("{name} unbekannt")); + assert_eq!(e.name(), *name); + } + assert_eq!(Effekt::aus_name("hall"), None); + } +} diff --git a/crates/audio-engine/src/eingang.rs b/crates/audio-engine/src/eingang.rs new file mode 100644 index 0000000..82e2213 --- /dev/null +++ b/crates/audio-engine/src/eingang.rs @@ -0,0 +1,320 @@ +//! Der Zuspieler für den AUX-Kanal: externes Audio von einem Gerät. +//! +//! Der AUX-Kanal gab es lange, aber er war leer. Im Mixer steht ein +//! vollständiger Kanalzug — Trim, EQ, Filter, Fader, Cue —, und in den +//! Ringpuffer davor hat nie jemand geschrieben. Im Programm stand das sogar +//! wörtlich: „AUX bleibt ohne Zuspieler still." +//! +//! Hier ist der Zuspieler. Er öffnet ein Aufnahmegerät und schiebt, was +//! hereinkommt, in den Ring — das Gegenstück zu [`crate::output`], und aus +//! demselben Grund ein eigener Callback: Aufnahme und Wiedergabe sind zwei +//! Uhren, und der Ring dazwischen ist das, was sie entkoppelt. +//! +//! # Zwei Dinge müssen stimmen, sonst wird es still gefälscht +//! +//! **Die Samplerate.** Der Ring trägt Samples, keine Zeit. Nimmt das Gerät mit +//! 44,1 kHz auf und rechnet die Engine mit 48, läuft alles um 8 % zu langsam — +//! und nichts sagt es, denn es klingt wie Musik. Deshalb wird eine +//! Konfiguration mit **genau** der Rate der Engine verlangt und sonst +//! abgelehnt. Umrechnen wäre ein eigener Baustein; falsch abspielen ist keiner. +//! +//! **Die Kanalzahl.** Der Kanal erwartet verschränktes Stereo. Ein Mikrofon +//! liefert mono, ein Interface womöglich acht Kanäle. Beides wird auf zwei +//! gebracht — mono verdoppelt, mehr als zwei auf die ersten beiden gekürzt. +//! +//! # Ungeprüft an echter Hardware +//! +//! Hier gibt es kein Audiogerät. Geprüft ist, was sich ohne eines prüfen lässt: +//! die Umrechnung auf Stereo und dass die Fehlermeldungen greifen. Ob der +//! Treiber tut, was seine Beschreibung sagt, weiß erst, wer es anschließt — +//! dieselbe Lücke wie bei den vier Ausgängen. + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{SampleFormat, SizedSample, StreamConfig}; + +use crate::aux::AuxWriter; + +/// Ein offenes Aufnahmegerät, das in den AUX-Ring schreibt. +pub struct Eingang { + device_name: String, + sample_rate: u32, + channels: usize, + _stream: cpal::Stream, +} + +impl Eingang { + /// Öffnet ein Aufnahmegerät und übergibt ihm die schreibende Seite. + /// + /// `wunsch` wählt das Gerät über einen Teil seines Namens; ohne Angabe das + /// Standardgerät. `rate` ist die Rate, mit der die Engine rechnet — eine + /// andere wird abgelehnt, nicht umgerechnet. + pub fn open(writer: AuxWriter, rate: u32, wunsch: Option<&str>) -> Result { + let host = cpal::default_host(); + let device = match wunsch { + Some(name) => geraet_suchen(&host, name)?, + None => host + .default_input_device() + .ok_or_else(|| "kein Aufnahmegerät gefunden".to_string())?, + }; + let device_name = name_von(&device); + + let config = passende_konfiguration(&device, rate)?; + let format = config.1; + let config: StreamConfig = config.0; + let channels = config.channels as usize; + + let stream = match format { + SampleFormat::F32 => bauen::(&device, config, writer, channels), + SampleFormat::F64 => bauen::(&device, config, writer, channels), + SampleFormat::I32 => bauen::(&device, config, writer, channels), + SampleFormat::U32 => bauen::(&device, config, writer, channels), + SampleFormat::I16 => bauen::(&device, config, writer, channels), + SampleFormat::U16 => bauen::(&device, config, writer, channels), + // Acht Bit ist die letzte Wahl und nie die erste — aber besser als + // ein Gerät, das sich nicht öffnen lässt. + SampleFormat::I8 => bauen::(&device, config, writer, channels), + SampleFormat::U8 => bauen::(&device, config, writer, channels), + other => return Err(format!("Sample-Format {other} nicht unterstützt")), + } + .map_err(|e| format!("Aufnahme-Stream ließ sich nicht öffnen: {e}"))?; + + stream + .play() + .map_err(|e| format!("Aufnahme ließ sich nicht starten: {e}"))?; + + Ok(Eingang { + device_name, + sample_rate: rate, + channels, + _stream: stream, + }) + } + + pub fn device_name(&self) -> &str { + &self.device_name + } + + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + /// Wie viele Kanäle das Gerät liefert — vor der Umrechnung auf Stereo. + pub fn channels(&self) -> usize { + self.channels + } +} + +/// Alle Aufnahmegeräte, die der Host kennt. +pub fn geraete() -> Vec { + let host = cpal::default_host(); + match host.input_devices() { + Ok(liste) => liste.map(|d| name_von(&d)).collect(), + Err(_) => Vec::new(), + } +} + +fn name_von(device: &cpal::Device) -> String { + device + .id() + .map(|id| id.to_string()) + .unwrap_or_else(|_| "unbekannt".into()) +} + +fn geraet_suchen(host: &cpal::Host, wunsch: &str) -> Result { + let liste = host + .input_devices() + .map_err(|e| format!("Aufnahmegeräte nicht auflistbar: {e}"))?; + let klein = wunsch.to_lowercase(); + for device in liste { + if name_von(&device).to_lowercase().contains(&klein) { + return Ok(device); + } + } + Err(format!( + "kein Aufnahmegerät, dessen Name „{wunsch}\" enthält — bekannt sind: {}", + geraete().join(", ") + )) +} + +/// Sucht eine Konfiguration mit **genau** dieser Rate und dem besten Format. +/// +/// Nicht die nächstbeste Rate: Eine Rate daneben heißt ein Tempo daneben, und +/// das fällt niemandem auf, weil es wie Musik klingt. +/// +/// Und nicht das erstbeste Format. Am `null`-Gerät von ALSA stand `i8` an +/// erster Stelle — acht Bit, also rund 48 dB Störabstand für Material, das +/// hinterher analysiert werden soll. Wer die Liste der Reihe nach abarbeitet, +/// nimmt das und merkt nichts. +fn passende_konfiguration( + device: &cpal::Device, + rate: u32, +) -> Result<(StreamConfig, SampleFormat), String> { + let moeglich = device + .supported_input_configs() + .map_err(|e| format!("Gerätekonfigurationen nicht lesbar: {e}"))?; + + let mut gesehen = Vec::new(); + let mut beste: Option<(u8, StreamConfig, SampleFormat)> = None; + for bereich in moeglich { + let von = bereich.min_sample_rate(); + let bis = bereich.max_sample_rate(); + gesehen.push(format!("{von}–{bis}")); + if von > rate || rate > bis { + continue; + } + let format = bereich.sample_format(); + let rang = rang(format); + if beste.as_ref().is_none_or(|(bisher, ..)| rang > *bisher) { + beste = Some((rang, bereich.with_sample_rate(rate).into(), format)); + } + } + + match beste { + Some((_, config, format)) => Ok((config, format)), + None => Err(format!( + "{} kann nicht mit {rate} Hz aufnehmen (es kann: {}). Umgerechnet wird \ + nicht — eine falsche Rate klingt wie Musik und ist keine.", + name_von(device), + gesehen.join(", ") + )), + } +} + +/// Wie brauchbar ein Sample-Format für Material ist, das analysiert wird. +/// +/// Höher ist besser. Entscheidend ist die Auflösung: Acht Bit sind rund 48 dB +/// Störabstand, sechzehn schon 96. Gleitkomma steht oben, weil es das ist, +/// womit gerechnet wird — jede Umrechnung mehr ist eine Rundung mehr. +fn rang(format: SampleFormat) -> u8 { + match format { + SampleFormat::F32 => 100, + SampleFormat::F64 => 90, + SampleFormat::I32 | SampleFormat::U32 => 80, + SampleFormat::I16 | SampleFormat::U16 => 70, + SampleFormat::I8 | SampleFormat::U8 => 10, + _ => 50, + } +} + +/// Bringt einen Block auf verschränktes Stereo. +/// +/// Mono wird verdoppelt, mehr als zwei Kanäle auf die ersten beiden gekürzt. +/// Ein unvollständiger letzter Frame wird verworfen — halbe Frames bringen die +/// Kanäle für den Rest der Aufnahme durcheinander. +fn nach_stereo(data: &[T], kanaele: usize, aus: &mut Vec) +where + T: SizedSample, + f32: cpal::FromSample, +{ + aus.clear(); + if kanaele == 0 { + return; + } + if kanaele == 1 { + for s in data { + let v = s.to_sample::(); + aus.push(v); + aus.push(v); + } + return; + } + for frame in data.chunks_exact(kanaele) { + aus.push(frame[0].to_sample::()); + aus.push(frame[1].to_sample::()); + } +} + +fn bauen( + device: &cpal::Device, + config: StreamConfig, + mut writer: AuxWriter, + kanaele: usize, +) -> Result +where + T: SizedSample, + f32: cpal::FromSample, +{ + // Einmal groß genug angelegt und danach nur noch gefüllt: Im Callback wird + // nichts angelegt, genau wie überall sonst im Audiopfad. + let mut block: Vec = Vec::with_capacity(8_192); + + device.build_input_stream( + config, + move |data: &[T], _: &cpal::InputCallbackInfo| { + nach_stereo(data, kanaele, &mut block); + writer.write(&block); + }, + |err| eprintln!("Aufnahme-Stream: {err}"), + None, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// **Gleitkomma schlägt acht Bit.** + /// + /// Am `null`-Gerät von ALSA steht `i8` an erster Stelle der Liste. Wer sie + /// der Reihe nach abarbeitet, nimmt acht Bit für Material, das hinterher + /// analysiert werden soll — und merkt nichts, denn es funktioniert ja. + #[test] + fn ein_gutes_format_schlaegt_ein_frueheres_schlechtes() { + assert!(rang(SampleFormat::F32) > rang(SampleFormat::I8)); + assert!(rang(SampleFormat::I16) > rang(SampleFormat::U8)); + assert!(rang(SampleFormat::I32) > rang(SampleFormat::I16)); + } + + #[test] + fn mono_wird_verdoppelt() { + let mut aus = Vec::new(); + nach_stereo(&[0.5f32, -0.25], 1, &mut aus); + assert_eq!(aus, vec![0.5, 0.5, -0.25, -0.25]); + } + + #[test] + fn stereo_bleibt_stereo() { + let mut aus = Vec::new(); + nach_stereo(&[0.5f32, -0.5, 0.25, -0.25], 2, &mut aus); + assert_eq!(aus, vec![0.5, -0.5, 0.25, -0.25]); + } + + /// **Ein Interface mit acht Eingängen darf nicht acht Kanäle durchreichen.** + /// Der AUX-Kanal ist stereo; alles darüber gehört abgeschnitten, nicht + /// hineingemischt. + #[test] + fn mehr_als_zwei_kanaele_werden_gekuerzt() { + let mut aus = Vec::new(); + let block: Vec = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + nach_stereo(&block, 4, &mut aus); + assert_eq!(aus, vec![1.0, 2.0, 5.0, 6.0]); + } + + /// **Ein halber Frame am Blockende wird verworfen, nicht geraten.** + /// + /// Wer ihn mitnimmt, verschiebt links und rechts gegeneinander — und zwar + /// nicht für einen Block, sondern für den Rest der Aufnahme. + #[test] + fn ein_halber_frame_am_ende_wird_verworfen() { + let mut aus = Vec::new(); + nach_stereo(&[1.0f32, 2.0, 3.0], 2, &mut aus); + assert_eq!(aus, vec![1.0, 2.0]); + } + + #[test] + fn null_kanaele_ergeben_nichts_statt_einer_panik() { + let mut aus = Vec::new(); + nach_stereo(&[1.0f32, 2.0], 0, &mut aus); + assert!(aus.is_empty()); + } + + /// Der Puffer wird wiederverwendet — Reste des letzten Blocks dürfen nicht + /// stehen bleiben. + #[test] + fn der_puffer_wird_vor_jedem_block_geleert() { + let mut aus = Vec::new(); + nach_stereo(&[1.0f32, 2.0, 3.0, 4.0], 2, &mut aus); + nach_stereo(&[9.0f32, 8.0], 2, &mut aus); + assert_eq!(aus, vec![9.0, 8.0]); + } +} diff --git a/crates/audio-engine/src/eq.rs b/crates/audio-engine/src/eq.rs new file mode 100644 index 0000000..a8cc5f7 --- /dev/null +++ b/crates/audio-engine/src/eq.rs @@ -0,0 +1,240 @@ +//! Dreiband-EQ mit Kill. +//! +//! Kein Shelving-EQ, sondern ein echtes Crossover: Das Signal wird bei 200 Hz +//! und 2 kHz aufgeteilt, die drei Bänder werden getrennt verstärkt und wieder +//! summiert. +//! +//! Der Unterschied ist nicht akademisch. Ein Shelving-Filter kann ein Band +//! absenken, aber nie ganz entfernen — und ein DJ-EQ, dessen Bass-Regler den +//! Bass nicht *weg* bekommt, ist für Übergänge unbrauchbar. Mit Aufteilung ist +//! Verstärkung null gleichbedeutend mit Kill. +//! +//! ## Zwei Sackgassen, die hier schon durchlaufen sind +//! +//! **Tief- und Hochpass eines Zustandsvariablen-Filters** summieren sich nicht +//! zum Eingang — es fehlt der Bandpassanteil `k·bp`. In Neutralstellung war der +//! EQ dadurch kein Durchgang, sondern eine Kerbe um die Trennfrequenz; ein +//! 220-Hz-Ton verlor über 80 % seines Pegels. +//! +//! **Das Restband subtraktiv bilden** (`rest = Eingang − tief`) ist im +//! Zeitbereich zwar exakt, aber `tief` ist phasenverschoben. Bei 60 Hz liegt +//! die Verschiebung eines Filters vierter Ordnung schon bei etwa 50°, und +//! `|Eingang − tief|` wurde dadurch 0,84 statt nahe null — der Bass-Kill ließ +//! den Bass stehen. Auf der Trennfrequenz kam sogar das Anderthalbfache heraus. +//! +//! ## Was stattdessen gilt +//! +//! Linkwitz-Riley vierter Ordnung: je Trennstelle ein Tief- und ein Hochpass +//! aus zwei kaskadierten Butterworth-Stufen. Genau dafür ist diese Bauform +//! gemacht — beide Zweige liegen auf der Trennfrequenz *in Phase* bei −6 dB und +//! ergeben zusammen wieder eins. Die Summe aller drei Bänder ist ein Allpass: +//! Betrag glatt, Phase gedreht. Für einen EQ ist das genau richtig. + +use crate::svf::Svf; + +const LOW_MID_HZ: f32 = 200.0; +const MID_HIGH_HZ: f32 = 2_000.0; +const Q: f32 = std::f32::consts::FRAC_1_SQRT_2; + +/// Maximale Anhebung je Band (etwa +6 dB). +pub const MAX_GAIN: f32 = 2.0; + +/// Linkwitz-Riley-Trennstelle vierter Ordnung, mono. +#[derive(Debug, Clone, Copy)] +struct Crossover { + lp1: Svf, + lp2: Svf, + hp1: Svf, + hp2: Svf, +} + +impl Crossover { + fn new() -> Self { + Crossover { + lp1: Svf::new(), + lp2: Svf::new(), + hp1: Svf::new(), + hp2: Svf::new(), + } + } + + fn set(&mut self, cutoff: f32, sample_rate: f32) { + for svf in [&mut self.lp1, &mut self.lp2, &mut self.hp1, &mut self.hp2] { + svf.set(cutoff, Q, sample_rate); + } + } + + fn reset(&mut self) { + for svf in [&mut self.lp1, &mut self.lp2, &mut self.hp1, &mut self.hp2] { + svf.reset(); + } + } + + /// Liefert (unterhalb, oberhalb). + #[inline] + fn process(&mut self, input: f32) -> (f32, f32) { + let low = self.lp2.process(self.lp1.process(input).low).low; + let high = self.hp2.process(self.hp1.process(input).high).high; + (low, high) + } +} + +#[derive(Debug, Clone)] +pub struct ThreeBandEq { + /// Pro Kanal eine eigene Trennstelle — Filter haben Zustand. + lower: [Crossover; 2], + upper: [Crossover; 2], + low: f32, + mid: f32, + high: f32, +} + +impl ThreeBandEq { + pub fn new(sample_rate: f32) -> Self { + let mut eq = ThreeBandEq { + lower: [Crossover::new(); 2], + upper: [Crossover::new(); 2], + low: 1.0, + mid: 1.0, + high: 1.0, + }; + eq.set_sample_rate(sample_rate); + eq + } + + pub fn set_sample_rate(&mut self, sample_rate: f32) { + for split in self.lower.iter_mut() { + split.set(LOW_MID_HZ, sample_rate); + } + for split in self.upper.iter_mut() { + split.set(MID_HIGH_HZ, sample_rate); + } + } + + /// Verstärkungen der drei Bänder. 0.0 = Kill, 1.0 = neutral. + pub fn set_gains(&mut self, low: f32, mid: f32, high: f32) { + self.low = low.clamp(0.0, MAX_GAIN); + self.mid = mid.clamp(0.0, MAX_GAIN); + self.high = high.clamp(0.0, MAX_GAIN); + } + + pub fn gains(&self) -> (f32, f32, f32) { + (self.low, self.mid, self.high) + } + + pub fn is_neutral(&self) -> bool { + (self.low - 1.0).abs() < f32::EPSILON + && (self.mid - 1.0).abs() < f32::EPSILON + && (self.high - 1.0).abs() < f32::EPSILON + } + + pub fn reset(&mut self) { + for split in self.lower.iter_mut().chain(self.upper.iter_mut()) { + split.reset(); + } + } + + /// Verarbeitet interleaved Stereo an Ort und Stelle. + pub fn process(&mut self, buffer: &mut [f32]) { + for frame in buffer.as_chunks_mut::<2>().0 { + for (ch, sample) in frame.iter_mut().enumerate() { + let (low, rest) = self.lower[ch].process(*sample); + let (mid, high) = self.upper[ch].process(rest); + + *sample = low * self.low + mid * self.mid + high * self.high; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{rms, sine_stereo}; + + const RATE: u32 = 48_000; + + /// Verhältnis von Ausgang zu Eingang bei einer Frequenz, mit gegebenen Reglern. + fn verhaeltnis(freq: f32, low: f32, mid: f32, high: f32) -> f32 { + let signal = sine_stereo(freq, RATE, 0.5); + let mut eq = ThreeBandEq::new(RATE as f32); + eq.set_gains(low, mid, high); + + let mut out = signal.clone(); + eq.process(&mut out); + + let half = out.len() / 2; + rms(&out[half..]) / rms(&signal[half..]) + } + + #[test] + fn neutral_ist_ein_echter_durchgang() { + // Die Eigenschaft, an der beide früheren Entwürfe gescheitert sind. + for freq in [60.0, 200.0, 630.0, 2_000.0, 8_000.0] { + let v = verhaeltnis(freq, 1.0, 1.0, 1.0); + assert!( + (v - 1.0).abs() < 0.02, + "{freq} Hz wird neutral auf {v:.4} verändert" + ); + } + } + + #[test] + fn bass_kill_entfernt_den_bass() { + let bass = verhaeltnis(60.0, 0.0, 1.0, 1.0); + assert!(bass < 0.05, "60 Hz überlebt den Kill mit {bass:.3}"); + } + + #[test] + fn bass_kill_laesst_die_hoehen_stehen() { + // Das ist der eigentliche Zweck: Bass raus, Rest bleibt spielbar. + let hoehen = verhaeltnis(8_000.0, 0.0, 1.0, 1.0); + assert!(hoehen > 0.95, "Bass-Kill dämpft auch 8 kHz auf {hoehen:.3}"); + } + + #[test] + fn jedes_band_trifft_seinen_bereich() { + assert!( + verhaeltnis(60.0, 0.0, 1.0, 1.0) < 0.05, + "Low-Kill wirkt nicht" + ); + assert!( + verhaeltnis(630.0, 1.0, 0.0, 1.0) < 0.05, + "Mid-Kill wirkt nicht" + ); + assert!( + verhaeltnis(10_000.0, 1.0, 1.0, 0.0) < 0.05, + "High-Kill wirkt nicht" + ); + } + + #[test] + fn auf_der_trennfrequenz_teilen_sich_zwei_baender_das_signal() { + // Bei Linkwitz-Riley liegt dort jeder Zweig bei −6 dB. Kill des einen + // lässt also etwa die Hälfte stehen — kein Fehler, sondern die + // Bauform. Bei jedem analogen DJ-EQ ist es genauso. + let auf_der_grenze = verhaeltnis(200.0, 0.0, 1.0, 1.0); + assert!( + (0.35..0.65).contains(&auf_der_grenze), + "auf der Trennfrequenz unerwartet: {auf_der_grenze:.3}" + ); + } + + #[test] + fn alles_zu_ergibt_stille() { + let v = verhaeltnis(1_000.0, 0.0, 0.0, 0.0); + assert!(v < 0.02, "trotz aller Kills bleibt {v:.3}"); + } + + #[test] + fn anhebung_ist_begrenzt() { + let mut eq = ThreeBandEq::new(RATE as f32); + eq.set_gains(99.0, -5.0, 1.5); + let (low, mid, high) = eq.gains(); + + assert_eq!(low, MAX_GAIN); + assert_eq!(mid, 0.0); + assert_eq!(high, 1.5); + assert!(!eq.is_neutral()); + } +} diff --git a/crates/audio-engine/src/filter.rs b/crates/audio-engine/src/filter.rs new file mode 100644 index 0000000..9c47f24 --- /dev/null +++ b/crates/audio-engine/src/filter.rs @@ -0,0 +1,190 @@ +//! Filterknopf nach DJ-Art: ein Regler, zwei Richtungen. +//! +//! Mitte ist neutral, nach links fährt ein Tiefpass herunter, nach rechts ein +//! Hochpass herauf. Ein einziger Regler statt zweier ist keine Sparmaßnahme — +//! im Livebetrieb greift man blind danach, und die Mittelstellung muss sich +//! ertasten lassen. +//! +//! Die Frequenzen laufen exponentiell, weil Gehör so funktioniert: Von 100 auf +//! 200 Hz ist gefühlt derselbe Schritt wie von 1000 auf 2000. + +use crate::svf::Svf; + +/// Unterhalb dieser Auslenkung bleibt der Filter aus. +const DEADZONE: f32 = 0.02; + +const LP_MAX_HZ: f32 = 20_000.0; +const LP_MIN_HZ: f32 = 40.0; +const HP_MIN_HZ: f32 = 20.0; +const HP_MAX_HZ: f32 = 12_000.0; + +const Q: f32 = 0.9; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + Bypass, + LowPass, + HighPass, +} + +#[derive(Debug, Clone)] +pub struct DjFilter { + svf: [Svf; 2], + mode: Mode, + position: f32, + sample_rate: f32, +} + +impl DjFilter { + pub fn new(sample_rate: f32) -> Self { + DjFilter { + svf: [Svf::new(); 2], + mode: Mode::Bypass, + position: 0.0, + sample_rate, + } + } + + /// −1 = Tiefpass ganz zu, 0 = neutral, +1 = Hochpass ganz auf. + pub fn set_position(&mut self, position: f32) { + let position = position.clamp(-1.0, 1.0); + self.position = position; + + let (mode, cutoff) = if position < -DEADZONE { + let t = (-position - DEADZONE) / (1.0 - DEADZONE); + (Mode::LowPass, exp_sweep(LP_MAX_HZ, LP_MIN_HZ, t)) + } else if position > DEADZONE { + let t = (position - DEADZONE) / (1.0 - DEADZONE); + (Mode::HighPass, exp_sweep(HP_MIN_HZ, HP_MAX_HZ, t)) + } else { + (Mode::Bypass, 1_000.0) + }; + + if mode != self.mode { + // Beim Umschalten den Zustand verwerfen, sonst knackt der Übergang. + for svf in self.svf.iter_mut() { + svf.reset(); + } + self.mode = mode; + } + + for svf in self.svf.iter_mut() { + svf.set(cutoff, Q, self.sample_rate); + } + } + + pub fn position(&self) -> f32 { + self.position + } + + pub fn is_active(&self) -> bool { + self.mode != Mode::Bypass + } + + pub fn reset(&mut self) { + for svf in self.svf.iter_mut() { + svf.reset(); + } + } + + pub fn process(&mut self, buffer: &mut [f32]) { + if self.mode == Mode::Bypass { + return; + } + + for frame in buffer.as_chunks_mut::<2>().0 { + for (ch, sample) in frame.iter_mut().enumerate() { + let out = self.svf[ch].process(*sample); + *sample = match self.mode { + Mode::LowPass => out.low, + Mode::HighPass => out.high, + Mode::Bypass => *sample, + }; + } + } + } +} + +fn exp_sweep(from: f32, to: f32, t: f32) -> f32 { + from * (to / from).powf(t.clamp(0.0, 1.0)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{rms, sine_stereo}; + + const RATE: u32 = 48_000; + + fn verhaeltnis(freq: f32, position: f32) -> f32 { + let signal = sine_stereo(freq, RATE, 0.5); + let mut filter = DjFilter::new(RATE as f32); + filter.set_position(position); + + let mut out = signal.clone(); + filter.process(&mut out); + + let half = out.len() / 2; + rms(&out[half..]) / rms(&signal[half..]) + } + + #[test] + fn mittelstellung_laesst_alles_durch() { + for freq in [50.0, 1_000.0, 12_000.0] { + let v = verhaeltnis(freq, 0.0); + assert!((v - 1.0).abs() < 1e-4, "{freq} Hz verändert auf {v:.4}"); + } + } + + #[test] + fn die_totzone_gilt_in_beide_richtungen() { + assert!(!DjFilter::new(48_000.0).is_active()); + + let mut f = DjFilter::new(48_000.0); + f.set_position(0.01); + assert!(!f.is_active(), "winzige Auslenkung schaltet schon"); + + f.set_position(-0.01); + assert!(!f.is_active()); + + f.set_position(0.5); + assert!(f.is_active()); + } + + #[test] + fn ganz_links_bleibt_nur_bass() { + // Am Anschlag steht die Grenzfrequenz bei 40 Hz. Selbst 60 Hz liegt + // dann schon darüber und wird gedämpft — das ist gewollt, „ganz zu" + // heißt eben fast zu. Entscheidend ist der Abstand zu den Höhen. + let bass = verhaeltnis(40.0, -1.0); + let hoehen = verhaeltnis(8_000.0, -1.0); + + assert!(bass > 0.5, "Bass wird bei Tiefpass zu {bass:.3} gedämpft"); + assert!( + hoehen < 0.02, + "Höhen überleben den Tiefpass mit {hoehen:.3}" + ); + assert!( + bass / hoehen.max(1e-9) > 50.0, + "Tiefpass trennt kaum: Bass {bass:.3} gegen Höhen {hoehen:.4}" + ); + } + + #[test] + fn ganz_rechts_bleibt_nur_hoehe() { + let bass = verhaeltnis(60.0, 1.0); + let hoehen = verhaeltnis(14_000.0, 1.0); + + assert!(hoehen > 0.5, "Höhen bei Hochpass nur {hoehen:.3}"); + assert!(bass < 0.02, "Bass überlebt den Hochpass mit {bass:.3}"); + } + + #[test] + fn position_wird_geklemmt() { + let mut f = DjFilter::new(48_000.0); + f.set_position(-9.0); + assert_eq!(f.position(), -1.0); + f.set_position(9.0); + assert_eq!(f.position(), 1.0); + } +} diff --git a/crates/audio-engine/src/lib.rs b/crates/audio-engine/src/lib.rs new file mode 100644 index 0000000..22d0cb5 --- /dev/null +++ b/crates/audio-engine/src/lib.rs @@ -0,0 +1,46 @@ +//! Mixer-Engine: mehrere Kanäle, Master- und Cue-Bus. +//! +//! Setzt auf `audio-core` auf, das ein einzelnes Deck liefert. Hier kommt +//! zusammen, was daraus ein Mischpult macht — Kanalzüge mit EQ und Filter, +//! Crossfader, Begrenzer auf der Summe und ein getrennter Kopfhörerweg. +//! +//! Quellen sind austauschbar: Ein Kanal weiß nicht, ob hinter ihm ein Deck, +//! ein AUX-Eingang oder später ein generierter Track steht. +//! +//! Alles hier läuft im Audio-Callback und hält sich an dessen Regeln — keine +//! Allokation, kein Lock, keine Speicherfreigabe. Puffer wachsen beim ersten +//! Block auf die nötige Größe und bleiben danach stehen. + +pub mod aufnahme; +pub mod aux; +pub mod channel; +pub mod command; +pub mod crossfader; +pub mod effects; +pub mod eingang; +pub mod eq; +pub mod filter; +pub mod limiter; +pub mod mixer; +pub mod output; +pub mod source; +pub mod svf; +pub mod sync; + +#[cfg(test)] +pub(crate) mod testing; + +pub use aufnahme::{mitschnitt, Aufnahme, Mitschnitt}; +pub use aux::{aux_channel, AuxSource, AuxWriter}; +pub use channel::Channel; +pub use command::{channel as engine_channel, Command, EngineHandle, EngineRunner}; +pub use crossfader::{Assign, Crossfader}; +pub use effects::{Effekt, FxUnit}; +pub use eingang::Eingang; +pub use eq::ThreeBandEq; +pub use filter::DjFilter; +pub use limiter::Limiter; +pub use mixer::{assign_deck_pair, Engine}; +pub use output::Output; +pub use source::{DeckSource, LoopSource, SilentSource, Source}; +pub use sync::{phase_error, sync, sync_tempo_only, SyncPlan}; diff --git a/crates/audio-engine/src/limiter.rs b/crates/audio-engine/src/limiter.rs new file mode 100644 index 0000000..3bf8ed8 --- /dev/null +++ b/crates/audio-engine/src/limiter.rs @@ -0,0 +1,166 @@ +//! Begrenzer auf der Summe. +//! +//! Vier Kanäle addieren sich, und irgendwann übersteuert das. Ohne Begrenzer +//! wäre die Folge digitales Clipping — die hässlichste Art, laut zu werden. +//! +//! Bewusst einfach gehalten: Die Verstärkung fällt sofort und steigt langsam +//! wieder. Ohne Vorausschau (Lookahead) heißt das, dass sehr steile Transienten +//! verzerren statt weich begrenzt zu werden. Für die Summe eines DJ-Mixes ist +//! das vertretbar; ein Mastering-Begrenzer ist es nicht. + +/// Voreinstellung: knapp unter Vollaussteuerung, damit noch Luft bleibt. +pub const DEFAULT_CEILING: f32 = 0.98; + +#[derive(Debug, Clone)] +pub struct Limiter { + ceiling: f32, + gain: f32, + release: f32, +} + +impl Limiter { + pub fn new(sample_rate: f32) -> Self { + Limiter { + ceiling: DEFAULT_CEILING, + gain: 1.0, + release: release_coefficient(0.100, sample_rate), + } + } + + pub fn set_ceiling(&mut self, ceiling: f32) { + self.ceiling = ceiling.clamp(0.01, 1.0); + } + + pub fn ceiling(&self) -> f32 { + self.ceiling + } + + /// Aktuelle Pegelreduktion in Dezibel, negativ oder null. + pub fn reduction_db(&self) -> f32 { + 20.0 * self.gain.max(1e-6).log10() + } + + pub fn reset(&mut self) { + self.gain = 1.0; + } + + /// Verarbeitet interleaved Stereo an Ort und Stelle. + pub fn process(&mut self, buffer: &mut [f32]) { + for frame in buffer.as_chunks_mut::<2>().0 { + let peak = frame[0].abs().max(frame[1].abs()); + + let needed = if peak > 0.0 { + (self.ceiling / peak).min(1.0) + } else { + 1.0 + }; + + if needed < self.gain { + // Sofort greifen — hier darf nichts durchrutschen. + self.gain = needed; + } else { + self.gain += (needed - self.gain) * self.release; + } + + for sample in frame.iter_mut() { + *sample = (*sample * self.gain).clamp(-self.ceiling, self.ceiling); + } + } + } +} + +fn release_coefficient(seconds: f32, sample_rate: f32) -> f32 { + let samples = (seconds * sample_rate).max(1.0); + 1.0 - (-1.0 / samples).exp() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{peak, rms, sine_stereo}; + + const RATE: u32 = 48_000; + + #[test] + fn lautes_material_bleibt_unter_der_decke() { + let mut signal: Vec = sine_stereo(220.0, RATE, 0.5) + .into_iter() + .map(|v| v * 4.0) + .collect(); + + let mut limiter = Limiter::new(RATE as f32); + limiter.process(&mut signal); + + let gemessen = peak(&signal); + assert!( + gemessen <= limiter.ceiling() + 1e-6, + "Spitze {gemessen:.4} über der Decke {:.4}", + limiter.ceiling() + ); + } + + #[test] + fn leises_material_bleibt_unangetastet() { + let original = sine_stereo(220.0, RATE, 0.3) + .into_iter() + .map(|v| v * 0.2) + .collect::>(); + + let mut signal = original.clone(); + let mut limiter = Limiter::new(RATE as f32); + limiter.process(&mut signal); + + for (a, b) in original.iter().zip(&signal) { + assert!((a - b).abs() < 1e-6, "leises Signal wurde verändert"); + } + } + + #[test] + fn ein_einzelner_ausreisser_reisst_nicht_alles_mit() { + // Nach einem kurzen Peak muss der Pegel wieder hochkommen, sonst + // duckt ein einziger Knall den ganzen Mix. + let mut signal = sine_stereo(220.0, RATE, 1.0) + .into_iter() + .map(|v| v * 0.3) + .collect::>(); + signal[100] = 8.0; + signal[101] = 8.0; + + let mut limiter = Limiter::new(RATE as f32); + limiter.process(&mut signal); + + let spaet = rms(&signal[signal.len() * 3 / 4..]); + let erwartet = 0.3 / 2.0_f32.sqrt(); + assert!( + (spaet - erwartet).abs() < 0.02, + "Pegel erholt sich nicht: {spaet:.3} statt {erwartet:.3}" + ); + } + + #[test] + fn decke_wird_auf_sinnvolle_werte_geklemmt() { + let mut limiter = Limiter::new(RATE as f32); + limiter.set_ceiling(5.0); + assert_eq!(limiter.ceiling(), 1.0); + limiter.set_ceiling(-1.0); + assert_eq!(limiter.ceiling(), 0.01); + } + + #[test] + fn reduktion_wird_gemeldet() { + let mut signal = sine_stereo(220.0, RATE, 0.2) + .into_iter() + .map(|v| v * 4.0) + .collect::>(); + + let mut limiter = Limiter::new(RATE as f32); + assert_eq!(limiter.reduction_db(), 0.0); + + limiter.process(&mut signal); + assert!( + limiter.reduction_db() < -6.0, + "Reduktion nur {:.1} dB bei vierfachem Pegel", + limiter.reduction_db() + ); + } +} diff --git a/crates/audio-engine/src/mixer.rs b/crates/audio-engine/src/mixer.rs new file mode 100644 index 0000000..06814b6 --- /dev/null +++ b/crates/audio-engine/src/mixer.rs @@ -0,0 +1,550 @@ +//! Der Mixer: summiert Kanäle auf Master- und Cue-Bus. +//! +//! Zwei getrennte Summen, zwei getrennte Ausgänge. Der Master geht auf die +//! Anlage (Ausgang 1/2), der Cue auf den Kopfhörer (Ausgang 3/4) — beides über +//! *dasselbe* Gerät, weil zwei Geräte zwei Uhren bedeuten und deren Drift den +//! Kopfhörer nach ein paar Minuten hörbar gegen den Master verschiebt. Siehe +//! `docs/PLAN.md`. + +use crate::aufnahme::Mitschnitt; +use crate::channel::Channel; +use crate::crossfader::{Assign, Crossfader}; +use crate::limiter::Limiter; +use crate::source::Source; + +pub struct Engine { + sample_rate: f32, + channels: Vec, + crossfader: Crossfader, + limiter: Limiter, + master_gain: f32, + cue_gain: f32, + cue_mix: f32, + master: Vec, + cue: Vec, + /// Zwischenspeicher für das Signal hinter dem Fader. + post: Vec, + mitschnitt: Option, +} + +impl Engine { + pub fn new(sample_rate: f32) -> Self { + Engine { + sample_rate, + channels: Vec::new(), + crossfader: Crossfader::new(), + limiter: Limiter::new(sample_rate), + master_gain: 1.0, + cue_gain: 1.0, + cue_mix: 0.0, + master: Vec::new(), + cue: Vec::new(), + post: Vec::new(), + mitschnitt: None, + } + } + + pub fn sample_rate(&self) -> f32 { + self.sample_rate + } + + /// Hängt den Mitschnitt an die Summe. + pub fn set_mitschnitt(&mut self, mitschnitt: Mitschnitt) { + self.mitschnitt = Some(mitschnitt); + } + + /// Legt einen Kanal an und gibt seinen Index zurück. + pub fn add_channel(&mut self, name: impl Into, source: Box) -> usize { + self.channels + .push(Channel::new(name, source, self.sample_rate)); + self.channels.len() - 1 + } + + pub fn channel(&mut self, index: usize) -> &mut Channel { + &mut self.channels[index] + } + + pub fn channels(&self) -> &[Channel] { + &self.channels + } + + pub fn channel_count(&self) -> usize { + self.channels.len() + } + + pub fn crossfader(&mut self) -> &mut Crossfader { + &mut self.crossfader + } + + pub fn limiter(&self) -> &Limiter { + &self.limiter + } + + pub fn set_master_gain(&mut self, gain: f32) { + self.master_gain = gain.clamp(0.0, 2.0); + } + + pub fn set_cue_gain(&mut self, gain: f32) { + self.cue_gain = gain.clamp(0.0, 2.0); + } + + /// Blendet den Master in den Kopfhörer: 0.0 = nur Cue, 1.0 = nur Master. + pub fn set_cue_mix(&mut self, mix: f32) { + self.cue_mix = mix.clamp(0.0, 1.0); + } + + /// Wieviele Ausgangskanäle nötig sind, damit der Cue-Bus ankommt. + pub const REQUIRED_OUTPUTS_FOR_CUE: usize = 4; + + /// Rendert einen Block in den Gerätepuffer. + /// + /// Belegung: 0/1 Master, 2/3 Cue. Hat das Gerät weniger als vier Kanäle, + /// fällt der Cue-Bus weg — die Software läuft dann, aber Vorhören ist + /// nicht möglich. + pub fn render(&mut self, out: &mut [f32], out_channels: usize) { + if out_channels == 0 { + return; + } + let frames = out.len() / out_channels; + let len = frames * 2; + + if self.master.len() < len { + self.master.resize(len, 0.0); + self.cue.resize(len, 0.0); + self.post.resize(len, 0.0); + } + self.master[..len].fill(0.0); + self.cue[..len].fill(0.0); + + for channel in self.channels.iter_mut() { + let cross = self.crossfader.gain(channel.assign()); + let cued = channel.is_cued(); + // Ein Kanal mit klingendem Effekt ist nicht still, auch wenn sein + // Fader unten ist — sonst risse jedes Zuziehen die Delayfahne ab. + let hoerbar = cross > 0.0 && (channel.fader() > 0.0 || channel.klingt_nach()); + + if !hoerbar && !cued { + // Still und nicht im Kopfhörer: EQ, Filter und Effekte bleiben + // aus — das ist das Teure und wirkt gerade ohnehin nicht. + // + // Die **Quelle läuft trotzdem weiter**. Ein Deck, das mit + // zugezogenem Fader stehenbleibt, wäre kein Deck: Der Track, + // den man zum Einsatz bringen will, käme nicht von der Stelle, + // ein weggeblendetes Deck liefe aus der Phase, und + // `finished` käme nie — womit Sync und jeder wartende Agent + // ins Leere liefen. + channel.quelle_weiterziehen(frames); + continue; + } + + { + let pre = channel.render_pre_fader(frames); + + if cued { + for (dst, src) in self.cue[..len].iter_mut().zip(pre) { + *dst += *src; + } + } + self.post[..len].copy_from_slice(pre); + } + + if !hoerbar { + continue; + } + + // Fader und Effekte, dann erst der Crossfader. + channel.process_post_fader(&mut self.post[..len]); + for (dst, src) in self.master[..len].iter_mut().zip(&self.post[..len]) { + *dst += *src * cross; + } + } + + if (self.master_gain - 1.0).abs() > f32::EPSILON { + for sample in self.master[..len].iter_mut() { + *sample *= self.master_gain; + } + } + self.limiter.process(&mut self.master[..len]); + + // Der Mitschnitt greift **hinter** dem Begrenzer ab — aufgenommen wird + // das, was auf die Anlage geht, nicht das, was vorher da war. Hier + // wird nur in einen Ringpuffer geschoben; geschrieben wird woanders. + if let Some(m) = self.mitschnitt.as_mut() { + m.nimm_auf(&self.master[..len]); + } + + // Kopfhörer: Mischung aus Cue-Bus und Master. + for i in 0..len { + let mixed = self.cue[i] * (1.0 - self.cue_mix) + self.master[i] * self.cue_mix; + self.cue[i] = (mixed * self.cue_gain).clamp(-1.0, 1.0); + } + + for frame in 0..frames { + let base = frame * out_channels; + let slot = &mut out[base..base + out_channels]; + slot.fill(0.0); + + match out_channels { + 1 => slot[0] = 0.5 * (self.master[frame * 2] + self.master[frame * 2 + 1]), + 2 | 3 => { + slot[0] = self.master[frame * 2]; + slot[1] = self.master[frame * 2 + 1]; + } + _ => { + slot[0] = self.master[frame * 2]; + slot[1] = self.master[frame * 2 + 1]; + slot[2] = self.cue[frame * 2]; + slot[3] = self.cue[frame * 2 + 1]; + } + } + } + } +} + +/// Bequemer Zugriff auf die Crossfader-Seiten beim Aufbau. +pub fn assign_deck_pair(engine: &mut Engine, left: usize, right: usize) { + engine.channel(left).set_assign(Assign::A); + engine.channel(right).set_assign(Assign::B); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::{LoopSource, SilentSource}; + use crate::testing::{peak, rms, rms_channel, sine_stereo}; + + const RATE: f32 = 48_000.0; + const FRAMES: usize = 4_800; + + fn ton(freq: f32, amplitude: f32) -> Box { + let samples = sine_stereo(freq, RATE as u32, 1.0) + .into_iter() + .map(|v| v * amplitude) + .collect(); + Box::new(LoopSource::new(samples)) + } + + fn rendern(engine: &mut Engine, out_channels: usize) -> Vec { + let mut out = vec![0.0; FRAMES * out_channels]; + engine.render(&mut out, out_channels); + out + } + + /// Ein Deck mit zugezogenem Fader muss weiterlaufen. + /// + /// Die erste Fassung sprang über stille Kanäle hinweg, um EQ und Filter zu + /// sparen — und übersprang dabei auch die Quelle. Damit stand jedes Deck, + /// das nicht zu hören war: Der Track, den man einsetzen wollte, kam nicht + /// von der Stelle, zwei gesyncte Decks liefen auseinander, sobald eines + /// weggeblendet war, und `finished` kam nie. Beatmatching wäre damit + /// wirkungslos gewesen. + /// + /// Geprüft wird an einer Quelle, die zählt, wie viele Frames sie abgeben + /// musste — der Fortschritt selbst, nicht sein Klang. + #[test] + fn ein_stummer_kanal_laeuft_trotzdem_weiter() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + struct Zaehler(Arc); + impl Source for Zaehler { + fn render(&mut self, buffer: &mut [f32]) { + self.0.fetch_add(buffer.len() / 2, Ordering::Relaxed); + buffer.fill(0.0); + } + } + + let mut engine = Engine::new(RATE); + let gezogen = Arc::new(AtomicUsize::new(0)); + let kanal = engine.add_channel("STUMM", Box::new(Zaehler(Arc::clone(&gezogen)))); + + // Fader zu, kein Kopfhörer — der Kanal ist nirgends zu hören. + engine.channel(kanal).set_fader(0.0); + engine.channel(kanal).set_cue(false); + rendern(&mut engine, 2); + + assert_eq!( + gezogen.load(Ordering::Relaxed), + FRAMES, + "die Quelle wurde nicht weitergezogen — das Deck steht" + ); + } + + fn master_rms(out: &[f32], channels: usize) -> f32 { + rms_channel(out, 0, channels) + } + + fn cue_rms(out: &[f32], channels: usize) -> f32 { + rms_channel(out, 2, channels) + } + + #[test] + fn ohne_kanaele_bleibt_es_still() { + let mut engine = Engine::new(RATE); + let out = rendern(&mut engine, 4); + assert_eq!(peak(&out), 0.0); + } + + #[test] + fn zwei_decks_summieren_sich() { + let mut engine = Engine::new(RATE); + let a = engine.add_channel("A", ton(220.0, 0.3)); + let b = engine.add_channel("B", ton(660.0, 0.3)); + + engine.channel(a).set_fader(1.0); + let nur_a = master_rms(&rendern(&mut engine, 4), 4); + + engine.channel(b).set_fader(1.0); + let beide = master_rms(&rendern(&mut engine, 4), 4); + + assert!( + beide > nur_a * 1.2, + "zweites Deck kommt nicht an: {nur_a:.4} → {beide:.4}" + ); + } + + #[test] + fn der_crossfader_blendet_zwischen_den_seiten() { + let mut engine = Engine::new(RATE); + let a = engine.add_channel("A", ton(220.0, 0.4)); + let b = engine.add_channel("B", ton(220.0, 0.4)); + engine.channel(a).set_fader(1.0); + engine.channel(b).set_fader(1.0); + assign_deck_pair(&mut engine, a, b); + + engine.crossfader().set_position(-1.0); + let links = master_rms(&rendern(&mut engine, 4), 4); + + engine.channel(a).set_fader(0.0); + let links_ohne_a = master_rms(&rendern(&mut engine, 4), 4); + + assert!(links > 0.1, "auf A steht nichts an: {links:.4}"); + assert!( + links_ohne_a < 0.01, + "B ist bei Crossfader ganz links hörbar: {links_ohne_a:.4}" + ); + } + + #[test] + fn thru_kanaele_ueberleben_jede_crossfader_stellung() { + // Genau dafür ist AUX da: ein Mikrofon darf nicht verschwinden, + // nur weil der Crossfader bewegt wird. + let mut engine = Engine::new(RATE); + let aux = engine.add_channel("AUX", ton(440.0, 0.3)); + engine.channel(aux).set_fader(1.0); + engine.channel(aux).set_assign(Assign::Thru); + + let mut pegel = Vec::new(); + for pos in [-1.0, -0.5, 0.0, 0.5, 1.0] { + engine.crossfader().set_position(pos); + pegel.push(master_rms(&rendern(&mut engine, 4), 4)); + } + + let min = pegel.iter().cloned().fold(f32::MAX, f32::min); + let max = pegel.iter().cloned().fold(0.0f32, f32::max); + assert!(min > 0.1, "Thru-Kanal fällt weg: {pegel:?}"); + assert!( + (max - min) / max < 0.02, + "Thru-Kanal schwankt mit dem Crossfader: {pegel:?}" + ); + } + + #[test] + fn cue_hoert_einen_kanal_mit_geschlossenem_fader() { + // Der eigentliche Zweck des Vorhörens. + let mut engine = Engine::new(RATE); + let a = engine.add_channel("A", ton(220.0, 0.4)); + engine.channel(a).set_fader(0.0); + engine.channel(a).set_cue(true); + + let out = rendern(&mut engine, 4); + + assert!( + master_rms(&out, 4) < 0.001, + "der Kanal steht trotz geschlossenem Fader auf dem Master" + ); + assert!( + cue_rms(&out, 4) > 0.1, + "im Kopfhörer ist nichts zu hören: {:.4}", + cue_rms(&out, 4) + ); + } + + #[test] + fn ohne_cue_taste_bleibt_der_kopfhoerer_still() { + let mut engine = Engine::new(RATE); + let a = engine.add_channel("A", ton(220.0, 0.4)); + engine.channel(a).set_fader(1.0); + + let out = rendern(&mut engine, 4); + assert!(master_rms(&out, 4) > 0.1); + assert!(cue_rms(&out, 4) < 0.001, "Cue-Bus leckt"); + } + + #[test] + fn cue_mix_holt_den_master_in_den_kopfhoerer() { + let mut engine = Engine::new(RATE); + let a = engine.add_channel("A", ton(220.0, 0.4)); + engine.channel(a).set_fader(1.0); + engine.set_cue_mix(1.0); + + let out = rendern(&mut engine, 4); + assert!( + cue_rms(&out, 4) > 0.1, + "bei vollem Cue-Mix kommt der Master nicht durch" + ); + } + + #[test] + fn zweikanalgeraet_liefert_master_ohne_zu_paniken() { + let mut engine = Engine::new(RATE); + let a = engine.add_channel("A", ton(220.0, 0.4)); + engine.channel(a).set_fader(1.0); + engine.channel(a).set_cue(true); + + let out = rendern(&mut engine, 2); + assert!(master_rms(&out, 2) > 0.1); + } + + #[test] + fn die_summe_wird_begrenzt() { + let mut engine = Engine::new(RATE); + for i in 0..4 { + let ch = engine.add_channel(format!("D{i}"), ton(220.0, 0.9)); + engine.channel(ch).set_fader(1.0); + } + + let out = rendern(&mut engine, 4); + let spitze = peak(&out); + + assert!( + spitze <= engine.limiter().ceiling() + 1e-6, + "vier laute Kanäle erzeugen Spitze {spitze:.3}" + ); + } + + #[test] + fn stille_kanaele_kosten_nichts_und_stoeren_nicht() { + let mut engine = Engine::new(RATE); + engine.add_channel("leer", Box::new(SilentSource)); + let a = engine.add_channel("A", ton(220.0, 0.3)); + engine.channel(a).set_fader(1.0); + + let out = rendern(&mut engine, 4); + assert!(master_rms(&out, 4) > 0.1); + } + + /// Der Grund, warum die Effekte hinter dem Fader sitzen. + /// + /// Zieht man den Fader zu, während ein Delay klingt, muss die Fahne + /// ausklingen. Ohne die Nachfrage bei [`Channel::klingt_nach`] würde der + /// Mixer den stummen Kanal überspringen und den Hall abschneiden — die + /// Post-Fader-Anordnung wäre dann nur auf dem Papier richtig. + #[test] + fn die_delayfahne_ueberlebt_den_zugezogenen_fader() { + use crate::effects::Effekt; + + let mut engine = Engine::new(RATE); + let kanal = engine.add_channel( + "A", + Box::new(LoopSource::new(sine_stereo(440.0, RATE as u32, 0.02))), + ); + engine.channel(kanal).set_assign(Assign::Thru); + engine.channel(kanal).set_fader(1.0); + + { + let fx = engine.channel(kanal).fx(); + fx.set_effekt(Effekt::Delay); + fx.set_mix(1.0); + fx.set_amount(0.8); + fx.set_zeit(0.15); + } + + // Erst füttern, damit die Leitung voll ist. + let mut block = vec![0.0f32; 4_096]; + for _ in 0..20 { + engine.render(&mut block, 2); + } + + // Jetzt zuziehen — und trotzdem muss etwas herauskommen. + engine.channel(kanal).set_fader(0.0); + + let mut lautester = 0.0f32; + for _ in 0..6 { + block.fill(0.0); + engine.render(&mut block, 2); + lautester = lautester.max(block.iter().fold(0.0f32, |m, v| m.max(v.abs()))); + } + + assert!( + lautester > 0.001, + "die Fahne reißt beim Zuziehen ab: Restpegel {lautester}" + ); + } + + /// Die Gegenprobe: Ohne Effekt bleibt ein zugezogener Kanal still. + #[test] + fn ohne_effekt_bleibt_der_zugezogene_kanal_still() { + let mut engine = Engine::new(RATE); + let kanal = engine.add_channel( + "A", + Box::new(LoopSource::new(sine_stereo(440.0, RATE as u32, 0.02))), + ); + engine.channel(kanal).set_assign(Assign::Thru); + engine.channel(kanal).set_fader(0.0); + + let mut block = vec![0.0f32; 2_048]; + engine.render(&mut block, 2); + assert!(rms(&block) < 1e-6, "stummer Kanal ist nicht still"); + } + + /// Der Kopfhörer greift **vor** den Effekten ab. + /// + /// Man bereitet den nächsten Track im Kopfhörer vor; ein Effekt, den man + /// für die Anlage eingestellt hat, gehört dort nicht hinein. + #[test] + fn der_kopfhoerer_hoert_die_effekte_nicht() { + use crate::effects::Effekt; + + // Zwei gleich aufgebaute Mixer, gleich weit gerendert — sonst + // vergliche man zwei verschiedene Stellen derselben Schleife, und der + // Unterschied käme vom Signal statt vom Effekt. + fn bauen(mit_effekt: bool) -> f32 { + let mut engine = Engine::new(RATE); + let kanal = engine.add_channel( + "A", + Box::new(LoopSource::new(sine_stereo(440.0, RATE as u32, 0.05))), + ); + engine.channel(kanal).set_cue(true); + engine.channel(kanal).set_fader(0.0); + + if mit_effekt { + let fx = engine.channel(kanal).fx(); + fx.set_effekt(Effekt::Gater); + fx.set_mix(1.0); + // Fast immer zu — wirkte das auf den Cue-Bus, wäre es nicht + // zu übersehen. + fx.set_amount(0.0); + fx.set_zeit(0.01); + } + + let mut block = vec![0.0f32; 4 * 1_024]; + let mut summe = 0.0; + for _ in 0..8 { + block.fill(0.0); + engine.render(&mut block, 4); + summe += rms_channel(&block, 2, 4); + } + summe / 8.0 + } + + let ohne = bauen(false); + let mit = bauen(true); + + assert!(ohne > 0.05, "der Cue-Bus ist von vornherein still: {ohne}"); + assert!( + (ohne - mit).abs() / ohne < 0.01, + "der Gater wirkt bis in den Kopfhörer: {ohne:.4} gegen {mit:.4}" + ); + } +} diff --git a/crates/audio-engine/src/output.rs b/crates/audio-engine/src/output.rs new file mode 100644 index 0000000..7af2baa --- /dev/null +++ b/crates/audio-engine/src/output.rs @@ -0,0 +1,175 @@ +//! Ausgabe der Engine auf ein Audiogerät. +//! +//! Sucht eine Gerätekonfiguration mit vier Ausgängen, damit der Cue-Bus auf +//! 3/4 landet. Gibt es die nicht, läuft die Software trotzdem — dann eben ohne +//! Vorhören, und sie sagt das auch. + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{FromSample, SampleFormat, SizedSample, StreamConfig}; + +use crate::command::EngineRunner; +use crate::mixer::Engine; + +/// Fehlschlag beim Öffnen — **mit** dem Runner zurück. +/// +/// Ohne ihn wäre der Mixer verloren, sobald kein Gerät da ist. Der Aufrufer +/// soll ihn weiterverwenden können, etwa für einen Leerlauf-Taktgeber. +/// +/// Wird als `Box` zurückgegeben: der Runner macht den Fehlerwert groß, und +/// jeder Erfolgsfall müsste ihn sonst mitschleppen. +pub struct OpenError { + pub message: String, + pub runner: EngineRunner, +} + +impl std::fmt::Display for OpenError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +pub struct Output { + device_name: String, + sample_rate: u32, + channels: usize, + _stream: cpal::Stream, +} + +impl Output { + /// Öffnet das Standardgerät und übergibt ihm den Mixer. + pub fn open(runner: EngineRunner) -> Result> { + macro_rules! fehler { + ($runner:expr, $($arg:tt)*) => { + return Err(Box::new(OpenError { message: format!($($arg)*), runner: $runner })) + }; + } + + let host = cpal::default_host(); + let Some(device) = host.default_output_device() else { + fehler!(runner, "kein Ausgabegerät gefunden"); + }; + let device_name = device + .id() + .map(|id| id.to_string()) + .unwrap_or_else(|_| "unbekannt".into()); + + let supported = match device.default_output_config() { + Ok(c) => c, + Err(e) => fehler!(runner, "Gerätekonfiguration nicht lesbar: {e}"), + }; + let sample_format = supported.sample_format(); + let config: StreamConfig = supported.into(); + + let sample_rate = config.sample_rate; + let channels = config.channels as usize; + + // Der Runner wandert in den Callback; scheitert das Öffnen, kommt er + // von dort zurück. + let gebaut = match sample_format { + SampleFormat::F32 => build::(&device, config, runner), + SampleFormat::I16 => build::(&device, config, runner), + SampleFormat::I32 => build::(&device, config, runner), + SampleFormat::U16 => build::(&device, config, runner), + SampleFormat::U8 => build::(&device, config, runner), + SampleFormat::F64 => build::(&device, config, runner), + other => fehler!(runner, "Sample-Format {other} nicht unterstützt"), + }; + + let stream = match gebaut { + Ok(s) => s, + Err(boxed) => { + let (e, runner) = *boxed; + fehler!(runner, "Stream ließ sich nicht öffnen: {e}") + } + }; + + if let Err(e) = stream.play() { + // Hier ist der Runner im Stream gefangen; der Stream wird verworfen. + return Err(Box::new(OpenError { + message: format!("Wiedergabe ließ sich nicht starten: {e}"), + runner: EngineRunner::leer(), + })); + } + + Ok(Output { + device_name, + sample_rate, + channels, + _stream: stream, + }) + } + + pub fn device_name(&self) -> &str { + &self.device_name + } + + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + pub fn channels(&self) -> usize { + self.channels + } + + /// Ob der Cue-Bus einen eigenen Ausgang hat. + pub fn has_cue_output(&self) -> bool { + self.channels >= Engine::REQUIRED_OUTPUTS_FOR_CUE + } +} + +#[allow(clippy::type_complexity)] +fn build( + device: &cpal::Device, + config: StreamConfig, + runner: EngineRunner, +) -> Result> +where + T: SizedSample + FromSample, +{ + let channels = config.channels as usize; + let mut block: Vec = Vec::new(); + + // Der Runner muss im Fehlerfall wieder heraus, also über eine Zelle, die + // der Callback leert und die wir sonst zurücknehmen. + let geteilt = std::sync::Arc::new(std::sync::Mutex::new(Some(runner))); + let fuer_callback = std::sync::Arc::clone(&geteilt); + let mut eigener: Option = None; + + let ergebnis = device.build_output_stream( + config, + move |data: &mut [T], _: &cpal::OutputCallbackInfo| { + // Einmalig übernehmen, danach nie wieder das Lock anfassen. + if eigener.is_none() { + eigener = fuer_callback.lock().ok().and_then(|mut g| g.take()); + } + let Some(runner) = eigener.as_mut() else { + data.fill(T::from_sample(0.0)); + return; + }; + + if block.len() < data.len() { + block.resize(data.len(), 0.0); + } + let scratch = &mut block[..data.len()]; + runner.render(scratch, channels); + + for (dst, src) in data.iter_mut().zip(scratch.iter()) { + *dst = T::from_sample(*src); + } + }, + |err| eprintln!("Audio-Stream: {err}"), + None, + ); + + match ergebnis { + Ok(stream) => Ok(stream), + Err(e) => { + let runner = geteilt + .lock() + .ok() + .and_then(|mut g| g.take()) + .unwrap_or_else(EngineRunner::leer); + Err(Box::new((e, runner))) + } + } +} diff --git a/crates/audio-engine/src/source.rs b/crates/audio-engine/src/source.rs new file mode 100644 index 0000000..e1c4115 --- /dev/null +++ b/crates/audio-engine/src/source.rs @@ -0,0 +1,101 @@ +//! Signalquellen eines Kanals. +//! +//! Der Mixer kennt keine Decks. Er kennt Quellen, die interleaved Stereo +//! liefern — ob dahinter ein Deck steht, ein Mikrofon am AUX-Eingang oder +//! später ein generierter Track, ist ihm gleich. +//! +//! Das ist dieselbe Trennung wie in `docs/ARCHITEKTUR.md`: austauschbare +//! Trackquellen, damit die Generierung sich später einhängen kann, ohne dass +//! am Mixer etwas anzufassen ist. + +use audio_core::deck::Voice; + +/// Liefert interleaved Stereo. Wird im Audio-Callback aufgerufen und darf +/// deshalb nicht allokieren, blockieren oder Speicher freigeben. +pub trait Source: Send { + fn render(&mut self, out: &mut [f32]); +} + +/// Ein Deck aus `audio-core`. +pub struct DeckSource { + voice: Voice, +} + +impl DeckSource { + pub fn new(voice: Voice) -> Self { + DeckSource { voice } + } +} + +impl Source for DeckSource { + fn render(&mut self, out: &mut [f32]) { + self.voice.render_stereo(out); + } +} + +/// Stille. Nützlich als Platzhalter für einen Kanal ohne geladenen Track. +pub struct SilentSource; + +impl Source for SilentSource { + fn render(&mut self, out: &mut [f32]) { + out.fill(0.0); + } +} + +/// Spielt einen festen Puffer in Schleife. Für Testaufbauten und Zuspieler. +pub struct LoopSource { + samples: Vec, + position: usize, +} + +impl LoopSource { + /// `samples` ist interleaved Stereo. + pub fn new(samples: Vec) -> Self { + LoopSource { + samples, + position: 0, + } + } +} + +impl Source for LoopSource { + fn render(&mut self, out: &mut [f32]) { + if self.samples.is_empty() { + out.fill(0.0); + return; + } + for sample in out.iter_mut() { + *sample = self.samples[self.position]; + self.position = (self.position + 1) % self.samples.len(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stille_ist_still() { + let mut out = vec![1.0; 64]; + SilentSource.render(&mut out); + assert!(out.iter().all(|v| *v == 0.0)); + } + + #[test] + fn schleife_wiederholt_sich() { + let mut source = LoopSource::new(vec![1.0, 2.0, 3.0, 4.0]); + let mut out = vec![0.0; 10]; + source.render(&mut out); + + assert_eq!(out, vec![1.0, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0, 4.0, 1.0, 2.0]); + } + + #[test] + fn leere_schleife_liefert_stille_statt_zu_paniken() { + let mut source = LoopSource::new(Vec::new()); + let mut out = vec![1.0; 8]; + source.render(&mut out); + assert!(out.iter().all(|v| *v == 0.0)); + } +} diff --git a/crates/audio-engine/src/svf.rs b/crates/audio-engine/src/svf.rs new file mode 100644 index 0000000..78a2312 --- /dev/null +++ b/crates/audio-engine/src/svf.rs @@ -0,0 +1,147 @@ +//! Zustandsvariablen-Filter (TPT, nach Andy Simper). +//! +//! Baustein für EQ und Filterknopf. Liefert Tief-, Band- und Hochpass aus +//! *einem* Durchlauf — genau das, was ein Crossover braucht, ohne das Signal +//! mehrfach durch getrennte Filter zu schicken. +//! +//! Die topologiebewahrende Form bleibt auch bei Parameteränderungen während +//! des Betriebs stabil. Das ist keine Feinheit: An einem DJ-Filterknopf wird +//! gedreht, während Audio läuft, und ein klassisches Biquad kann dabei knacken. + +use std::f32::consts::PI; + +#[derive(Debug, Clone, Copy)] +pub struct Outputs { + pub low: f32, + pub band: f32, + pub high: f32, +} + +#[derive(Debug, Clone, Copy)] +pub struct Svf { + ic1: f32, + ic2: f32, + a1: f32, + a2: f32, + a3: f32, + k: f32, +} + +impl Default for Svf { + fn default() -> Self { + Self::new() + } +} + +impl Svf { + pub fn new() -> Self { + let mut svf = Svf { + ic1: 0.0, + ic2: 0.0, + a1: 0.0, + a2: 0.0, + a3: 0.0, + k: 0.0, + }; + svf.set(1_000.0, std::f32::consts::FRAC_1_SQRT_2, 48_000.0); + svf + } + + /// Setzt Grenzfrequenz und Güte. `q` = 0.707 ergibt Butterworth. + pub fn set(&mut self, cutoff_hz: f32, q: f32, sample_rate: f32) { + let nyquist = sample_rate * 0.5; + let cutoff = cutoff_hz.clamp(10.0, nyquist * 0.99); + let g = (PI * cutoff / sample_rate).tan(); + let k = 1.0 / q.max(0.01); + + self.a1 = 1.0 / (1.0 + g * (g + k)); + self.a2 = g * self.a1; + self.a3 = g * self.a2; + self.k = k; + } + + pub fn reset(&mut self) { + self.ic1 = 0.0; + self.ic2 = 0.0; + } + + #[inline] + pub fn process(&mut self, input: f32) -> Outputs { + let v3 = input - self.ic2; + let v1 = self.a1 * self.ic1 + self.a2 * v3; + let v2 = self.ic2 + self.a2 * self.ic1 + self.a3 * v3; + + self.ic1 = 2.0 * v1 - self.ic1; + self.ic2 = 2.0 * v2 - self.ic2; + + Outputs { + low: v2, + band: v1, + high: input - self.k * v1 - v2, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{rms, sine}; + + const RATE: f32 = 48_000.0; + + fn durchlass(cutoff: f32, freq: f32, band: fn(Outputs) -> f32) -> f32 { + let signal = sine(freq, RATE as u32, 0.5); + let mut svf = Svf::new(); + svf.set(cutoff, std::f32::consts::FRAC_1_SQRT_2, RATE); + + // Erste Hälfte verwerfen, damit der Einschwingvorgang draußen bleibt. + let out: Vec = signal.iter().map(|x| band(svf.process(*x))).collect(); + rms(&out[out.len() / 2..]) / rms(&signal[signal.len() / 2..]) + } + + #[test] + fn tiefpass_laesst_tiefes_durch_und_sperrt_hohes() { + let durch = durchlass(1_000.0, 100.0, |o| o.low); + let gesperrt = durchlass(1_000.0, 10_000.0, |o| o.low); + + assert!(durch > 0.9, "100 Hz nur bei {durch:.3} durchgelassen"); + assert!(gesperrt < 0.05, "10 kHz noch bei {gesperrt:.3}"); + } + + #[test] + fn hochpass_ist_das_spiegelbild() { + let gesperrt = durchlass(1_000.0, 100.0, |o| o.high); + let durch = durchlass(1_000.0, 10_000.0, |o| o.high); + + assert!(durch > 0.9, "10 kHz nur bei {durch:.3}"); + assert!(gesperrt < 0.05, "100 Hz noch bei {gesperrt:.3}"); + } + + #[test] + fn an_der_grenzfrequenz_bleibt_die_haelfte_der_leistung() { + let durch = durchlass(1_000.0, 1_000.0, |o| o.low); + // −3 dB entspricht Faktor 0.707. + assert!( + (durch - 0.707).abs() < 0.05, + "bei fc gemessen: {durch:.3}, erwartet ~0.707" + ); + } + + #[test] + fn bleibt_bei_extremen_parametern_endlich() { + let mut svf = Svf::new(); + for cutoff in [0.0, 1.0, 24_000.0, 1e9] { + for q in [0.0, 0.1, 100.0] { + svf.set(cutoff, q, RATE); + svf.reset(); + for i in 0..1_000 { + let o = svf.process(if i % 2 == 0 { 1.0 } else { -1.0 }); + assert!( + o.low.is_finite() && o.band.is_finite() && o.high.is_finite(), + "divergiert bei cutoff={cutoff}, q={q}" + ); + } + } + } + } +} diff --git a/crates/audio-engine/src/sync.rs b/crates/audio-engine/src/sync.rs new file mode 100644 index 0000000..e71ff4d --- /dev/null +++ b/crates/audio-engine/src/sync.rs @@ -0,0 +1,274 @@ +//! Sync: zwei Decks auf Tempo *und* Phase bringen. +//! +//! Der Teil, den man leicht halbiert. Gleiches Tempo genügt nicht — zwei Decks +//! mit exakt 128 BPM, deren Beats aber um eine Achtel versetzt liegen, klingen +//! genauso falsch wie zwei mit verschiedenem Tempo. Sync ist deshalb immer +//! zweierlei: +//! +//! 1. **Tempo** — der Pitchfader des Slaves wird so gesetzt, dass sein +//! effektives Tempo dem des Masters entspricht. +//! 2. **Phase** — der Slave springt auf die Beat-Lage des Masters. +//! +//! Danach halten beide von selbst zusammen: Ist das effektive Tempo gleich, +//! läuft auch die Phase gleich schnell weiter. Es braucht keine fortlaufende +//! Nachregelung, solange niemand am Pitchfader dreht. +//! +//! ## Warum gesprungen und nicht geschoben wird +//! +//! Ein Sprung ist hörbar, wenn er groß ist. Weichere Verfahren ziehen das Tempo +//! kurz an, bis die Phase stimmt. Das ist angenehmer, aber auch träger, und für +//! den ersten Aufbau ist der Sprung ehrlicher: Er stimmt sofort und exakt. +//! Der Sprung nimmt immer den kürzeren Weg, höchstens eine halbe Beat-Länge. + +use audio_core::deck::DeckState; +use audio_core::grid::shortest_phase_delta; + +/// Was am Slave-Deck zu tun ist. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SyncPlan { + /// Neuer Wert für den Pitchfader des Slaves. + pub tempo: f32, + /// Zielposition in Quell-Frames, wenn die Phase korrigiert werden muss. + pub seek_to: Option, + /// Wie weit die Phase danebenlag, in Beats (−0.5 … 0.5). + pub phase_error_beats: f64, +} + +/// Berechnet den Plan, ohne etwas zu verändern. +/// +/// `None`, wenn einem der beiden Decks das Beatgrid fehlt — ohne Grid gibt es +/// weder Tempo noch Phase, auf die man synchronisieren könnte. +pub fn plan(master: &DeckState, slave: &DeckState, sample_rate: u32) -> Option { + let master_grid = master.grid()?; + let slave_grid = slave.grid()?; + + let master_bpm = master_grid.bpm * master.tempo(); + let tempo = master_bpm / slave_grid.bpm; + if !tempo.is_finite() || tempo <= 0.0 { + return None; + } + + let master_pos = master.position_frames() as f64; + let slave_pos = slave.position_frames() as f64; + + let master_phase = master_grid.phase_at(master_pos, sample_rate); + let slave_phase = slave_grid.phase_at(slave_pos, sample_rate); + let delta = shortest_phase_delta(slave_phase, master_phase); + + let ziel = slave_pos + delta * slave_grid.frames_per_beat(sample_rate); + let seek_to = (ziel >= 0.0).then_some(ziel.round() as u64); + + Some(SyncPlan { + tempo, + seek_to, + phase_error_beats: delta, + }) +} + +/// Berechnet und wendet an. Gibt zurück, was getan wurde. +pub fn sync(master: &DeckState, slave: &DeckState, sample_rate: u32) -> Option { + let plan = plan(master, slave, sample_rate)?; + + slave.set_tempo(plan.tempo); + if let Some(frame) = plan.seek_to { + slave.seek_frames(frame); + } + + Some(plan) +} + +/// Nur das Tempo angleichen, ohne Sprung. +/// +/// Für den Fall, dass man selbst einschieben will — das Tempo abzunehmen ist +/// die mühsame Arbeit, die Phase trifft man von Hand. +pub fn sync_tempo_only(master: &DeckState, slave: &DeckState) -> Option { + let master_bpm = master.effective_bpm()?; + let slave_grid = slave.grid()?; + + let tempo = master_bpm / slave_grid.bpm; + if !tempo.is_finite() || tempo <= 0.0 { + return None; + } + + slave.set_tempo(tempo); + Some(tempo) +} + +/// Aktueller Phasenversatz in Beats, −0.5 bis 0.5. Für Anzeigen und Tests. +pub fn phase_error(master: &DeckState, slave: &DeckState, sample_rate: u32) -> Option { + let master_phase = master.beat_phase(sample_rate)?; + let slave_phase = slave.beat_phase(sample_rate)?; + Some(shortest_phase_delta(slave_phase, master_phase)) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use audio_core::deck::Voice; + use audio_core::{Beatgrid, Track}; + + use super::*; + use crate::testing::sine_stereo; + + const RATE: u32 = 48_000; + + fn deck(bpm: f32, anchor: u64, secs: f32) -> (Voice, Arc) { + let track = Arc::new(Track { + samples: sine_stereo(220.0, RATE, secs), + sample_rate: RATE, + stems: Vec::new(), + }); + let state = Arc::new(DeckState::new()); + state.set_grid(Some(Beatgrid::new(bpm, anchor, 1.0))); + state.set_playing(true); + (Voice::new(track, Arc::clone(&state)), state) + } + + #[test] + fn tempo_wird_angeglichen() { + let (_a, master) = deck(128.0, 0, 5.0); + let (_b, slave) = deck(124.0, 0, 5.0); + + let plan = sync(&master, &slave, RATE).expect("kein Plan"); + + assert!( + (plan.tempo - 128.0 / 124.0).abs() < 1e-6, + "Tempofaktor {:.5}", + plan.tempo + ); + assert!( + (slave.effective_bpm().unwrap() - 128.0).abs() < 0.01, + "effektives Tempo {:?}", + slave.effective_bpm() + ); + } + + #[test] + fn der_pitchfader_des_masters_zaehlt_mit() { + let (_a, master) = deck(128.0, 0, 5.0); + let (_b, slave) = deck(128.0, 0, 5.0); + + master.set_tempo(1.05); + sync(&master, &slave, RATE); + + assert!( + (slave.tempo() - 1.05).abs() < 1e-5, + "Slave folgt dem Pitchfader nicht: {:.4}", + slave.tempo() + ); + } + + #[test] + fn die_phase_wird_korrigiert() { + let (mut a, master) = deck(120.0, 0, 30.0); + let (mut b, slave) = deck(120.0, 0, 30.0); + + // Slave um eine Viertel-Beat-Länge versetzen. + let viertel = (60.0 / 120.0 * RATE as f64 * 0.25) as u64; + slave.seek_frames(viertel); + + let mut out = vec![0.0; 1_024 * 2]; + a.render_stereo(&mut out); + b.render_stereo(&mut out); + + let plan = sync(&master, &slave, RATE).expect("kein Plan"); + assert!(plan.seek_to.is_some(), "kein Sprung geplant"); + + b.render_stereo(&mut out); + a.render_stereo(&mut out); + + let rest = phase_error(&master, &slave, RATE).unwrap().abs(); + assert!( + rest < 0.01, + "nach Sync noch {rest:.4} Beats daneben (vorher {:.4})", + plan.phase_error_beats + ); + } + + #[test] + fn der_sprung_nimmt_den_kurzen_weg() { + let (_a, master) = deck(120.0, 0, 30.0); + let (mut b, slave) = deck(120.0, 0, 30.0); + + // Slave liegt bei Phase 0.9, Master bei 0.0 — vorwärts sind es 0.1, + // rückwärts 0.9. Der Plan muss den kurzen Weg nehmen. + let fast_ganz = (60.0 / 120.0 * RATE as f64 * 0.9) as u64; + slave.seek_frames(fast_ganz); + + // `seek_frames` ist nur eine Anforderung; ausgeführt wird sie im + // Audio-Thread. Ohne einen Renderdurchlauf steht die Position noch + // auf dem alten Wert und der Plan liefe ins Leere. + let mut out = vec![0.0; 256 * 2]; + b.render_stereo(&mut out); + + let plan = plan(&master, &slave, RATE).expect("kein Plan"); + assert!( + plan.phase_error_beats.abs() <= 0.5, + "Sprung über eine halbe Beat-Länge: {:.3}", + plan.phase_error_beats + ); + assert!( + plan.phase_error_beats > 0.0, + "Sprung geht in die falsche Richtung: {:.3}", + plan.phase_error_beats + ); + } + + #[test] + fn zwei_decks_bleiben_ueber_minuten_im_takt() { + // Das Abnahmekriterium aus docs/PLAN.md, Phase 4. + let (mut a, master) = deck(128.0, 0, 400.0); + let (mut b, slave) = deck(124.0, 7_000, 400.0); + + sync(&master, &slave, RATE); + + let block = 4_096; + let mut out_a = vec![0.0; block * 2]; + let mut out_b = vec![0.0; block * 2]; + + let bloecke = (300.0 * RATE as f64 / block as f64) as usize; + for _ in 0..bloecke { + a.render_stereo(&mut out_a); + b.render_stereo(&mut out_b); + } + + let fehler = phase_error(&master, &slave, RATE).unwrap().abs(); + let ms = fehler * 60.0 / 128.0 * 1_000.0; + + assert!( + ms < 5.0, + "nach 5 Minuten {ms:.2} ms auseinander ({fehler:.5} Beats)" + ); + } + + #[test] + fn ohne_grid_gibt_es_keinen_sync() { + let (_a, master) = deck(128.0, 0, 5.0); + let (_b, slave) = deck(124.0, 0, 5.0); + + slave.set_grid(None); + assert!(plan(&master, &slave, RATE).is_none()); + assert!(sync(&master, &slave, RATE).is_none()); + assert!(sync_tempo_only(&master, &slave).is_none()); + assert!(phase_error(&master, &slave, RATE).is_none()); + } + + #[test] + fn nur_tempo_springt_nicht() { + let (_a, master) = deck(128.0, 0, 30.0); + let (_b, slave) = deck(124.0, 0, 30.0); + + slave.seek_frames(12_345); + let vorher = slave.position_frames(); + + let tempo = sync_tempo_only(&master, &slave).expect("kein Tempo"); + + assert!((tempo - 128.0 / 124.0).abs() < 1e-6); + assert_eq!( + slave.position_frames(), + vorher, + "sync_tempo_only hat die Position verändert" + ); + } +} diff --git a/crates/audio-engine/src/testing.rs b/crates/audio-engine/src/testing.rs new file mode 100644 index 0000000..75b8cd5 --- /dev/null +++ b/crates/audio-engine/src/testing.rs @@ -0,0 +1,37 @@ +//! Testsignale und Messhilfen für die Engine. + +pub fn sine(freq: f32, sample_rate: u32, secs: f32) -> Vec { + let n = (sample_rate as f32 * secs) as usize; + let step = 2.0 * std::f32::consts::PI * freq / sample_rate as f32; + (0..n).map(|i| (step * i as f32).sin()).collect() +} + +/// Interleaved Stereo, beide Kanäle gleich. +pub fn sine_stereo(freq: f32, sample_rate: u32, secs: f32) -> Vec { + sine(freq, sample_rate, secs) + .into_iter() + .flat_map(|v| [v, v]) + .collect() +} + +pub fn rms(values: &[f32]) -> f32 { + if values.is_empty() { + return 0.0; + } + (values.iter().map(|v| (v * v) as f64).sum::() / values.len() as f64).sqrt() as f32 +} + +/// Effektivwert eines Kanals aus interleaved Stereo. +pub fn rms_channel(interleaved: &[f32], channel: usize, channels: usize) -> f32 { + let values: Vec = interleaved + .iter() + .skip(channel) + .step_by(channels) + .copied() + .collect(); + rms(&values) +} + +pub fn peak(values: &[f32]) -> f32 { + values.iter().fold(0.0f32, |m, v| m.max(v.abs())) +} diff --git a/crates/control/Cargo.toml b/crates/control/Cargo.toml new file mode 100644 index 0000000..e6b4406 --- /dev/null +++ b/crates/control/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "control" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +audio-core = { version = "0.1.0", path = "../audio-core" } +audio-engine = { version = "0.1.0", path = "../audio-engine" } +getrandom = "0.3.4" diff --git a/crates/control/src/bogen.rs b/crates/control/src/bogen.rs new file mode 100644 index 0000000..cc097f7 --- /dev/null +++ b/crates/control/src/bogen.rs @@ -0,0 +1,353 @@ +//! Der Bogen: was das Set vorhat, bevor der nächste Track gewählt wird. +//! +//! Ein einzelner guter Übergang ist Handwerk. Ein gutes Set ist Architektur: +//! Aufbau, Plateau, Bruch, Wiederaufbau — über eine Stunde, nicht über vier +//! Minuten. Bisher konnte die Anlage jeden Übergang begründen und keine +//! Reihenfolge. +//! +//! **Für ein Team ist der Bogen das, worüber man sich einig sein muss.** Ohne +//! ihn verhandeln zwei Agenten über den nächsten Track ohne gemeinsamen +//! Maßstab: Der eine will Druck, der andere Luft, und beide haben recht, weil +//! es keinen Satz gibt, gegen den sich das prüfen ließe. Mit ihm heißt die +//! Frage nicht mehr „welcher Track ist gut", sondern „was fehlt hier gerade" — +//! und darauf gibt es eine Zahl. +//! +//! ```text +//! set master.arc 0 0.3, 20 0.7, 45 0.95, 60 0.5, 80 1.0 +//! do master.arc_start +//! get master.arc_gap → value master.arc_gap 0.250000 +//! ``` +//! +//! Die Zeiten sind **Minuten** — ein Set dauert Stunden, keine Sekunden. Wer +//! genauer will, schreibt `m:ss`; `20:30` sind zwanzigeinhalb Minuten. +//! +//! # Woher die Ist-Energie kommt — und woher nicht +//! +//! **Nicht aus dem Pegel.** Die Gliederung misst je Abschnitt einen Pegel, aber +//! der ist auf den lautesten Abschnitt *desselben* Tracks bezogen: Der Drop +//! eines leisen Stücks steht dort genauso bei 0,99 wie der eines lauten. Über +//! Tracks hinweg ist das nicht vergleichbar, und ein Bogen, der solche Zahlen +//! addiert, rechnet mit Äpfeln. +//! +//! Stattdessen aus der **Art** des laufenden Abschnitts. Ein Drop ist ein Drop, +//! ob leise produziert oder laut. Das ist grob — sechs Stufen, mehr nicht — und +//! grob ist hier ehrlicher als eine Nachkommastelle, die niemand einlösen kann. +//! Wer es feiner braucht, misst den Raum und gibt ihn als Signal herein. + +use audio_core::Art; + +/// Wie viel Energie eine Abschnittsart bedeutet. +/// +/// Eine grobe Leiter, keine Messung. Sie beantwortet die einzige Frage, die +/// sich über Tracks hinweg stellen lässt: Ist das hier gerade ein Höhepunkt, +/// eine Atempause oder etwas dazwischen? +pub fn energie(art: Art) -> f64 { + match art { + Art::Intro => 0.2, + Art::Aufbau => 0.55, + Art::Drop => 0.9, + // Ein Break ist nicht so leer wie ein Intro: Die Drums laufen meist + // weiter, und der Raum bleibt wach. + Art::Break => 0.35, + Art::Outro => 0.2, + Art::Teil => 0.6, + } +} + +/// Wie viel Energie ein ganzer Track trägt. +/// +/// Der Mittelwert über die Abschnitte, **nach Länge gewichtet**: Ein Track mit +/// einem kurzen Drop und langem Outro ist ruhiger als einer, bei dem es +/// umgekehrt ist, und ein ungewichteter Mittelwert könnte beide nicht +/// unterscheiden. +/// +/// # Was diese Zahl **nicht** ist +/// +/// Sie ist **kein Maß für Lautstärke oder Härte.** Die Abschnittsarten werden +/// je Track gegen dessen *eigene* Quantile benannt — der Drop eines leisen +/// Stücks heißt genauso „Drop" wie der eines lauten. Was hier herauskommt, ist +/// deshalb: *wie viel seiner Länge ein Track auf seinem eigenen Höhepunkt +/// verbringt.* +/// +/// Das ist trotzdem über Tracks hinweg vergleichbar und für den Aufbau eines +/// Sets die brauchbarere Größe: Ein Peak-Track sitzt die meiste Zeit oben, ein +/// Warmup-Track baut die meiste Zeit auf. Wer aber „welches Stück ist +/// härter?" fragt, bekommt hier keine Antwort — und soll auch keine bekommen, +/// statt einer erfundenen. +/// +/// `None` bei einer Gliederung ohne Abschnitte — dann ist über den Track +/// nichts bekannt, und das ist etwas anderes als „mittlere Energie". +pub fn energie_des_tracks(struktur: &audio_core::struktur::Struktur) -> Option { + let mut summe = 0.0; + let mut laenge = 0.0; + for a in &struktur.abschnitte { + let frames = a.bis_frames.saturating_sub(a.von_frames) as f64; + if frames <= 0.0 { + continue; + } + summe += energie(a.art) * frames; + laenge += frames; + } + (laenge > 0.0).then(|| summe / laenge) +} + +/// Ein Stützpunkt: zu dieser Minute so viel Energie. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Punkt { + pub minute: f64, + pub energie: f64, +} + +/// Die Ziel-Energiekurve über die Setdauer. +/// +/// Zwischen den Stützpunkten wird geradlinig verbunden — nicht weil das +/// musikalisch stimmt, sondern weil alles andere eine Genauigkeit vortäuschte, +/// die eine von Hand gesetzte Kurve nicht hat. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Bogen { + punkte: Vec, +} + +impl Bogen { + pub fn neu() -> Bogen { + Bogen::default() + } + + pub fn ist_leer(&self) -> bool { + self.punkte.is_empty() + } + + pub fn punkte(&self) -> &[Punkt] { + &self.punkte + } + + /// Wie lang der Bogen ist, in Minuten. + pub fn dauer(&self) -> f64 { + self.punkte.last().map(|p| p.minute).unwrap_or(0.0) + } + + /// Was der Bogen zu dieser Minute vorsieht. + /// + /// Vor dem ersten Punkt gilt der erste, nach dem letzten der letzte: Ein + /// Set, das länger läuft als geplant, soll nicht plötzlich auf null fallen. + pub fn soll(&self, minute: f64) -> Option { + let erster = self.punkte.first()?; + let letzter = self.punkte.last()?; + if minute <= erster.minute { + return Some(erster.energie); + } + if minute >= letzter.minute { + return Some(letzter.energie); + } + + let paar = self + .punkte + .windows(2) + .find(|w| minute >= w[0].minute && minute <= w[1].minute)?; + let (a, b) = (paar[0], paar[1]); + let spanne = b.minute - a.minute; + if spanne <= 0.0 { + return Some(b.energie); + } + let t = (minute - a.minute) / spanne; + Some(a.energie + (b.energie - a.energie) * t) + } + + /// Wohin es von hier aus geht, über die nächsten `voraus` Minuten. + pub fn verlauf(&self, minute: f64, voraus: f64) -> Option { + let jetzt = self.soll(minute)?; + let gleich = self.soll(minute + voraus)?; + Some(Verlauf::aus_differenz(gleich - jetzt)) + } +} + +/// Wohin der Bogen als Nächstes will. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verlauf { + Steigt, + Haelt, + Faellt, +} + +impl Verlauf { + /// Ab welcher Änderung eine Richtung eine ist. + /// + /// Unter einem Zwanzigstel ist es ein Plateau — und ein Plateau ist eine + /// Aussage, keine Verlegenheit: „hier bleibt es, wo es ist" beantwortet die + /// Frage nach dem nächsten Track genauso wie „hier geht es hoch". + pub const SCHWELLE: f64 = 0.05; + + pub fn aus_differenz(d: f64) -> Verlauf { + if d > Verlauf::SCHWELLE { + Verlauf::Steigt + } else if d < -Verlauf::SCHWELLE { + Verlauf::Faellt + } else { + Verlauf::Haelt + } + } + + pub fn name(&self) -> &'static str { + match self { + Verlauf::Steigt => "steigt", + Verlauf::Haelt => "haelt", + Verlauf::Faellt => "faellt", + } + } +} + +/// Liest einen Bogen aus Text: `0 0.3, 20 0.7, 60 0.5`. +/// +/// Zeiten in **Minuten**, wahlweise als `m:ss`. Die Punkte werden sortiert — eine Zeile, +/// in der sie durcheinandergeraten sind, würde sonst still falsch +/// interpoliert. +pub fn parse(text: &str) -> Result { + let mut punkte = Vec::new(); + for stueck in text.split(',') { + let stueck = stueck.trim(); + if stueck.is_empty() { + continue; + } + let (zeit, energie) = stueck + .split_once(char::is_whitespace) + .ok_or_else(|| format!("{stueck} ist kein Punkt: ' ' erwartet"))?; + + let minute = zeit_lesen(zeit.trim())?; + let energie: f64 = energie + .trim() + .parse() + .map_err(|_| format!("{energie} ist keine Energie zwischen 0 und 1"))?; + if !(0.0..=1.0).contains(&energie) { + return Err(format!("{energie} liegt nicht zwischen 0 und 1")); + } + if minute < 0.0 { + return Err(format!("{zeit} liegt vor dem Anfang")); + } + punkte.push(Punkt { minute, energie }); + } + + if punkte.len() < 2 { + return Err("ein Bogen braucht mindestens zwei Punkte".into()); + } + punkte.sort_by(|a, b| a.minute.total_cmp(&b.minute)); + Ok(Bogen { punkte }) +} + +fn zeit_lesen(text: &str) -> Result { + match text.split_once(':') { + Some((min, sek)) => { + let min: f64 = min.parse().map_err(|_| format!("{text} ist keine Zeit"))?; + let sek: f64 = sek.parse().map_err(|_| format!("{text} ist keine Zeit"))?; + Ok(min + sek / 60.0) + } + None => text.parse().map_err(|_| format!("{text} ist keine Zeit")), + } +} + +/// Schreibt einen Bogen so, wie `parse` ihn wieder liest. +pub fn text(bogen: &Bogen) -> String { + bogen + .punkte + .iter() + .map(|p| { + let minuten = p.minute.floor(); + let sekunden = ((p.minute - minuten) * 60.0).round(); + format!("{minuten:.0}:{sekunden:02.0} {:.2}", p.energie) + }) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn beispiel() -> Bogen { + parse("0 0.3, 20 0.7, 45 0.95, 60 0.5").expect("lesbar") + } + + #[test] + fn ein_bogen_wird_gelesen_und_wieder_geschrieben() { + let b = beispiel(); + assert_eq!(b.punkte().len(), 4); + assert_eq!(b.dauer(), 60.0); + assert_eq!(text(&b), "0:00 0.30, 20:00 0.70, 45:00 0.95, 60:00 0.50"); + // Und `m:ss` wird genauso gelesen: zwanzigeinhalb Minuten. + assert_eq!(parse("0 0.3, 20:30 0.7").unwrap().dauer(), 20.5); + assert_eq!(parse(&text(&b)).unwrap(), b); + } + + #[test] + fn zwischen_den_punkten_wird_geradlinig_verbunden() { + let b = beispiel(); + assert_eq!(b.soll(0.0), Some(0.3)); + assert_eq!(b.soll(20.0), Some(0.7)); + // Auf halbem Weg zwischen 0 und 20. + assert!((b.soll(10.0).unwrap() - 0.5).abs() < 1e-9); + } + + /// Ein Set, das länger läuft als geplant, fällt nicht plötzlich auf null. + #[test] + fn vor_dem_anfang_und_nach_dem_ende_gilt_der_rand() { + let b = beispiel(); + assert_eq!(b.soll(-5.0), Some(0.3)); + assert_eq!(b.soll(999.0), Some(0.5)); + } + + #[test] + fn der_verlauf_sagt_wohin_es_geht() { + let b = beispiel(); + assert_eq!(b.verlauf(0.0, 5.0), Some(Verlauf::Steigt)); + assert_eq!(b.verlauf(50.0, 5.0), Some(Verlauf::Faellt)); + // Am Ende geht es nirgendwohin mehr. + assert_eq!(b.verlauf(90.0, 5.0), Some(Verlauf::Haelt)); + } + + /// Ein Plateau ist eine Aussage, keine Verlegenheit. + #[test] + fn ein_flacher_abschnitt_haelt() { + let b = parse("0 0.7, 30 0.72, 60 0.9").unwrap(); + assert_eq!(b.verlauf(5.0, 5.0), Some(Verlauf::Haelt)); + } + + /// Durcheinandergeratene Zeiten würden sonst still falsch interpoliert. + #[test] + fn die_punkte_werden_sortiert() { + let b = parse("60 0.5, 0 0.3, 20 0.7").unwrap(); + assert_eq!(b.punkte()[0].minute, 0.0); + assert_eq!(b.punkte()[2].minute, 60.0); + } + + /// Unsinn wird gemeldet, nicht verschluckt — sonst führe ein Set gegen eine + /// Kurve, die niemand gemeint hat. + #[test] + fn unsinn_wird_gemeldet() { + assert!(parse("").is_err(), "leer"); + assert!(parse("0 0.3").is_err(), "ein Punkt ist kein Bogen"); + assert!(parse("0 0.3, 20 1.5").is_err(), "Energie über 1"); + assert!(parse("0 0.3, 20 -0.1").is_err(), "Energie unter 0"); + assert!(parse("0 0.3, gleich 0.7").is_err(), "keine Zeit"); + assert!(parse("0, 20 0.7").is_err(), "keine Energie"); + } + + #[test] + fn ein_leerer_bogen_sagt_nichts_statt_null() { + let b = Bogen::neu(); + assert!(b.ist_leer()); + assert_eq!(b.soll(10.0), None); + assert_eq!(b.verlauf(10.0, 5.0), None); + } + + /// Die Leiter der Abschnittsarten: grob, aber über Tracks hinweg + /// vergleichbar — anders als der Pegel. + #[test] + fn die_energieleiter_ordnet_die_abschnitte() { + assert!(energie(Art::Drop) > energie(Art::Aufbau)); + assert!(energie(Art::Aufbau) > energie(Art::Break)); + assert!(energie(Art::Break) > energie(Art::Intro)); + assert_eq!(energie(Art::Intro), energie(Art::Outro)); + for art in Art::ALLE { + assert!((0.0..=1.0).contains(&energie(art)), "{}", art.name()); + } + } +} diff --git a/crates/control/src/ereignis.rs b/crates/control/src/ereignis.rs new file mode 100644 index 0000000..8f750ec --- /dev/null +++ b/crates/control/src/ereignis.rs @@ -0,0 +1,186 @@ +//! Was geschehen ist, ohne dass jemand danach gefragt hat. +//! +//! **Der Fund, mit dem P2 anfing.** Der Zeitplan meldet, wenn eine Rampe fertig +//! ist, abgebrochen wird oder — der interessante Fall — *abgelöst*, weil jemand +//! anders denselben Regler angefasst hat. Diese Meldungen gab der Taktgeber +//! zurück, und der Thread, der ihn aufruft, warf sie weg. Für einen einzelnen +//! Bediener fiel das nie auf: Er *war* derjenige, der den Regler angefasst hat. +//! +//! Für ein Team ist es der schlimmste denkbare Zustand. Zwei Agenten greifen +//! nach demselben Fader, einer verliert — und erfährt nichts. Er hält seine +//! Blende weiter für laufend, plant darauf auf, und der nächste Griff sitzt auf +//! einer Annahme, die seit zwanzig Sekunden falsch ist. +//! +//! Hier liegen diese Meldungen, bis jemand sie abholt. +//! +//! # Warum ein Ring mit Nummern und kein Wert +//! +//! Ein Control `master.last_event` wäre einfacher gewesen und falsch: Der +//! Taktgeber läuft alle 5 ms, der Server vergleicht alle 50 ms. Zwischen zwei +//! Blicken passen zehn Ereignisse, und neun davon wären weg — ausgerechnet in +//! dem Moment, in dem viel gleichzeitig geschieht, also genau dann, wenn man +//! sie braucht. +//! +//! Deshalb eine laufende Nummer je Zeile. Wer seine letzte kennt, bekommt alles +//! seither. Und wer zu langsam liest, **erfährt das**: Der Ring ist endlich, und +//! was herausfällt, wird gezählt statt verschwiegen — dieselbe Regel wie bei den +//! verworfenen Frames im Mitschnitt und den unlesbaren Zeilen der Mitschrift. +//! Eine Lücke, die aussieht wie keine, ist das schlechteste von allem. + +use std::collections::VecDeque; + +/// Wie viele Zeilen aufgehoben werden. +/// +/// Bei 5 ms Takt sind 64 Zeilen gut drei Sekunden dichtes Geschehen — mehr, als +/// zwischen zwei Blicken eines Abonnenten (50 ms) je anfällt, und wenig genug, +/// dass niemand hier ein Protokoll suchen will. Wer alles will, liest die +/// Mitschrift. +pub const PLATZ: usize = 64; + +/// Ereignisse mit laufender Nummer. +#[derive(Debug, Default)] +pub struct Ereignisse { + zeilen: VecDeque<(u64, String)>, + /// Die zuletzt vergebene Nummer. 0 heißt: noch nichts geschehen. + nummer: u64, +} + +impl Ereignisse { + pub fn neu() -> Ereignisse { + Ereignisse::default() + } + + /// Die zuletzt vergebene Nummer. + /// + /// Wer hier einsteigt, merkt sie sich und bekommt danach alles Neue — die + /// Vergangenheit ausdrücklich nicht. Ein Bediener, der sich gerade + /// verbindet, soll nicht die Ablösungen von vor zehn Minuten nachgereicht + /// bekommen. + pub fn nummer(&self) -> u64 { + self.nummer + } + + pub fn letzte(&self) -> Option<&str> { + self.zeilen.back().map(|(_, z)| z.as_str()) + } + + pub fn ist_leer(&self) -> bool { + self.zeilen.is_empty() + } + + /// Trägt ein Ereignis ein. + /// + /// Mehrzeiliges wird auf die erste Zeile gekürzt: Der Ring soll Bewegungen + /// festhalten, keine Trefferlisten. + pub fn melden(&mut self, zeile: &str) { + let Some(erste) = zeile.lines().find(|z| !z.trim().is_empty()) else { + return; + }; + self.nummer += 1; + self.zeilen + .push_back((self.nummer, erste.trim().to_string())); + while self.zeilen.len() > PLATZ { + self.zeilen.pop_front(); + } + } + + /// Alles, was nach `seit` dazugekommen ist. + /// + /// Zweiter Wert: wie viele Zeilen dazwischen aus dem Ring gefallen sind. + /// Über null heißt, dass der Leser zu langsam war — und das gehört ihm + /// gesagt, statt ihn glauben zu lassen, es sei nichts passiert. + pub fn seit(&self, seit: u64) -> (Vec, usize) { + let verloren = match self.zeilen.front() { + Some((aelteste, _)) if *aelteste > seit + 1 => (aelteste - seit - 1) as usize, + _ => 0, + }; + let neue = self + .zeilen + .iter() + .filter(|(n, _)| *n > seit) + .map(|(_, z)| z.clone()) + .collect(); + (neue, verloren) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mit(zeilen: &[&str]) -> Ereignisse { + let mut e = Ereignisse::neu(); + for z in zeilen { + e.melden(z); + } + e + } + + #[test] + fn wer_seine_nummer_kennt_bekommt_alles_seither() { + let e = mit(&["plan 1 fertig", "plan 2 abgeloest", "plan 3 fertig"]); + assert_eq!(e.nummer(), 3); + + let (neue, verloren) = e.seit(1); + assert_eq!(neue, vec!["plan 2 abgeloest", "plan 3 fertig"]); + assert_eq!(verloren, 0); + + assert_eq!(e.seit(3).0.len(), 0, "nichts Neues seit der letzten"); + } + + /// Wer sich verbindet, bekommt nicht die Vergangenheit nachgereicht. + #[test] + fn wer_jetzt_einsteigt_bekommt_nur_neues() { + let mut e = mit(&["plan 1 fertig", "plan 2 fertig"]); + let einstieg = e.nummer(); + e.melden("plan 3 abgeloest channel1.fader"); + + assert_eq!(e.seit(einstieg).0, vec!["plan 3 abgeloest channel1.fader"]); + } + + /// Der Ring ist endlich. Was herausfällt, wird **gezählt** — eine Lücke, + /// die aussieht wie keine, wäre das schlechteste von allem. + #[test] + fn was_herausfaellt_wird_gezaehlt_statt_verschwiegen() { + let mut e = Ereignisse::neu(); + for i in 0..PLATZ + 10 { + e.melden(&format!("plan {i} fertig")); + } + + let (neue, verloren) = e.seit(0); + assert_eq!(neue.len(), PLATZ); + assert_eq!(verloren, 10, "die Lücke wurde verschwiegen"); + + // Wer mitgekommen ist, verliert nichts. + let (_, verloren) = e.seit(e.nummer() - 1); + assert_eq!(verloren, 0); + } + + #[test] + fn der_ring_waechst_nicht_unbegrenzt() { + let mut e = Ereignisse::neu(); + for i in 0..1_000 { + e.melden(&format!("plan {i} fertig")); + } + assert_eq!(e.zeilen.len(), PLATZ); + assert_eq!(e.nummer(), 1_000); + } + + #[test] + fn eine_leere_meldung_bekommt_keine_nummer() { + let mut e = Ereignisse::neu(); + e.melden(" \n "); + assert_eq!(e.nummer(), 0); + assert!(e.ist_leer()); + } + + /// Mehrzeiliges wird gekürzt — der Ring hält Bewegungen fest, keine + /// Trefferlisten. + #[test] + fn mehrzeiliges_wird_auf_die_erste_zeile_gekuerzt() { + let mut e = Ereignisse::neu(); + e.melden("ok 3 Treffer\neins\nzwei"); + assert_eq!(e.letzte(), Some("ok 3 Treffer")); + assert_eq!(e.nummer(), 1); + } +} diff --git a/crates/control/src/katalog.rs b/crates/control/src/katalog.rs new file mode 100644 index 0000000..ac60657 --- /dev/null +++ b/crates/control/src/katalog.rs @@ -0,0 +1,1024 @@ +//! Was es gibt — und was es bedeutet. +//! +//! Das ist der Teil, den Mixxx nicht hat. Dort ist die Liste der Controls +//! *Dokumentation*: Sie steht im Handbuch, und wer von außen steuern will, +//! liest sie und tippt die Namen ab. Läuft das Programm, lässt sich nicht +//! erfragen, was es kann. +//! +//! Hier trägt jedes Control seinen Bereich, seine Einheit, seinen Standardwert +//! und einen Satz zur Bedeutung mit sich. Ein Agent, ein Controller-Mapping +//! oder ein Skript kann das Pult zur Laufzeit **fragen**, statt es zu wissen. +//! Genau darauf setzt später die MCP-Schicht auf: Werkzeugbeschreibungen lassen +//! sich daraus erzeugen, statt sie von Hand doppelt zu pflegen. +//! +//! Die Namen sind englisch, obwohl der Rest des Codes deutsch kommentiert ist. +//! Sie sind die Schnittstelle nach außen, und die spricht die Sprache, die +//! Controller-Mappings und Agenten ohnehin verwenden. + +use crate::wert::{Art, Einheit}; + +#[derive(Debug, Clone)] +pub struct Beschreibung { + pub element: &'static str, + pub art: Art, + /// Nur bei [`Art::Zahl`]: kleinster und größter sinnvoller Wert. + pub bereich: Option<(f64, f64)>, + pub einheit: Einheit, + pub auswahl: &'static [&'static str], + /// Kurzbeschriftung für enge Stellen — ein Kanalzug ist keine + /// Handbuchseite. Leer heißt: keine sinnvolle Kurzform, das Control + /// gehört nicht auf einen Knopf. + pub kurz: &'static str, + pub schreibbar: bool, + /// Nur bei [`Art::Aktion`]: was der Auslöser als Argument erwartet. + pub argument: &'static str, + pub text: &'static str, +} + +impl Beschreibung { + /// Wert innerhalb des Bereichs zurechtstutzen. + /// + /// Ein Fader über 1.0 ist kein Fehler, den man dem Aufrufer um die Ohren + /// hauen muss — ein MIDI-Regler, der 127 sendet, meint das Maximum. Werte + /// außerhalb werden deshalb begrenzt, nicht abgelehnt. + pub fn begrenzen(&self, wert: f64) -> f64 { + match self.bereich { + Some((min, max)) => wert.clamp(min, max), + None => wert, + } + } + + /// 0..1 in den echten Bereich. Für Controller, die nur Prozent kennen. + pub fn aus_normiert(&self, norm: f64) -> f64 { + match self.bereich { + Some((min, max)) => min + norm.clamp(0.0, 1.0) * (max - min), + None => norm, + } + } + + /// Echter Bereich nach 0..1. + pub fn nach_normiert(&self, wert: f64) -> f64 { + match self.bereich { + Some((min, max)) if max > min => ((wert - min) / (max - min)).clamp(0.0, 1.0), + _ => wert, + } + } +} + +#[allow(clippy::too_many_arguments)] +const fn zahl( + element: &'static str, + kurz: &'static str, + min: f64, + max: f64, + einheit: Einheit, + schreibbar: bool, + text: &'static str, +) -> Beschreibung { + Beschreibung { + element, + art: Art::Zahl, + bereich: Some((min, max)), + einheit, + auswahl: &[], + kurz, + schreibbar, + argument: "", + text, + } +} + +const fn schalter( + element: &'static str, + kurz: &'static str, + schreibbar: bool, + text: &'static str, +) -> Beschreibung { + Beschreibung { + element, + art: Art::Schalter, + bereich: None, + einheit: Einheit::Keine, + auswahl: &[], + kurz, + schreibbar, + argument: "", + text, + } +} + +/// Ein Auslöser. `argument` beschreibt, was er erwartet — leer heißt: nichts. +const fn aktion(element: &'static str, argument: &'static str, text: &'static str) -> Beschreibung { + Beschreibung { + element, + art: Art::Aktion, + bereich: None, + einheit: Einheit::Keine, + auswahl: &[], + kurz: "", + schreibbar: true, + argument, + text, + } +} + +/// Ein Textfeld, das sich auch beschreiben lässt. +const fn text_schreibbar(element: &'static str, text: &'static str) -> Beschreibung { + Beschreibung { + element, + art: Art::Text, + bereich: None, + einheit: Einheit::Keine, + auswahl: &[], + kurz: "", + schreibbar: true, + argument: "", + text, + } +} + +const fn text_feld(element: &'static str, text: &'static str) -> Beschreibung { + Beschreibung { + element, + art: Art::Text, + bereich: None, + einheit: Einheit::Keine, + auswahl: &[], + kurz: "", + schreibbar: false, + argument: "", + text, + } +} + +/// Obergrenze des Tempo-Reglers. ±8 % ist der Regelweg eines Technics-Decks +/// und damit das, was DJs im Griff haben. +pub const TEMPO_MIN: f64 = 0.92; +pub const TEMPO_MAX: f64 = 1.08; + +/// Hot Cues, die es je Deck gibt. Muss zu `audio_core::deck::HOT_CUES` passen. +pub const HOT_CUES: usize = audio_core::deck::HOT_CUES; + +pub static DECK: &[Beschreibung] = &[ + schalter("play", "PLAY", true, "Läuft das Deck"), + zahl( + "position", + "POS", + 0.0, + f64::MAX, + Einheit::Sekunden, + true, + "Abspielposition; Schreiben springt", + ), + zahl( + "duration", + "LEN", + 0.0, + f64::MAX, + Einheit::Sekunden, + false, + "Länge des geladenen Tracks", + ), + zahl( + "bpm", + "BPM", + 0.0, + 400.0, + Einheit::Bpm, + false, + "Tempo einschließlich Tempo-Regler", + ), + zahl( + "bpm_grid", + "GRID", + 0.0, + 400.0, + Einheit::Bpm, + true, + "Tempo des Beatgrids, ohne Regler; schreiben korrigiert es und merkt es sich", + ), + zahl( + "grid_anchor", + "ANKER", + 0.0, + f64::MAX, + Einheit::Sekunden, + true, + "Wo der erste Beat liegt; schreiben verschiebt das ganze Raster", + ), + zahl( + "tempo", + "TEMPO", + TEMPO_MIN, + TEMPO_MAX, + Einheit::Faktor, + true, + "Tempo-Regler; 1.0 ist die Originalgeschwindigkeit", + ), + schalter( + "keylock", + "KEYLOCK", + true, + "Tonhöhe beim Tempowechsel halten", + ), + zahl( + "beat_phase", + "PHASE", + 0.0, + 1.0, + Einheit::Beats, + false, + "Lage im Beat, 0 ist auf dem Schlag", + ), + // Was ein Bediener wirklich wissen will, statt der Zahlen, aus denen er es + // sich sonst selbst ausrechnet. Position, Länge und Tempo stehen daneben — + // aber wer daraus „noch 32 Beats" ableiten muss, rechnet es bei jedem + // Blick neu und macht dabei irgendwann einen Fehler, den niemand sieht. + zahl( + "beat", + "BEAT", + 0.0, + f64::MAX, + Einheit::Beats, + false, + "Der wievielte Beat gerade läuft, vom Grid-Anker gezählt", + ), + zahl( + "beats_left", + "REST", + 0.0, + f64::MAX, + Einheit::Beats, + false, + "Beats bis zum Ende des Tracks — danach richtet sich, wann der Übergang anfängt", + ), + zahl( + "phrase_beats", + "PHRASE", + 1.0, + 64.0, + Einheit::Beats, + true, + "Wie lang eine Phrase ist; 16 passt zu den meisten Tanzstücken", + ), + zahl( + "beats_to_phrase", + "BIS PHRASE", + 0.0, + 64.0, + Einheit::Beats, + false, + "Beats bis zur nächsten Phrasengrenze — dort setzt man ein, nicht irgendwo", + ), + // Einzelspuren (P6). Ohne sie lässt sich „zwei Stimmen gleichzeitig" nur + // vermeiden, indem man gar nicht überlagert. + zahl( + "stems", + "", + 0.0, + 4.0, + Einheit::Keine, + false, + "Wie viele Einzelspuren der geladene Track hat; 0 heißt: keine", + ), + schalter("loop_active", "LOOP", true, "Läuft gerade eine Schleife"), + zahl( + "loop_beats", + "LOOP", + 0.0, + 64.0, + Einheit::Beats, + true, + "Schleife dieser Länge ab der Position setzen", + ), + // Die Gliederung (S2). Ohne sie war „blende aus, während das Outro läuft" + // nicht sagbar — und der eingehende Track setzte auf Sekunde 0 ein statt + // auf einem Downbeat. + text_feld( + "section", + "Welcher Abschnitt gerade läuft: intro, aufbau, drop, break, outro oder teil", + ), + zahl( + "section_beats_left", + "ABSCHNITT", + 0.0, + f64::MAX, + Einheit::Beats, + false, + "Beats bis zum Ende des laufenden Abschnitts", + ), + zahl( + "beats_to_outro", + "BIS OUTRO", + f64::MIN, + f64::MAX, + Einheit::Beats, + false, + "Beats bis zum Anfang des Outros; negativ, sobald es läuft — die Stelle, \ +an der ein Übergang sitzen darf", + ), + zahl( + "intro_beats", + "INTRO", + 0.0, + f64::MAX, + Einheit::Beats, + false, + "Wie lang das Intro ist — danach sucht man einen Track zum Einmischen aus", + ), + zahl( + "entry", + "EINSTIEG", + 0.0, + f64::MAX, + Einheit::Sekunden, + false, + "Wo der Track einsetzen sollte: sein erster Downbeat, nicht Sekunde 0", + ), + text_feld("title", "Titel des geladenen Tracks"), + text_feld("artist", "Künstler des geladenen Tracks"), + text_feld( + "key", + "Tonart des geladenen Tracks, etwa Am oder F#; leer heißt unbekannt", + ), + text_feld( + "key_camelot", + "Dieselbe Tonart als Camelot-Zahl (8A, 5B) — danach wird harmonisch gemischt", + ), + // Ohne das erfährt ein Agent nie, dass er den nächsten Track auflegen + // muss. Für autonomes Auflegen ist es die zentrale Information. + schalter("finished", "", false, "Track ist durchgelaufen"), + text_feld( + "load_status", + "Stand des letzten Ladeauftrags: bereit, laedt oder ein Fehler", + ), + aktion( + "sync", + "[deck]", + "Auf das andere Deck ziehen — Tempo UND Phase; ohne Argument das jeweils andere", + ), + aktion( + "load", + "", + "Track laden; arbeitet im Hintergrund, Fortschritt über load_status", + ), + aktion("jump_cue", "<1..8>", "Einen gesetzten Hot Cue anspringen"), + aktion( + "jump_entry", + "", + "Auf den Einstiegspunkt springen — den ersten Downbeat statt Sekunde 0", + ), + aktion( + "beatjump", + "", + "Um so viele Beats springen, negativ zurück", + ), + aktion( + "grid_here", + "", + "Den Anker auf die aktuelle Position legen — der Beat ist hier", + ), + aktion( + "grid_scale", + "", + "Grid-Tempo mit diesem Faktor multiplizieren; 0.5 und 2 räumen Oktavfehler auf", + ), +]; + +/// Hot Cues heißen `cue1` bis `cue8` und lassen sich nicht als feste Liste +/// hinschreiben, ohne sie achtmal zu wiederholen. +pub fn hot_cue_beschreibung(index: usize) -> Option { + if index >= HOT_CUES { + return None; + } + + Some(Beschreibung { + // Der Name wird zur Laufzeit gebraucht, `element` ist aber `'static`. + // Deshalb eine kleine feste Tabelle statt einer geleakten Zeichenkette. + element: HOT_CUE_NAMEN[index], + kurz: "", + art: Art::Zahl, + bereich: Some((0.0, f64::MAX)), + einheit: Einheit::Sekunden, + auswahl: &[], + schreibbar: true, + argument: "", + text: "Hot Cue; Schreiben setzt ihn, Lesen gibt seine Position oder '-'", + }) +} + +static HOT_CUE_NAMEN: [&str; HOT_CUES] = [ + "cue1", "cue2", "cue3", "cue4", "cue5", "cue6", "cue7", "cue8", +]; + +/// Wie viele Einzelspuren ein Deck führt. Muss zu `audio_core::MAX_STEMS` +/// passen. +pub const MAX_STEMS: usize = audio_core::MAX_STEMS; + +/// Einzelspuren heißen `stem1_level` bis `stem4_name` — aus demselben Grund +/// wie die Hot Cues zur Laufzeit gebaut statt achtmal hingeschrieben. +pub fn stem_beschreibung(index: usize, feld: &str) -> Option { + if index >= MAX_STEMS { + return None; + } + Some(match feld { + "name" => Beschreibung { + element: STEM_NAMEN[index], + kurz: "", + art: Art::Text, + bereich: None, + einheit: Einheit::Keine, + auswahl: &[], + schreibbar: false, + argument: "", + text: "Wie die Einzelspur heißt — kommt aus dem Dateinamen. \ +Leer, wenn der Track so viele Spuren nicht hat", + }, + "level" => Beschreibung { + element: STEM_PEGEL[index], + kurz: "STEM", + art: Art::Zahl, + bereich: Some((0.0, 1.0)), + einheit: Einheit::Keine, + auswahl: &[], + schreibbar: true, + argument: "", + text: "Pegel dieser Einzelspur. 0 nimmt sie heraus — damit lässt \ +sich die Stimme des ausgehenden Tracks wegnehmen, während der neue kommt", + }, + _ => return None, + }) +} + +static STEM_NAMEN: [&str; MAX_STEMS] = ["stem1_name", "stem2_name", "stem3_name", "stem4_name"]; +static STEM_PEGEL: [&str; MAX_STEMS] = ["stem1_level", "stem2_level", "stem3_level", "stem4_level"]; + +/// Ein Kanalzug, in der Reihenfolge, in der er am Gerät steht. +/// +/// Die Reihenfolge ist nicht beliebig: Die Oberfläche holt sich ihre Regler +/// aus dem Katalog und zeichnet sie so, wie sie hier stehen. Höhen oben, +/// Bässe unten — andersherum greift man beim Auflegen ständig daneben. +pub static KANAL: &[Beschreibung] = &[ + zahl( + "trim", + "TRIM", + 0.0, + 2.0, + Einheit::Faktor, + true, + "Eingangsverstärkung vor dem EQ", + ), + zahl("eq_high", "HI", 0.0, 2.0, Einheit::Faktor, true, "Höhen"), + zahl("eq_mid", "MID", 0.0, 2.0, Einheit::Faktor, true, "Mitten"), + zahl( + "eq_low", + "LOW", + 0.0, + 2.0, + Einheit::Faktor, + true, + "Bässe; 0 ist ein echter Kill", + ), + zahl( + "filter", + "FLT", + -1.0, + 1.0, + Einheit::Bipolar, + true, + "DJ-Filter; negativ Tiefpass, positiv Hochpass", + ), + zahl( + "fader", + "FADER", + 0.0, + 1.0, + Einheit::Faktor, + true, + "Linefader", + ), + schalter("cue", "CUE", true, "Kanal auf den Kopfhörer legen"), + Beschreibung { + element: "fx", + kurz: "FX", + art: Art::Auswahl, + bereich: None, + einheit: Einheit::Keine, + auswahl: audio_engine::Effekt::NAMEN, + schreibbar: true, + argument: "", + text: "Effekt hinter dem Fader; die Fahne überlebt den zugezogenen Fader", + }, + zahl( + "fx_mix", + "MIX", + 0.0, + 1.0, + Einheit::Faktor, + true, + "Trocken bis nass", + ), + zahl( + "fx_amount", + "AMT", + 0.0, + 1.0, + Einheit::Faktor, + true, + "Stärke: Rückkopplung, Öffnungsdauer, Tiefe oder Härte", + ), + zahl( + "fx_time", + "ZEIT", + 0.001, + 4.0, + Einheit::Sekunden, + true, + "Effektzeit; mit fx_sync auf das Tempo des Decks setzen", + ), + aktion( + "fx_sync", + "", + "Effektzeit auf so viele Beats des zugehörigen Decks setzen", + ), + Beschreibung { + element: "assign", + kurz: "", + art: Art::Auswahl, + bereich: None, + einheit: Einheit::Keine, + auswahl: &["a", "b", "thru"], + schreibbar: true, + argument: "", + text: "Seite am Crossfader; 'thru' geht am Crossfader vorbei", + }, +]; + +pub static MASTER: &[Beschreibung] = &[ + aktion( + "record", + "", + "Mitschnitt der Summe starten; nimmt auf, was auf die Anlage geht", + ), + aktion( + "record_stop", + "", + "Mitschnitt beenden und die Datei abschließen", + ), + // Der einzige Weg, auf dem ein Bediener erfährt, dass ihm jemand anders + // dazwischengekommen ist. Ein Abo darauf meldet **jede** Zeile, nicht nur + // die zuletzt geschriebene — siehe `Sitzung::aenderungen`. + text_feld( + "events", + "Was der Plan zuletzt gemeldet hat: fertig, abgeloest, abgebrochen, gestrichen. \ +Abonnieren meldet jede Zeile einzeln, mit 'event' davor", + ), + // Der Bogen: was das Set vorhat, bevor der nächste Track gewählt wird. + text_schreibbar( + "arc", + "Ziel-Energiekurve über die Setdauer: '0 0.3, 20 0.7, 45 0.95, 60 0.5'. \ +Zeiten in Minuten, Energie 0 bis 1. Schreibbar", + ), + zahl( + "arc_minutes", + "", + 0.0, + f64::MAX, + Einheit::Keine, + false, + "Wie lange das Set schon läuft, in Minuten; leer vor arc_start", + ), + zahl( + "arc_target", + "SOLL", + 0.0, + 1.0, + Einheit::Keine, + false, + "Wie viel Energie der Bogen hier vorsieht", + ), + zahl( + "arc_actual", + "IST", + 0.0, + 1.0, + Einheit::Keine, + false, + "Wie viel gerade läuft — aus der Art des Abschnitts, nicht aus dem Pegel: \ +der ist auf den Track selbst bezogen und über Tracks hinweg nicht vergleichbar", + ), + zahl( + "arc_gap", + "LÜCKE", + -1.0, + 1.0, + Einheit::Keine, + false, + "Soll minus Ist. Positiv heißt: der Bogen will mehr, als gerade läuft — \ +die Zahl, nach der der nächste Track gewählt wird", + ), + zahl( + "arc_curve", + "KURVE", + 0.0, + 1.0, + Einheit::Keine, + false, + "Was die geschriebene Kurve hier vorsieht — ungebeugt. \ +Der Vergleich zu arc_target zeigt, wieviel davon der Raum ist", + ), + // Der Raum: was draußen geschieht, verschiebt das Ziel — siehe `crate::raum`. + text_schreibbar( + "room", + "Welches Signal den Bogen beugt und wie stark: 'signal1 0.3'. \ +Gebeugt wird nach dem Trend, nicht nach dem Wert, und höchstens um 0.25. \ +'-' oder 'aus' schaltet es ab. Schreibbar", + ), + zahl( + "room_bend", + "BEUGUNG", + -1.0, + 1.0, + Einheit::Keine, + false, + "Wie weit der Raum das Ziel gerade verschiebt; leer ohne Raum oder ohne Trend", + ), + text_feld( + "why", + "In einem Satz, warum als Nächstes das kommen soll, was kommen soll: \ +Raum, Ziel, Ist-Energie, Lücke — und ob zu oft dasselbe gefahren wurde", + ), + text_feld( + "arc_trend", + "Wohin der Bogen als Nächstes will: steigt, haelt oder faellt", + ), + aktion( + "arc_start", + "", + "Das Set beginnt jetzt — ohne das gibt es keinen Ort auf dem Bogen", + ), + aktion( + "uebergang", + " [beats]", + "Einen benannten Übergang vormerken — die Zeilen dafür stehen in der Antwort \ +und im Plan. Die Anlage wählt nicht aus: welcher Griff passt, entscheidet, wer \ +begründen kann warum", + ), + // Zurückhaltung: ob immer dasselbe gefahren wird. Gezählt wird, wenn der + // Crossfader die Seite wechselt — siehe `crate::vielfalt`. + zahl( + "repeats", + "", + 0.0, + f64::MAX, + Einheit::Keine, + false, + "Wie viele der letzten Übergänge hintereinander gleich waren. \ +1 heißt: der letzte war anders als der davor. Ab 3 wiederholt sich jemand hörbar", + ), + text_feld( + "transitions", + "Die letzten Übergänge, ältester zuerst. Wer nur mit den Kanalfadern mischt \ +und den Crossfader stehen lässt, taucht hier nicht auf", + ), + zahl( + "event_count", + "", + 0.0, + f64::MAX, + Einheit::Keine, + false, + "Wie viele Ereignisse es insgesamt gab. Springt die Zahl um mehr als eins, \ +hat man dazwischen welche verpasst — für alle, die fragen statt zu abonnieren", + ), + schalter("recording", "", false, "Läuft gerade ein Mitschnitt"), + zahl( + "record_seconds", + "", + 0.0, + f64::MAX, + Einheit::Sekunden, + false, + "Bisherige Länge des Mitschnitts", + ), + zahl( + "record_dropped", + "", + 0.0, + f64::MAX, + Einheit::Keine, + false, + "Frames, die der Schreiber nicht mehr annehmen konnte — alles über 0 heißt Lücken", + ), + aktion( + "search", + "", + "Die Sammlung durchsuchen; antwortet mit einer Zeile je Treffer", + ), + aktion("playlists", "", "Die Playlists der Sammlung aufzählen"), + aktion( + "playlist", + "", + "Die Tracks einer Playlist auflisten, in ihrer Reihenfolge", + ), + aktion( + "search_mixable", + "", + "Tracks suchen, die tempomäßig zu diesem Wert passen", + ), + aktion( + "search_next", + "", + "Was als Nächstes passt: nach Tempo des laufenden Decks, nach Tonart und \ +nach dem, was Bogen und Raum gerade wollen. Jede Zeile sagt, warum sie dort steht. \ +Ausgewählt wird nicht — die Anlage sortiert, entschieden wird woanders", + ), + aktion( + "search_harmonic", + "", + "Tracks suchen, deren Tonart harmonisch passt; nimmt Am, F# oder 8A", + ), + // Was von außen hereinkommt: die Reaktion des Raums. Feste Plätze mit + // beschriftbarem Namen — wie ein Kanalzug, den man mit Klebeband + // beschriftet. Dynamische Namen bräuchten geleakte Zeichenketten, und + // dieselbe Entscheidung ist schon bei den Hot Cues gefallen. + zahl( + "signal1", + "SIG1", + -1.0, + 1.0, + Einheit::Keine, + true, + "Signal 1 von außen; -1 bis 1, wofür es steht sagt signal1_name", + ), + text_schreibbar( + "signal1_name", + "Wofür Signal 1 steht, etwa 'Energie auf der Flaeche'; leer heisst ungenutzt", + ), + zahl( + "signal1_trend", + "TRD1", + -100.0, + 100.0, + Einheit::Keine, + false, + "Änderung von Signal 1 je Minute; positiv steigt, leer heisst zu wenige Proben", + ), + zahl( + "signal1_age", + "ALT1", + 0.0, + f64::MAX, + Einheit::Sekunden, + false, + "Sekunden seit der letzten Meldung an Signal 1; alte Werte lügen nicht, sie sind nur alt", + ), + zahl( + "signal2", + "SIG2", + -1.0, + 1.0, + Einheit::Keine, + true, + "Signal 2 von außen; -1 bis 1, wofür es steht sagt signal2_name", + ), + text_schreibbar( + "signal2_name", + "Wofür Signal 2 steht, etwa 'Energie auf der Flaeche'; leer heisst ungenutzt", + ), + zahl( + "signal2_trend", + "TRD2", + -100.0, + 100.0, + Einheit::Keine, + false, + "Änderung von Signal 2 je Minute; positiv steigt, leer heisst zu wenige Proben", + ), + zahl( + "signal2_age", + "ALT2", + 0.0, + f64::MAX, + Einheit::Sekunden, + false, + "Sekunden seit der letzten Meldung an Signal 2; alte Werte lügen nicht, sie sind nur alt", + ), + zahl( + "signal3", + "SIG3", + -1.0, + 1.0, + Einheit::Keine, + true, + "Signal 3 von außen; -1 bis 1, wofür es steht sagt signal3_name", + ), + text_schreibbar( + "signal3_name", + "Wofür Signal 3 steht, etwa 'Energie auf der Flaeche'; leer heisst ungenutzt", + ), + zahl( + "signal3_trend", + "TRD3", + -100.0, + 100.0, + Einheit::Keine, + false, + "Änderung von Signal 3 je Minute; positiv steigt, leer heisst zu wenige Proben", + ), + zahl( + "signal3_age", + "ALT3", + 0.0, + f64::MAX, + Einheit::Sekunden, + false, + "Sekunden seit der letzten Meldung an Signal 3; alte Werte lügen nicht, sie sind nur alt", + ), + zahl( + "signal4", + "SIG4", + -1.0, + 1.0, + Einheit::Keine, + true, + "Signal 4 von außen; -1 bis 1, wofür es steht sagt signal4_name", + ), + text_schreibbar( + "signal4_name", + "Wofür Signal 4 steht, etwa 'Energie auf der Flaeche'; leer heisst ungenutzt", + ), + zahl( + "signal4_trend", + "TRD4", + -100.0, + 100.0, + Einheit::Keine, + false, + "Änderung von Signal 4 je Minute; positiv steigt, leer heisst zu wenige Proben", + ), + zahl( + "signal4_age", + "ALT4", + 0.0, + f64::MAX, + Einheit::Sekunden, + false, + "Sekunden seit der letzten Meldung an Signal 4; alte Werte lügen nicht, sie sind nur alt", + ), + aktion( + "queue", + "", + "Was als Nächstes kommt — eine Zeile je Eintrag, mit Nummer und Notiz", + ), + aktion( + "queue_add", + "", + "Einen Track hinten anhängen; derselbe Pfad wird nicht zweimal angenommen", + ), + aktion( + "queue_note", + " ", + "Notieren, warum ein Eintrag dort steht — auch, um es zu widerrufen", + ), + aktion( + "queue_bump", + "", + "Einen Eintrag nach vorn ziehen, also zum Nächsten machen", + ), + aktion("queue_drop", "", "Einen Eintrag herausnehmen"), + aktion( + "queue_clear", + "", + "Die ganze Liste leeren — auch, was andere vorgemerkt haben", + ), + aktion( + "queue_next", + "[deck]", + "Den vordersten Eintrag laden; ohne Angabe auf ein Deck, das nicht läuft", + ), + zahl( + "crossfader", + "XFADER", + -1.0, + 1.0, + Einheit::Bipolar, + true, + "Crossfader; -1 ist ganz A, +1 ganz B", + ), + zahl( + "crossfader_curve", + "KURVE", + 0.0, + 1.0, + Einheit::Faktor, + true, + "Kurve; 0 weich, 1 hart", + ), + zahl( + "gain", + "MASTER", + 0.0, + 1.5, + Einheit::Faktor, + true, + "Summenlautstärke", + ), + zahl( + "cue_gain", + "KOPFH", + 0.0, + 1.5, + Einheit::Faktor, + true, + "Kopfhörerlautstärke", + ), + zahl( + "cue_mix", + "CUE/MST", + 0.0, + 1.0, + Einheit::Faktor, + true, + "Kopfhörer zwischen Vorhören (0) und Summe (1)", + ), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jedes_control_beschreibt_sich_selbst() { + // Der ganze Sinn des Katalogs: Wer fragt, bekommt eine Antwort, die + // ohne Handbuch trägt. + let alle = DECK.iter().chain(KANAL).chain(MASTER); + for b in alle { + assert!(!b.element.is_empty()); + assert!(!b.text.is_empty(), "{} hat keine Beschreibung", b.element); + if b.art == Art::Zahl { + assert!( + b.bereich.is_some(), + "{} ist eine Zahl ohne Bereich", + b.element + ); + } + if b.art == Art::Auswahl { + assert!( + !b.auswahl.is_empty(), + "{} ist eine Auswahl ohne Optionen", + b.element + ); + } + } + } + + #[test] + fn namen_kommen_innerhalb_einer_gruppe_nur_einmal_vor() { + for gruppe in [DECK, KANAL, MASTER] { + let mut namen: Vec<_> = gruppe.iter().map(|b| b.element).collect(); + let vorher = namen.len(); + namen.sort_unstable(); + namen.dedup(); + assert_eq!(namen.len(), vorher, "doppelter Name in einer Gruppe"); + } + } + + #[test] + fn normierung_geht_hin_und_zurueck() { + let b = &KANAL[0]; // trim, 0..2 + assert_eq!(b.aus_normiert(0.0), 0.0); + assert_eq!(b.aus_normiert(1.0), 2.0); + assert_eq!(b.nach_normiert(1.0), 0.5); + + // Ein bipolares Control hat seine Mitte bei 0.5 normiert — das ist der + // Punkt, an dem ein MIDI-Regler in der Raste steht. + let filter = KANAL.iter().find(|b| b.element == "filter").unwrap(); + assert_eq!(filter.nach_normiert(0.0), 0.5); + assert_eq!(filter.aus_normiert(0.5), 0.0); + } + + #[test] + fn werte_ausserhalb_werden_begrenzt_statt_abgelehnt() { + // Ein MIDI-Regler auf Anschlag meint das Maximum, keinen Fehler. + let fader = KANAL.iter().find(|b| b.element == "fader").unwrap(); + assert_eq!(fader.begrenzen(1.7), 1.0); + assert_eq!(fader.begrenzen(-0.2), 0.0); + } + + #[test] + fn es_gibt_genau_so_viele_hot_cues_wie_im_deck() { + assert!(hot_cue_beschreibung(HOT_CUES - 1).is_some()); + assert!(hot_cue_beschreibung(HOT_CUES).is_none()); + assert_eq!(hot_cue_beschreibung(0).unwrap().element, "cue1"); + } + + #[test] + fn nur_gelesene_controls_sind_auch_als_solche_markiert() { + let dauer = DECK.iter().find(|b| b.element == "duration").unwrap(); + assert!( + !dauer.schreibbar, + "die Länge eines Tracks lässt sich nicht setzen" + ); + } +} diff --git a/crates/control/src/lib.rs b/crates/control/src/lib.rs new file mode 100644 index 0000000..97f613e --- /dev/null +++ b/crates/control/src/lib.rs @@ -0,0 +1,68 @@ +//! Ein benannter Steuerraum für alles, was bedienbar ist. +//! +//! Der Gedanke stammt von Mixxx: Dort laufen Tastatur, MIDI, HID und die +//! Oberfläche über *dieselben* benannten Controls, statt dass jede Bedienart +//! ihren eigenen Draht zum Mixer zieht. Das ist die richtige Idee, und sie ist +//! frei — geschützt ist die Ausdrucksform, nicht der Entwurf (siehe +//! `docs/MIXXX.md`). +//! +//! Drei Dinge machen wir anders, und sie sind der Grund, warum sich der Bau +//! lohnt: +//! +//! 1. **Der Steuerraum beschreibt sich selbst.** Jedes Control trägt Bereich, +//! Einheit, Schreibbarkeit und einen Satz zur Bedeutung. Bei Mixxx steht das +//! im Handbuch; ein laufendes Programm lässt sich nicht fragen, was es kann. +//! Ein Agent, der hier `list` sagt, weiß danach genug, um zu bedienen. +//! 2. **Werte sind typisiert.** Ein Schalter ist ein Schalter, eine Auswahl +//! trägt ihre Namen. Nicht alles ist ein `double`, in das jeder Tippfehler +//! als gültiger Wert hineinpasst. +//! 3. **Er ist von außen erreichbar.** Mixxx spricht OSC nur ausgehend; +//! Befehle entgegenzunehmen ist dort ein seit Jahren offener Wunsch. Hier +//! ist es der Zweck. +//! 4. **Er kennt Zeit.** Ein Übergang ist eine Bewegung über Takte, keine +//! Folge von Reglerstellungen. Wer „Bass raus über acht Beats" sagen kann, +//! muss nicht in einer engen Schleife pollen — siehe [`zeitplan`]. +//! +//! Echtzeit bleibt unangetastet: Das Pult schreibt nie in den Audio-Callback, +//! sondern schickt Kommandos in dieselbe lock-freie Schlange, die die +//! Oberfläche benutzt. Gelesen wird aus Atomics und aus dem Spiegel, nie aus +//! dem Mixer selbst. + +pub mod bogen; +pub mod ereignis; +pub mod katalog; +pub mod mitschrift; +pub mod protokoll; +pub mod pult; +pub mod raum; +pub mod schluessel; +pub mod server; +pub mod signal; +pub mod uebergang; +pub mod vielfalt; +pub mod warteschlange; +pub mod wert; +pub mod zeitplan; + +#[cfg(test)] +pub(crate) mod testing; + +pub use bogen::Bogen; +pub use ereignis::Ereignisse; +pub use katalog::Beschreibung; +pub use mitschrift::{Mitschrift, Richtung, Stand}; +pub use protokoll::behandle; +pub use protokoll::Sitzung; +pub use pult::{ + assign_aus_name, assign_name, DeckEintrag, Fehler, KanalSpiegel, MasterSpiegel, Sammlung, + Steuerpult, Treffer, +}; +pub use raum::Raum; +pub use schluessel::{Gruppe, Schluessel}; +pub use server::{Server, ServerFehler}; +pub use signal::Signal; +pub use uebergang::Griff; +pub use vielfalt::Vielfalt; +pub use warteschlange::Warteschlange; +pub use wert::{Art, Einheit, Wert}; +pub use zeitplan::{Zeitplan, TAKT}; diff --git a/crates/control/src/mitschrift.rs b/crates/control/src/mitschrift.rs new file mode 100644 index 0000000..2d225a1 --- /dev/null +++ b/crates/control/src/mitschrift.rs @@ -0,0 +1,650 @@ +//! Was gemeint war — die Mitschrift neben dem Mitschnitt. +//! +//! Der Mitschnitt sagt, was herauskam. Er sagt nicht, was beabsichtigt war, und +//! deshalb kann der Kritiker zwei Dinge nicht, die er können müsste: +//! +//! **Den Beginn einer langen Blende findet er im Klang nicht.** Am Anfang ist +//! der eingehende Track per Konstruktion unhörbar — das ist, was eine Blende +//! ausmacht. Bei sechzehn Sekunden liegt seine Schätzung um vier daneben, und +//! das ist keine Ungenauigkeit, die sich wegrechnen ließe: Die Information +//! steht in der Summe nicht drin. +//! +//! **Die Phrasenlage kann er gar nicht messen.** Der Anker eines nachträglich +//! geschätzten Rasters ist der erste starke Schlag im Analysefenster, nicht der +//! Anfang einer Phrase. Jede Angabe „so viele Beats neben der Eins" wäre gegen +//! einen willkürlichen Nullpunkt gemessen. +//! +//! Beides weiß die Anlage im Moment des Geschehens genau. Sie kennt den Frame, +//! an dem der Befehl ankam, und sie kennt den Beat, auf dem jedes Deck dabei +//! stand. Wer das mitschreibt, muss es hinterher nicht erraten. +//! +//! **Das ist ausdrücklich keine zweite Wahrheit.** Die Mitschrift sagt, wann +//! der Griff an den Fader geschah; ob dabei etwas Gutes herauskam, sagt +//! weiterhin nur der Mitschnitt. Erst nebeneinander ergeben sie ein Urteil — +//! und der Abstand zwischen beiden ist selbst eine Zahl, die es wert ist, +//! aufgeschrieben zu werden: Er misst, wie weit der Kritiker danebenliegt, wenn +//! er raten muss. +//! +//! # Form +//! +//! Zeilenweise Text, damit man sie ohne Werkzeug lesen kann. Ein Kopf aus +//! Kommentaren, dann je Ereignis eine Zeile: +//! +//! ```text +//! # musik-mitschrift 1 +//! # mitschnitt /pfad/set.wav +//! # rate 48000 +//! 120960 2.520 deck1=64.000/16 deck2=-0.998/16~ > ramp master.crossfader 1 32 +//! 133440 2.780 deck1=70.000/16 deck2=5.000/16 < ok plan 3 ramp master.crossfader nach 1 +//! ``` +//! +//! Ein Deckfeld ist `deck=/`; ein angehängtes `~` heißt, dass +//! das Deck **stand**. Das ist kein Beiwerk: Ein wartendes Deck steht irgendwo +//! zwischen zwei Phrasengrenzen, und ohne die Markierung liest sich das später +//! wie „es kam neben der Eins herein" — obwohl es überhaupt nicht hereinkam. +//! +//! `>` ist hereingekommen, `<` ist hinausgegangen — Absicht und Antwort. Die +//! Sekunden sind eine Bequemlichkeit für den lesenden Menschen; **maßgeblich +//! ist der Frame**, und der Leser rechnet sie aus der Rate im Kopf neu aus. +//! Zwei Zahlen, die dasselbe sagen sollen, driften sonst irgendwann +//! auseinander, und dann glaubt man der falschen. + +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; + +/// Fassung der Form. Steht im Kopf, damit ein Leser eine spätere erkennt. +pub const FORMAT: u32 = 1; + +/// Die Endung, die neben dem Mitschnitt liegt. +pub const ENDUNG: &str = "mitschrift"; + +/// Woher eine Zeile stammt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Richtung { + /// Ein Befehl, der hereinkam — die Absicht. + Befehl, + /// Was die Anlage darauf meldete — das Ergebnis. + Meldung, +} + +impl Richtung { + pub fn zeichen(&self) -> char { + match self { + Richtung::Befehl => '>', + Richtung::Meldung => '<', + } + } + + pub fn parse(text: &str) -> Option { + match text { + ">" => Some(Richtung::Befehl), + "<" => Some(Richtung::Meldung), + _ => None, + } + } +} + +/// Das Zeichen für ein Deck, das nicht lief. +/// +/// Steht am Ende des Feldes: `deck2=-0.998/16~`. +pub const STEHT: char = '~'; + +/// Wo ein Deck stand, als etwas geschah. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Stand { + /// Nullbasiert; in der Zeile steht `deck1` für 0. + pub deck: usize, + /// Beat auf dem Beatgrid des Decks. + pub beat: f64, + /// Wie viele Beats die Anlage als eine Phrase zählt. + pub phrase_beats: f64, + /// Ob das Deck lief. + /// + /// **Ohne das lädt die Phrasenlage eines stehenden Decks zu einem falschen + /// Schluss ein.** Ein Deck, das am Anfang seines Tracks wartet, steht + /// irgendwo zwischen zwei Phrasengrenzen — das heißt nicht, dass es neben + /// der Eins eingesetzt hätte. Es hat überhaupt nicht eingesetzt. + pub laeuft: bool, +} + +impl Stand { + /// Wie weit hinter der letzten Phrasengrenze, in Beats. + /// + /// `None`, wenn das Deck stand: Dann ist die Zahl zwar ausrechenbar, aber + /// sie beantwortet keine Frage, die jemand gestellt hätte. + /// + /// **Gemessen gegen die Rechnung der Anlage, nicht gegen die Musik.** Ob + /// Beat 0 des Rasters wirklich ein Downbeat ist, weiß erst die + /// Strukturanalyse (S2). Bis dahin beantwortet diese Zahl die kleinere, + /// aber immerhin beantwortbare Frage: Hat die Anlage getan, was sie selbst + /// vorhatte? + pub fn phrasenlage(&self) -> Option { + if !self.laeuft || self.phrase_beats <= 0.0 { + return None; + } + Some(self.beat.rem_euclid(self.phrase_beats)) + } + + fn feld(&self) -> String { + let mut feld = format!( + "deck{}={:.3}/{}", + self.deck + 1, + self.beat, + self.phrase_beats + ); + if !self.laeuft { + feld.push(STEHT); + } + feld + } + + fn parse(feld: &str) -> Option { + let rest = feld.strip_prefix("deck")?; + let (nummer, rest) = rest.split_once('=')?; + let (beat, phrase) = rest.split_once('/')?; + let nummer: usize = nummer.parse().ok()?; + let (phrase, laeuft) = match phrase.strip_suffix(STEHT) { + Some(ohne) => (ohne, false), + None => (phrase, true), + }; + Some(Stand { + deck: nummer.checked_sub(1)?, + beat: beat.parse().ok()?, + phrase_beats: phrase.parse().ok()?, + laeuft, + }) + } +} + +/// Ein Ereignis in der Mitschrift. +#[derive(Debug, Clone, PartialEq)] +pub struct Ereignis { + /// Position im Mitschnitt, in Frames. Die maßgebliche Zeitangabe. + pub frame: u64, + /// Wo die Decks standen. Nur die mit Beatgrid stehen drin — ein Deck ohne + /// Raster hat keinen Beat, auf den sich etwas beziehen ließe. + pub staende: Vec, + pub richtung: Richtung, + pub text: String, +} + +impl Ereignis { + pub fn sekunden(&self, rate: u32) -> f64 { + if rate == 0 { + return 0.0; + } + self.frame as f64 / rate as f64 + } + + pub fn stand(&self, deck: usize) -> Option<&Stand> { + self.staende.iter().find(|s| s.deck == deck) + } + + /// Die Zeile, wie sie in der Datei steht. + pub fn zeile(&self, rate: u32) -> String { + let mut zeile = format!("{} {:.3}", self.frame, self.sekunden(rate)); + for s in &self.staende { + zeile.push(' '); + zeile.push_str(&s.feld()); + } + zeile.push(' '); + zeile.push(self.richtung.zeichen()); + zeile.push(' '); + zeile.push_str(&self.text); + zeile + } + + /// Liest eine Zeile zurück. + /// + /// `None` für alles, was nicht passt — eine abgeschnittene letzte Zeile + /// nach einem Absturz darf nicht die ganze Datei unlesbar machen. + pub fn parse(zeile: &str) -> Option { + let zeile = zeile.trim_end_matches(['\r', '\n']); + let mut worte = zeile.split_whitespace(); + let frame: u64 = worte.next()?.parse().ok()?; + // Die Sekunden werden gelesen und verworfen: Maßgeblich ist der Frame. + worte.next()?.parse::().ok()?; + + let mut staende = Vec::new(); + let richtung = loop { + let wort = worte.next()?; + if let Some(r) = Richtung::parse(wort) { + break r; + } + staende.push(Stand::parse(wort)?); + }; + + // Der Text ist der Rest der Zeile, mit den ursprünglichen Abständen. + // Über die Wortgrenzen ließe er sich nicht wiederherstellen. + let marke = format!(" {} ", richtung.zeichen()); + let text = zeile.split_once(&marke)?.1.to_string(); + + Some(Ereignis { + frame, + staende, + richtung, + text, + }) + } +} + +/// Der Kopf der Datei. +#[derive(Debug, Clone, PartialEq)] +pub struct Kopf { + pub format: u32, + /// Zu welchem Mitschnitt die Mitschrift gehört. + pub mitschnitt: String, + pub rate: u32, +} + +/// Eine gelesene Mitschrift. +#[derive(Debug, Clone)] +pub struct Protokoll { + pub kopf: Kopf, + pub ereignisse: Vec, + /// Wie viele Zeilen sich nicht lesen ließen. + /// + /// Steht hier, statt still übersprungen zu werden: Eine Mitschrift mit + /// Lücken, die aussieht wie eine ohne, wäre das schlechteste von allem — + /// dieselbe Überlegung wie bei den verworfenen Frames im Mitschnitt. + pub unlesbar: usize, +} + +impl Protokoll { + /// Alle Ereignisse, deren Text mit einem der Präfixe beginnt. + pub fn mit_praefix<'a>( + &'a self, + praefixe: &'a [&'a str], + ) -> impl Iterator { + self.ereignisse + .iter() + .filter(move |e| praefixe.iter().any(|p| e.text.starts_with(p))) + } +} + +/// Liest eine Mitschrift aus Text. +pub fn aus_text(text: &str) -> Result { + let mut format = None; + let mut mitschnitt = String::new(); + let mut rate = None; + let mut ereignisse = Vec::new(); + let mut unlesbar = 0usize; + + for zeile in text.lines() { + let getrimmt = zeile.trim(); + if getrimmt.is_empty() { + continue; + } + if let Some(kopf) = getrimmt.strip_prefix('#') { + let kopf = kopf.trim(); + if let Some(wert) = kopf.strip_prefix("musik-mitschrift ") { + format = wert.trim().parse().ok(); + } else if let Some(wert) = kopf.strip_prefix("mitschnitt ") { + mitschnitt = wert.trim().to_string(); + } else if let Some(wert) = kopf.strip_prefix("rate ") { + rate = wert.trim().parse().ok(); + } + continue; + } + match Ereignis::parse(zeile) { + Some(e) => ereignisse.push(e), + None => unlesbar += 1, + } + } + + let Some(format) = format else { + return Err("kein Kopf — das ist keine Mitschrift".into()); + }; + if format > FORMAT { + return Err(format!( + "Fassung {format} ist neuer als die bekannte {FORMAT}" + )); + } + let Some(rate) = rate.filter(|r| *r > 0) else { + return Err("keine Abtastrate im Kopf — Frames wären ohne sie zeitlos".into()); + }; + + Ok(Protokoll { + kopf: Kopf { + format, + mitschnitt, + rate, + }, + ereignisse, + unlesbar, + }) +} + +/// Liest eine Mitschrift von der Platte. +pub fn lesen(pfad: &Path) -> Result { + let text = std::fs::read_to_string(pfad).map_err(|e| format!("{}: {e}", pfad.display()))?; + aus_text(&text) +} + +/// Der Schreiber, der neben dem Mitschnitt mitläuft. +/// +/// **Er schreibt sofort durch.** Ein gepufferter Schreiber verlöre bei einem +/// Absturz genau die Zeilen, die erklären würden, was zuletzt geschah. Das ist +/// vertretbar, weil hier nicht der Audio-Callback schreibt, sondern der +/// Steuer-Thread, und weil ein Set ein paar hundert Ereignisse hat und nicht +/// ein paar hunderttausend. +pub struct Mitschrift { + datei: BufWriter, + pfad: PathBuf, + rate: u32, + fehler: u64, +} + +impl Mitschrift { + /// Legt die Mitschrift neben einen Mitschnitt. + pub fn starten(pfad: &Path, mitschnitt: &Path, rate: u32) -> Result { + let datei = File::create(pfad).map_err(|e| format!("{}: {e}", pfad.display()))?; + let mut m = Mitschrift { + datei: BufWriter::new(datei), + pfad: pfad.to_path_buf(), + rate, + fehler: 0, + }; + let kopf = format!( + "# musik-mitschrift {FORMAT}\n# mitschnitt {}\n# rate {rate}\n", + mitschnitt.display() + ); + m.roh(&kopf); + Ok(m) + } + + /// Wo die Mitschrift liegt. + pub fn pfad(&self) -> &Path { + &self.pfad + } + + /// Wie viele Zeilen sich nicht schreiben ließen. + pub fn fehler(&self) -> u64 { + self.fehler + } + + /// Hält ein Ereignis fest. + /// + /// Mehrzeilige Antworten werden auf ihre erste Zeile gekürzt: Eine Suche + /// mit vierzig Treffern würde die Mitschrift sonst zuschütten, und was sie + /// festhalten soll, ist die Bewegung, nicht der Katalog. + pub fn halten(&mut self, frame: u64, staende: &[Stand], richtung: Richtung, text: &str) { + let mut zeilen = text.lines().filter(|z| !z.trim().is_empty()); + let Some(erste) = zeilen.next() else { + return; + }; + let weitere = zeilen.count(); + let text = if weitere > 0 { + format!("{erste} … (+{weitere} Zeilen)") + } else { + erste.to_string() + }; + + let ereignis = Ereignis { + frame, + staende: staende.to_vec(), + richtung, + text, + }; + let zeile = ereignis.zeile(self.rate); + self.roh(&zeile); + self.roh("\n"); + } + + fn roh(&mut self, text: &str) { + // Ein Schreibfehler mitten im Set darf weder den Mix anhalten noch + // stillschweigend verschwinden. Also zählen und beim Stoppen melden — + // dieselbe Regel wie bei den verworfenen Frames. + if self.datei.write_all(text.as_bytes()).is_err() || self.datei.flush().is_err() { + self.fehler += 1; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stand(deck: usize, beat: f64) -> Stand { + Stand { + deck, + beat, + phrase_beats: 16.0, + laeuft: true, + } + } + + fn stehend(deck: usize, beat: f64) -> Stand { + Stand { + laeuft: false, + ..stand(deck, beat) + } + } + + #[test] + fn eine_zeile_ueberlebt_hin_und_zurueck() { + let e = Ereignis { + frame: 120_960, + staende: vec![stand(0, 64.0), stand(1, 3.5)], + richtung: Richtung::Befehl, + text: "ramp master.crossfader 1 32".into(), + }; + let zeile = e.zeile(48_000); + assert_eq!( + zeile, + "120960 2.520 deck1=64.000/16 deck2=3.500/16 > ramp master.crossfader 1 32" + ); + assert_eq!(Ereignis::parse(&zeile), Some(e)); + } + + /// Der Text darf alles enthalten, auch die Zeichen, die sonst die Richtung + /// markieren. Sonst zerfiele jede Meldung mit einem Vergleich darin. + #[test] + fn ein_text_mit_spitzen_klammern_bleibt_heil() { + let e = Ereignis { + frame: 0, + staende: Vec::new(), + richtung: Richtung::Befehl, + text: "when deck1.beats_left < 32 do master.queue_next".into(), + }; + let zurueck = Ereignis::parse(&e.zeile(48_000)).expect("nicht lesbar"); + assert_eq!( + zurueck.text, + "when deck1.beats_left < 32 do master.queue_next" + ); + assert_eq!(zurueck.richtung, Richtung::Befehl); + } + + /// Der Text ist der Rest der Zeile, nicht eine Liste von Wörtern. + /// + /// Das Protokoll lässt zwischen zwei Wörtern mehrere Leerzeichen stehen, + /// damit ein Wert später einmal welche enthalten darf. Wer die Zeile über + /// Wortgrenzen wieder zusammensetzt, ändert genau diese Befehle still ab — + /// und die Mitschrift behauptete dann etwas anderes, als gesagt wurde. + #[test] + fn mehrere_leerzeichen_im_text_ueberleben() { + let e = Ereignis { + frame: 0, + staende: Vec::new(), + richtung: Richtung::Befehl, + text: "set deck1.title Zwei Leerzeichen".into(), + }; + let zurueck = Ereignis::parse(&e.zeile(48_000)).expect("nicht lesbar"); + assert_eq!(zurueck.text, "set deck1.title Zwei Leerzeichen"); + } + + /// Ein Deck ohne Beatgrid steht gar nicht in der Zeile — es hat keinen + /// Beat, auf den sich etwas beziehen ließe. + #[test] + fn ohne_stand_geht_es_auch() { + let e = Ereignis { + frame: 4_800, + staende: Vec::new(), + richtung: Richtung::Meldung, + text: "ok".into(), + }; + let zeile = e.zeile(48_000); + assert_eq!(zeile, "4800 0.100 < ok"); + assert_eq!(Ereignis::parse(&zeile), Some(e)); + } + + /// Maßgeblich ist der Frame. Stünde in der Datei eine abweichende + /// Sekundenangabe, dürfte sie nichts verschieben. + #[test] + fn die_sekunden_in_der_zeile_sind_nur_bequemlichkeit() { + let e = Ereignis::parse("96000 999.999 > set channel1.fader 0.5").expect("nicht lesbar"); + assert_eq!(e.frame, 96_000); + assert!((e.sekunden(48_000) - 2.0).abs() < 1e-9); + } + + #[test] + fn die_phrasenlage_zaehlt_ab_der_letzten_grenze() { + assert_eq!(stand(0, 64.0).phrasenlage(), Some(0.0)); + assert!((stand(0, 68.25).phrasenlage().unwrap() - 4.25).abs() < 1e-9); + // Vor dem Nullpunkt darf sie nicht negativ werden. + assert!((stand(0, -1.0).phrasenlage().unwrap() - 15.0).abs() < 1e-9); + } + + /// Ein stehendes Deck hat keine Phrasenlage. + /// + /// Ausrechenbar wäre sie — es steht ja irgendwo zwischen zwei Grenzen. + /// Aber sie beantwortete die Frage „kam es neben der Eins?" mit einer Zahl + /// über ein Deck, das gar nicht eingesetzt hat. Genau so entsteht ein + /// Bericht, der etwas behauptet, das nie geschehen ist. + #[test] + fn ein_stehendes_deck_hat_keine_phrasenlage() { + assert_eq!(stehend(1, -0.998).phrasenlage(), None); + assert_eq!(stehend(1, 8.0).phrasenlage(), None); + } + + #[test] + fn ob_ein_deck_lief_ueberlebt_die_zeile() { + let e = Ereignis { + frame: 0, + staende: vec![stand(0, 48.0), stehend(1, -0.998)], + richtung: Richtung::Befehl, + text: "ramp master.crossfader 1 32".into(), + }; + let zeile = e.zeile(48_000); + assert!(zeile.contains("deck2=-0.998/16~"), "{zeile}"); + assert!(zeile.contains("deck1=48.000/16 "), "{zeile}"); + assert_eq!(Ereignis::parse(&zeile), Some(e)); + } + + fn beispiel() -> String { + [ + "# musik-mitschrift 1", + "# mitschnitt /pfad/set.wav", + "# rate 48000", + "0 0.000 deck1=0.000/16 > do deck1.play", + "120960 2.520 deck1=64.000/16 > ramp master.crossfader 1 32", + "121000 2.521 deck1=64.021/16 < ok plan 3 ramp master.crossfader nach 1", + ] + .join("\n") + } + + #[test] + fn eine_ganze_mitschrift_laesst_sich_lesen() { + let p = aus_text(&beispiel()).expect("nicht lesbar"); + assert_eq!(p.kopf.rate, 48_000); + assert_eq!(p.kopf.mitschnitt, "/pfad/set.wav"); + assert_eq!(p.ereignisse.len(), 3); + assert_eq!(p.unlesbar, 0); + assert_eq!(p.ereignisse[1].frame, 120_960); + assert_eq!(p.ereignisse[1].stand(0).map(|s| s.beat), Some(64.0)); + } + + /// Ein Absturz schneidet die letzte Zeile ab. Der Rest muss trotzdem + /// lesbar bleiben — und die Lücke muss dastehen. + #[test] + fn eine_abgeschnittene_zeile_macht_nicht_die_datei_unlesbar() { + let text = format!("{}\n1210", beispiel()); + let p = aus_text(&text).expect("nicht lesbar"); + assert_eq!(p.ereignisse.len(), 3); + assert_eq!(p.unlesbar, 1, "die Lücke wurde verschwiegen"); + } + + #[test] + fn ohne_kopf_ist_es_keine_mitschrift() { + assert!(aus_text("0 0.000 > do deck1.play").is_err()); + } + + /// Ohne Rate sind die Frames zeitlos. Lieber absagen als schweigend mit 0 + /// rechnen. + #[test] + fn ohne_rate_ist_es_keine_mitschrift() { + let text = "# musik-mitschrift 1\n# mitschnitt a.wav\n0 0.000 > do deck1.play"; + assert!(aus_text(text).is_err()); + } + + #[test] + fn eine_neuere_fassung_wird_nicht_geraten() { + let text = "# musik-mitschrift 99\n# rate 48000\n"; + let fehler = aus_text(text).expect_err("neuere Fassung wurde gelesen"); + assert!(fehler.contains("99"), "{fehler}"); + } + + #[test] + fn geschriebenes_laesst_sich_wieder_lesen() { + let ordner = std::env::temp_dir().join(format!("mitschrift-{}", std::process::id())); + std::fs::create_dir_all(&ordner).expect("Ordner"); + let pfad = ordner.join("set.mitschrift"); + + { + let mut m = + Mitschrift::starten(&pfad, Path::new("/pfad/set.wav"), 48_000).expect("starten"); + m.halten(0, &[stand(0, 0.0)], Richtung::Befehl, "do deck1.play"); + m.halten( + 120_960, + &[stand(0, 64.0)], + Richtung::Befehl, + "ramp master.crossfader 1 32", + ); + m.halten(121_000, &[stand(0, 64.021)], Richtung::Meldung, "ok plan 3"); + assert_eq!(m.fehler(), 0); + } + + let p = lesen(&pfad).expect("lesen"); + assert_eq!(p.kopf.mitschnitt, "/pfad/set.wav"); + assert_eq!(p.ereignisse.len(), 3); + assert_eq!(p.ereignisse[2].richtung, Richtung::Meldung); + assert_eq!(p.unlesbar, 0); + + std::fs::remove_dir_all(&ordner).ok(); + } + + /// Eine Antwort mit vierzig Treffern würde die Mitschrift zuschütten. + #[test] + fn eine_mehrzeilige_antwort_wird_gekuerzt() { + let ordner = std::env::temp_dir().join(format!("mitschrift-lang-{}", std::process::id())); + std::fs::create_dir_all(&ordner).expect("Ordner"); + let pfad = ordner.join("set.mitschrift"); + + { + let mut m = Mitschrift::starten(&pfad, Path::new("set.wav"), 48_000).expect("starten"); + m.halten(0, &[], Richtung::Meldung, "ok 3 Treffer\neins\nzwei\ndrei"); + } + + let p = lesen(&pfad).expect("lesen"); + assert_eq!(p.ereignisse.len(), 1, "aus einer Antwort wurden mehrere"); + assert_eq!(p.ereignisse[0].text, "ok 3 Treffer … (+3 Zeilen)"); + + std::fs::remove_dir_all(&ordner).ok(); + } + + #[test] + fn eine_leere_antwort_schreibt_nichts() { + let ordner = std::env::temp_dir().join(format!("mitschrift-leer-{}", std::process::id())); + std::fs::create_dir_all(&ordner).expect("Ordner"); + let pfad = ordner.join("set.mitschrift"); + + { + let mut m = Mitschrift::starten(&pfad, Path::new("set.wav"), 48_000).expect("starten"); + m.halten(0, &[], Richtung::Meldung, " \n "); + } + + assert_eq!(lesen(&pfad).expect("lesen").ereignisse.len(), 0); + std::fs::remove_dir_all(&ordner).ok(); + } +} diff --git a/crates/control/src/protokoll.rs b/crates/control/src/protokoll.rs new file mode 100644 index 0000000..1c9a4a1 --- /dev/null +++ b/crates/control/src/protokoll.rs @@ -0,0 +1,1915 @@ +//! Zeilenprotokoll für die Steuerung von außen. +//! +//! Bewusst Text und bewusst zeilenweise: Man kann es mit `nc` von Hand +//! sprechen, in einem Log lesen und ohne Bibliothek erzeugen. Ein +//! Binärprotokoll wäre schneller, aber hier fließen Reglerbewegungen, keine +//! Samples — ein paar Dutzend Zeilen je Sekunde im schlimmsten Fall. +//! +//! Die Trennung von der Steckdose ist Absicht: Hier drin kommt kein Socket vor, +//! nur Zeile rein, Zeile raus. Damit lässt sich das ganze Protokoll ohne +//! Netzwerk testen, und der Server daneben ist nur noch Verkabelung. +//! +//! ```text +//! > list +//! < control deck1.play schalter - rw Läuft das Deck +//! > get deck1.bpm +//! < value deck1.bpm 128.021 +//! > set channel1.fader 0.8 +//! < ok channel1.fader 0.8 +//! > setn channel1.fader 1.0 (normiert, 0..1 — für MIDI) +//! < ok channel1.fader 1 +//! ``` + +use crate::mitschrift::Richtung; +use crate::pult::Steuerpult; +use crate::schluessel::Schluessel; +use crate::wert::{Art, Wert}; + +/// Was eine Verbindung sich merkt. +/// +/// Nur die Abonnements. Ohne sie müsste jeder Bediener pollen, und wer auf das +/// Ende eines Tracks wartet, pollt dann im Sekundentakt eine Anlage, die +/// gerade läuft. +#[derive(Default)] +pub struct Sitzung { + abos: Vec<(Schluessel, Option)>, + /// Ab welcher Ereignisnummer gemeldet wird. `None` heißt: nicht + /// abonniert. + /// + /// Getrennt von den Abos oben, weil Ereignisse keine Werte sind: Bei einem + /// Wert interessiert der Stand, bei einem Ereignis **jede** Zeile. Wer nur + /// den zuletzt geschriebenen Stand meldete, verlöre neun von zehn + /// Ablösungen — der Taktgeber läuft alle 5 ms, verglichen wird alle 50. + ereignis_ab: Option, +} + +impl Sitzung { + pub fn neu() -> Sitzung { + Sitzung::default() + } + + pub fn hat_abos(&self) -> bool { + !self.abos.is_empty() + } + + /// Was sich seit dem letzten Blick geändert hat. + /// + /// Ehrlich gesagt: Das Pult meldet nichts von sich aus, hier wird + /// verglichen. Für Werte, die im Audio-Thread laufen — die Position ändert + /// sich mit jedem Sample — wäre eine echte Benachrichtigung ohnehin + /// sinnlos; man will sie in Abständen, nicht in Fluten. Der Gewinn liegt + /// darin, dass der Bediener das nicht selbst bauen muss. + pub fn aenderungen(&mut self, pult: &Steuerpult) -> Vec { + let mut zeilen = Vec::new(); + + // Ereignisse zuerst: Sie erklären, warum ein Wert sich gleich anders + // liest, als der Abonnent ihn hinterlassen hat. + if let Some(ab) = &mut self.ereignis_ab { + let (neue, verloren) = pult.ereignisse.seit(*ab); + if verloren > 0 { + zeilen.push(format!( + "warnung {verloren} Ereignisse verloren — zu langsam gelesen" + )); + } + zeilen.extend(neue.into_iter().map(|z| format!("event {z}"))); + *ab = pult.ereignisse.nummer(); + } + + for (schluessel, letzter) in &mut self.abos { + // Das Ereignis-Control steht in den Abos, damit `sub`, `unsub` und + // die Zählung eine Stelle haben — gemeldet wird es aber oben und + // zeilenweise, nicht als Wert. + if ist_ereignis(schluessel) { + continue; + } + let Ok(jetzt) = pult.lies(schluessel) else { + continue; + }; + if letzter.as_ref() == Some(&jetzt) { + continue; + } + zeilen.push(format!("value {schluessel} {jetzt}")); + *letzter = Some(jetzt); + } + + zeilen + } +} + +/// Beantwortet eine Zeile. Die Antwort kann mehrzeilig sein. +pub fn behandle(pult: &mut Steuerpult, sitzung: &mut Sitzung, zeile: &str) -> String { + let zeile = zeile.trim(); + if zeile.is_empty() { + return String::new(); + } + + // Nicht `splitn`: Zwischen zwei Wörtern können mehrere Leerzeichen + // stehen, und `splitn` macht daraus leere Felder. Der Rest bleibt am + // Stück, damit ein Wert später auch einmal Leerzeichen enthalten darf. + let mut rest = zeile; + let befehl = wort(&mut rest); + let erstes = Some(wort(&mut rest)).filter(|w| !w.is_empty()); + let zweites = Some(rest.trim()).filter(|w| !w.is_empty()); + + // Vor dem Ausführen: Der festgehaltene Frame soll der sein, an dem der + // Befehl ankam, nicht der, an dem die Antwort fertig war. + if aendert(befehl) { + pult.halten(Richtung::Befehl, zeile); + } + + let antwort = match befehl { + "list" => list(pult, erstes), + "get" => get(pult, erstes), + "set" => set(pult, erstes, zweites, false), + "setn" => set(pult, erstes, zweites, true), + "do" => ausloesen(pult, erstes, zweites), + "sub" => abonnieren(pult, sitzung, erstes, zweites), + "unsub" => abbestellen(sitzung, erstes, zweites), + "ramp" => rampe(pult, erstes, zweites), + "in" => spaeter(pult, erstes, zweites), + "when" => wenn(pult, erstes, zweites), + "plan" => plan_zeigen(pult), + "cancel" => streichen(pult, erstes), + "note" => notieren(zeile), + "help" => HILFE.to_string(), + andere => format!("err unbekannter Befehl: {andere} (help hilft)"), + }; + + if aendert(befehl) { + // Auch die Antwort, denn sie trägt die Plannummer — ohne sie ließe + // sich ein späteres „plan 3 fertig" keinem Auftrag zuordnen. Und ein + // `err` gehört genauso hinein: Ein Befehl, der nicht durchkam, erklärt + // im Nachhinein mehr als einer, der durchkam. + pult.halten(Richtung::Meldung, &antwort); + } + + antwort +} + +/// Ob ein Befehl etwas verändert — und damit in die Mitschrift gehört. +/// +/// `list`, `get`, `plan`, `help` und die Abos fragen nur nach. Sie stünden in +/// jeder zweiten Zeile und würden zuschütten, was die Mitschrift zeigen soll: +/// die Bewegungen. Ein Ereignisprotokoll, in dem man suchen muss, ist eines, +/// in das niemand sieht. +fn aendert(befehl: &str) -> bool { + matches!( + befehl, + // `note` ändert nichts und steht trotzdem hier: Diese Liste entscheidet, + // was in die Mitschrift kommt, und eine Notiz, die nicht mitgeschrieben + // wird, ist keine. + "set" | "setn" | "do" | "ramp" | "in" | "when" | "cancel" | "note" + ) +} + +/// Nimmt das nächste Wort und lässt den Rest stehen. +fn wort<'a>(rest: &mut &'a str) -> &'a str { + let getrimmt = rest.trim_start(); + match getrimmt.find(char::is_whitespace) { + Some(ende) => { + *rest = &getrimmt[ende..]; + &getrimmt[..ende] + } + None => { + *rest = ""; + getrimmt + } + } +} + +const HILFE: &str = "\ +ok Befehle: + list [praefix] Controls aufzählen, gefiltert nach Namensanfang + get Wert lesen + set Wert setzen; '-' leert (Hot Cues) + setn <0..1> Wert normiert setzen, für MIDI + do [arg] Aktion auslösen: sync, load, jump_cue, beatjump, search + sub ... Änderungen melden lassen, statt zu fragen + unsub [control]... Abbestellen; ohne Argument alles + ramp [deck] [form] Regler über Beats bewegen + Formen: linear, weich (S-Kurve), spaet (lange nichts), frueh (sofort viel) + in Befehl nach so vielen Beats ausführen + in phrase[+n] Befehl auf der nächsten Phrasengrenze ausführen + in deckN nach den Takten dieses Decks statt des ersten + when < Befehl, sobald ein Wert die Schwelle reisst + plan was vorgemerkt ist + cancel [id] Vorgemerktes zurücknehmen; ohne Argument alles + note eine Zeile in die Mitschrift schreiben, sonst nichts + help diese Übersicht"; + +/// `note ` — eine Zeile in die Mitschrift, und weiter nichts. +/// +/// Sie ändert an der Anlage nichts; sie hält fest, was sonst nirgends stünde. +/// Gebaut wurde sie für die Freigabe eines Agenten: Unter welcher Vollmacht ein +/// Set gefahren wurde, ist genau die Frage, die man hinterher stellt, und ohne +/// diesen Befehl wäre sie nicht zu beantworten. +/// +/// Die Zeile landet in der Mitschrift, weil [`aendert`] `note` mitzählt — nicht +/// weil hier etwas geschrieben würde. Das ist der ganze Trick: Der Befehl tut +/// nichts und wird trotzdem festgehalten. +fn notieren(zeile: &str) -> String { + let text = zeile.strip_prefix("note").unwrap_or("").trim(); + if text.is_empty() { + return "err note braucht einen Text".to_string(); + } + format!("ok note {text}") +} + +/// `ramp [deck]` +/// +/// Der Verb, der aus Reglerstellungen einen Übergang macht. Ohne ihn müsste +/// ein Bediener die Bewegung selbst in Schritte zerlegen und dazwischen +/// schlafen — über eine Leitung, die dafür zu ungenau ist. +fn rampe(pult: &mut Steuerpult, control: Option<&str>, rest: Option<&str>) -> String { + let Some(control) = control.and_then(Schluessel::parse) else { + return "err ramp braucht ein Control: ramp [deck] [form]".into(); + }; + let mut rest = rest.unwrap_or(""); + let ziel = wort(&mut rest); + let beats = wort(&mut rest); + + let (Ok(ziel), Ok(beats)) = (ziel.parse::(), beats.parse::()) else { + return "err ramp braucht Ziel und Länge in Beats: ramp channel1.fader 0 16".into(); + }; + + // Deck und Form stehen hinten, in beliebiger Reihenfolge. Erkannt werden + // sie daran, **was** sie sind, nicht an ihrer Stelle: Die Namen sind + // disjunkt, und eine feste Reihenfolge wäre nur eine Falle mehr für den, + // der die Form angeben will, aber kein Deck. + let mut takt_deck = None; + let mut form = crate::zeitplan::Form::default(); + loop { + let angabe = wort(&mut rest); + if angabe.is_empty() { + break; + } + if let Some(f) = crate::zeitplan::Form::parse(angabe) { + form = f; + } else if let Some(crate::schluessel::Gruppe::Deck(i)) = + crate::schluessel::Gruppe::parse(angabe) + { + takt_deck = Some(i); + } else { + let formen: Vec<&str> = crate::zeitplan::Form::ALLE + .iter() + .map(|f| f.name()) + .collect(); + return format!( + "err {angabe} ist weder ein Deck noch eine Form ({})", + formen.join(", ") + ); + } + } + + let mut plan = std::mem::take(&mut pult.plan); + let ergebnis = crate::zeitplan::rampe_planen( + pult, + &mut plan, + control.clone(), + ziel, + beats, + takt_deck, + form, + ); + pult.plan = plan; + + match ergebnis { + // Gezählt wird die Rampe nicht hier, sondern wenn sie ankommt: Eine + // vorgemerkte Bewegung, die jemand ablöst, hat nicht stattgefunden. + Ok(id) => format!( + "ok plan {id} ramp {control} nach {ziel} über {beats} Beats, {}", + form.name() + ), + Err(e) => format!("err {e}"), + } +} + +/// `in ` +/// +/// **`phrase` ist der Grund, warum es dieses Verb in dieser Form gibt.** Ein +/// Übergang beginnt auf der Eins einer Phrase, nicht nach einer runden Zahl +/// Beats. Wer das mit `in 32` nachbaut, trifft irgendwo hin — und der Mix +/// klingt danach, egal wie sauber alles andere sitzt. +fn spaeter(pult: &mut Steuerpult, erstes: Option<&str>, rest: Option<&str>) -> String { + // `in deck2 phrase+16 …` — der Taktgeber darf ein anderer sein als der, + // auf den der Befehl wirkt. Gebraucht wird das, sobald ein Deck in einer + // Schleife läuft: Dessen Beat wiederholt sich, und alles, was daran hängt, + // wiederholt sich mit oder kommt nie an. + let mut erstes = erstes.unwrap_or(""); + let mut rest = rest.unwrap_or(""); + let mut takt_deck = None; + if let Some(crate::schluessel::Gruppe::Deck(i)) = + Schluessel::parse(&format!("{erstes}.play")).map(|k| k.gruppe) + { + if i < pult.decks().len() { + takt_deck = Some(i); + erstes = wort(&mut rest); + } + } + + let beats = match beats_bezug(pult, erstes, takt_deck) { + Ok(b) => b, + Err(e) => return e, + }; + let befehl = rest.trim(); + if befehl.is_empty() { + return "err in braucht einen Befehl: in 16 set channel2.fader 0.9".into(); + } + + let mut plan = std::mem::take(&mut pult.plan); + let ergebnis = + crate::zeitplan::spaeter_planen(pult, &mut plan, beats, befehl.to_string(), takt_deck); + pult.plan = plan; + + match ergebnis { + Ok(id) => format!("ok plan {id} in {beats} Beats: {befehl}"), + Err(e) => format!("err {e}"), + } +} + +/// `when <\< oder \>> ` +/// +/// Das Gegenstück zu `in`: Der eine wartet auf Takte, der andere auf einen +/// Zustand. „Wenn Deck A noch 32 Beats hat, leg den nächsten auf" ist die +/// Frage, die beim Auflegen wirklich gestellt wird — und ohne dieses Verb +/// müsste sie jemand durch Abonnieren und Nachrechnen beantworten. +fn wenn(pult: &mut Steuerpult, control: Option<&str>, rest: Option<&str>) -> String { + let Some(control) = control.and_then(Schluessel::parse) else { + return "err when braucht ein Control: when deck1.beats_left < 32 do master.queue_next" + .into(); + }; + let mut rest = rest.unwrap_or(""); + let vergleich = wort(&mut rest); + let schwelle = wort(&mut rest); + let zeile = rest.trim(); + + let Some(vergleich) = crate::zeitplan::Vergleich::parse(vergleich) else { + return format!("err {vergleich} ist kein Vergleich — erlaubt sind < und >"); + }; + let Ok(schwelle) = schwelle.parse::() else { + return format!("err {schwelle} ist keine Zahl"); + }; + if zeile.is_empty() { + return "err when braucht einen Befehl: when deck1.beats_left < 32 do master.queue_next" + .into(); + } + + let mut plan = std::mem::take(&mut pult.plan); + let ergebnis = crate::zeitplan::wenn_planen( + pult, + &mut plan, + control.clone(), + vergleich, + schwelle, + zeile.to_string(), + ); + pult.plan = plan; + + match ergebnis { + Ok(id) => format!( + "ok plan {id} wenn {control} {} {schwelle}: {zeile}", + vergleich.zeichen() + ), + Err(e) => format!("err {e}"), + } +} + +/// Liest die Wartezeit eines `in`: eine Zahl, `phrase` oder `phrase+n`. +/// +/// Der Bezugspunkt für `phrase` ist dasselbe Deck, auf dessen Takten der +/// Auftrag dann liegt — das erste mit Beatgrid. Sonst zählte man die Beats des +/// einen und die Phrase des anderen. +fn beats_bezug(pult: &Steuerpult, roh: &str, takt_deck: Option) -> Result { + if let Ok(zahl) = roh.parse::() { + return Ok(zahl); + } + + let (wort, versatz) = match roh.split_once('+') { + Some((w, rest)) => { + let Ok(v) = rest.parse::() else { + return Err(format!("err {rest} ist keine Zahl Beats")); + }; + (w, v) + } + None => (roh, 0.0), + }; + if wort != "phrase" { + return Err( + "err in braucht Beats oder phrase: in 16 do deck2.sync, in phrase do deck2.sync".into(), + ); + } + + let Some(deck) = takt_deck.or_else(|| { + (0..pult.decks().len()).find(|i| crate::zeitplan::beat_jetzt(pult, *i).is_some()) + }) else { + return Err("err kein Deck mit Beatgrid — phrase hat keinen Bezugspunkt".into()); + }; + let Some(bis) = pult.beats_bis_phrase(deck) else { + return Err("err die Phrasenlage ist nicht bekannt".into()); + }; + Ok(bis + versatz) +} + +/// Was vorgemerkt ist — das gemeinsame Blatt, wenn mehrere bedienen. +fn plan_zeigen(pult: &Steuerpult) -> String { + let mut zeilen = Vec::new(); + + for a in pult.plan.auftraege() { + let jetzt = crate::zeitplan::beat_jetzt(pult, a.takt_deck).unwrap_or(a.ab_beat); + match &a.was { + crate::zeitplan::Was::Rampe(r) => zeilen.push(format!( + "plan {} ramp {} {:.4} → {:.4} über {} Beats {}, {:.1} gelaufen (deck{})", + a.id, + r.control, + r.von, + r.nach, + r.beats, + r.form.name(), + (jetzt - a.ab_beat).max(0.0), + a.takt_deck + 1 + )), + crate::zeitplan::Was::Spaeter { beim_beat, zeile } => zeilen.push(format!( + "plan {} in {:.1} Beats: {zeile} (deck{})", + a.id, + (beim_beat - jetzt).max(0.0), + a.takt_deck + 1 + )), + // Mit dem Ist-Wert dahinter: Wer den Plan liest, will wissen, wie + // weit die Schwelle noch weg ist, und nicht bloß, dass es sie gibt. + crate::zeitplan::Was::Wenn { + control, + vergleich, + schwelle, + zeile, + } => { + let steht = match pult.lies(control) { + Ok(crate::wert::Wert::Zahl(v)) => format!("{v:.2}"), + _ => "?".into(), + }; + zeilen.push(format!( + "plan {} wenn {control} {} {schwelle}: {zeile} (steht bei {steht})", + a.id, + vergleich.zeichen() + )) + } + } + } + + zeilen.push(format!("ok {} vorgemerkt", pult.plan.auftraege().len())); + zeilen.join("\n") +} + +/// `cancel [id]` +/// +/// **Auch das gehört in den Ring.** Streicht ein Bediener den Auftrag eines +/// anderen, verschwindet der aus dem Plan — und der, der ihn vorgemerkt hat, +/// sieht nur noch eine Lücke. Für ein Team ist das dieselbe stille Niederlage +/// wie eine abgelöste Rampe. +fn streichen(pult: &mut Steuerpult, id: Option<&str>) -> String { + let gewaehlt = match id { + None => None, + Some(text) => match text.parse::() { + Ok(id) => Some(id), + Err(_) => return format!("err {text} ist keine Plan-Nummer"), + }, + }; + let weg = pult.plan.streichen(gewaehlt); + if weg > 0 { + let welche = match gewaehlt { + Some(id) => format!("plan {id} gestrichen"), + None => format!("plan alle {weg} gestrichen"), + }; + pult.ereignisse.melden(&welche); + } + format!("ok {weg} gestrichen") +} + +fn list(pult: &Steuerpult, praefix: Option<&str>) -> String { + let mut zeilen = Vec::new(); + + for (schluessel, b) in pult.liste() { + let name = schluessel.to_string(); + if let Some(p) = praefix { + if !name.starts_with(p) { + continue; + } + } + + // Eine Spalte für „welche Werte sind erlaubt". Bei einer Aktion ist + // das ihr Argument — ohne das wüsste ein Aufrufer nicht, dass `load` + // einen Pfad will und `jump_cue` eine Zahl von 1 bis 8. + let raum = match (b.art, b.bereich, b.auswahl) { + (Art::Aktion, _, _) if !b.argument.is_empty() => b.argument.to_string(), + (_, _, auswahl) if !auswahl.is_empty() => auswahl.join("|"), + // Ein offenes Ende bleibt offen. Ohne das stünde in der + // Selbstbeschreibung die ausgeschriebene Zahl `f64::MIN` — dreihundert + // Stellen, die niemandem sagen, dass es keine Untergrenze gibt. + (_, Some((min, max)), _) if min == f64::MIN && max == f64::MAX => "..".to_string(), + (_, Some((min, max)), _) if min == f64::MIN => format!("..{max}"), + (_, Some((min, max)), _) if max == f64::MAX => format!("{min}.."), + (_, Some((min, max)), _) => format!("{min}..{max}"), + _ => "-".to_string(), + }; + + zeilen.push(format!( + "control {name} {} {raum} {} {} {}", + b.art.name(), + b.einheit.name(), + if b.schreibbar { "rw" } else { "r" }, + b.text + )); + } + + if zeilen.is_empty() { + return "ok 0 Controls".to_string(); + } + + let anzahl = zeilen.len(); + zeilen.push(format!("ok {anzahl} Controls")); + zeilen.join("\n") +} + +fn get(pult: &Steuerpult, control: Option<&str>) -> String { + let Some(text) = control.filter(|t| !t.is_empty()) else { + return "err get braucht ein Control".to_string(); + }; + let Some(schluessel) = Schluessel::parse(text) else { + return format!("err kein gültiger Name: {text}"); + }; + + match pult.lies(&schluessel) { + Ok(wert) => format!("value {schluessel} {wert}"), + Err(e) => format!("err {e}"), + } +} + +fn set(pult: &mut Steuerpult, control: Option<&str>, wert: Option<&str>, normiert: bool) -> String { + let Some(text) = control.filter(|t| !t.is_empty()) else { + return "err set braucht ein Control".to_string(); + }; + let Some(roh) = wert.filter(|t| !t.is_empty()) else { + return format!("err set {text} braucht einen Wert"); + }; + let Some(schluessel) = Schluessel::parse(text) else { + return format!("err kein gültiger Name: {text}"); + }; + + if normiert { + let Ok(norm) = roh.parse::() else { + return format!("err {roh} ist keine Zahl"); + }; + return match pult.schreibe_normiert(&schluessel, norm) { + Ok(()) => bestaetigen(pult, &schluessel), + Err(e) => format!("err {e}"), + }; + } + + let Some(b) = pult.beschreibung(&schluessel) else { + return format!("err unbekanntes Control: {schluessel}"); + }; + + let parsed = match b.art { + Art::Schalter => match roh { + "1" | "on" | "an" | "true" => Wert::Schalter(true), + "0" | "off" | "aus" | "false" => Wert::Schalter(false), + _ => return format!("err {schluessel} erwartet 0 oder 1, nicht {roh}"), + }, + Art::Auswahl => Wert::Auswahl(roh.to_string()), + Art::Text => Wert::Text(roh.to_string()), + Art::Aktion => return format!("err {schluessel} ist eine Aktion — mit 'do' auslösen"), + Art::Zahl => { + if roh == "-" { + // Leeren statt auf null setzen — bei einem Hot Cue ist das ein + // Unterschied. + Wert::Leer + } else { + match roh.parse::() { + Ok(v) => Wert::Zahl(v), + Err(_) => return format!("err {roh} ist keine Zahl"), + } + } + } + }; + + match pult.schreibe(&schluessel, parsed) { + Ok(()) => bestaetigen(pult, &schluessel), + Err(e) => format!("err {e}"), + } +} + +/// Sammelt die Control-Namen einer Zeile ein. +fn namen(erstes: Option<&str>, rest: Option<&str>) -> Vec { + let mut aus = Vec::new(); + if let Some(e) = erstes { + aus.push(e.to_string()); + } + if let Some(r) = rest { + aus.extend(r.split_whitespace().map(str::to_string)); + } + aus +} + +/// Ob ein Schlüssel das Ereignis-Control meint. +fn ist_ereignis(k: &Schluessel) -> bool { + k.gruppe == crate::schluessel::Gruppe::Master && k.element == "events" +} + +fn abonnieren( + pult: &Steuerpult, + sitzung: &mut Sitzung, + erstes: Option<&str>, + rest: Option<&str>, +) -> String { + let gewuenscht = namen(erstes, rest); + if gewuenscht.is_empty() { + return "err sub braucht mindestens ein Control".to_string(); + } + + let mut angenommen = 0usize; + for name in gewuenscht { + let Some(schluessel) = Schluessel::parse(&name) else { + return format!("err kein gültiger Name: {name}"); + }; + // Nur abonnieren, was sich auch lesen lässt — ein Abo auf eine Aktion + // würde nie etwas melden und sähe trotzdem aus, als täte es das. + if let Err(e) = pult.lies(&schluessel) { + return format!("err {e}"); + } + if sitzung.abos.iter().any(|(k, _)| k == &schluessel) { + continue; + } + // Ereignisse steigen bei **jetzt** ein, nicht bei null: Wer sich + // gerade verbindet, soll nicht die Ablösungen von vor zehn Minuten + // nachgereicht bekommen. + if ist_ereignis(&schluessel) { + sitzung.ereignis_ab = Some(pult.ereignisse.nummer()); + } + // Ohne letzten Wert: Der erste Vergleich meldet den Ist-Zustand, und + // der Bediener muss nicht zusätzlich einmal `get` sagen. + sitzung.abos.push((schluessel, None)); + angenommen += 1; + } + + format!("ok sub {angenommen} neu, {} gesamt", sitzung.abos.len()) +} + +fn abbestellen(sitzung: &mut Sitzung, erstes: Option<&str>, rest: Option<&str>) -> String { + let namen = namen(erstes, rest); + if namen.is_empty() { + let weg = sitzung.abos.len(); + sitzung.abos.clear(); + sitzung.ereignis_ab = None; + return format!("ok unsub {weg} abbestellt"); + } + + let vorher = sitzung.abos.len(); + for name in namen { + sitzung.abos.retain(|(k, _)| k.to_string() != name); + } + // Abbestellt heißt abbestellt — sonst liefen die Ereignisse weiter, weil + // sie nicht an der Abo-Liste hängen, sondern an einer eigenen Nummer. + if !sitzung.abos.iter().any(|(k, _)| ist_ereignis(k)) { + sitzung.ereignis_ab = None; + } + format!( + "ok unsub {} abbestellt, {} gesamt", + vorher - sitzung.abos.len(), + sitzung.abos.len() + ) +} + +fn ausloesen(pult: &mut Steuerpult, control: Option<&str>, argument: Option<&str>) -> String { + let Some(text) = control.filter(|t| !t.is_empty()) else { + return "err do braucht eine Aktion".to_string(); + }; + let Some(schluessel) = Schluessel::parse(text) else { + return format!("err kein gültiger Name: {text}"); + }; + + match pult.ausloesen(&schluessel, argument) { + Ok(zeilen) => { + let mut aus = zeilen; + aus.push(format!("ok {schluessel}")); + aus.join("\n") + } + Err(e) => format!("err {e}"), + } +} + +/// Bestätigt mit dem Wert, der **wirklich** angekommen ist. +/// +/// Nicht mit dem, der gesendet wurde: Begrenzung und Rundung können ihn +/// verändert haben, und wer steuert, soll das erfahren, ohne nachzufragen. +fn bestaetigen(pult: &Steuerpult, schluessel: &Schluessel) -> String { + match pult.lies(schluessel) { + Ok(wert) => format!("ok {schluessel} {wert}"), + Err(e) => format!("err {e}"), + } +} + +#[cfg(test)] +mod tests { + + /// Ein offenes Ende bleibt offen — in beide Richtungen. + /// + /// `beats_to_outro` hat keine Untergrenze: Sobald das Outro läuft, wird die + /// Zahl negativ. Ohne diesen Fall stand in der Selbstbeschreibung die + /// ausgeschriebene kleinste Fließkommazahl, dreihundert Stellen lang. + #[test] + fn ein_offener_bereich_wird_nicht_ausgeschrieben() { + let (pult, _runner) = crate::testing::pult_mit_zwei_decks(); + let mut sitzung = Sitzung::neu(); + let mut pult = pult; + + let antwort = behandle(&mut pult, &mut sitzung, "list deck1.beats_to_outro"); + assert!( + antwort.contains(" zahl .. beats "), + "der Bereich steht ausgeschrieben da: {antwort}" + ); + assert!(!antwort.contains("17976931348623157"), "{antwort}"); + } + + use super::*; + use crate::testing::pult_mit_zwei_decks; + + #[test] + fn setzen_und_lesen_ueber_das_protokoll() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + assert_eq!( + behandle(&mut pult, &mut s, "set channel1.fader 0.8"), + "ok channel1.fader 0.800000" + ); + assert_eq!( + behandle(&mut pult, &mut s, "get channel1.fader"), + "value channel1.fader 0.800000" + ); + } + + #[test] + fn die_bestaetigung_zeigt_den_angekommenen_wert() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + // 9.0 wird auf 1.0 begrenzt — die Antwort sagt 1, nicht 9. + assert_eq!( + behandle(&mut pult, &mut s, "set channel1.fader 9"), + "ok channel1.fader 1" + ); + } + + #[test] + fn schalter_verstehen_wort_und_zahl() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + for eingabe in ["1", "on", "an", "true"] { + behandle(&mut pult, &mut s, "set deck1.play 0"); + assert_eq!( + behandle(&mut pult, &mut s, &format!("set deck1.play {eingabe}")), + "ok deck1.play 1", + "{eingabe} hätte einschalten müssen" + ); + } + + assert!(behandle(&mut pult, &mut s, "set deck1.play vielleicht").starts_with("err")); + } + + #[test] + fn list_zaehlt_auf_und_laesst_sich_filtern() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + let alles = behandle(&mut pult, &mut s, "list"); + assert!(alles.contains("control deck1.play schalter")); + assert!(alles.contains("control master.crossfader")); + + let nur_deck2 = behandle(&mut pult, &mut s, "list deck2."); + assert!(nur_deck2.contains("deck2.play")); + assert!(!nur_deck2.contains("deck1.play")); + assert!(!nur_deck2.contains("master.")); + } + + #[test] + fn jede_zeile_der_liste_traegt_ihre_bedeutung() { + // Der Unterschied zu Mixxx: Wer die Liste hat, braucht kein Handbuch. + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + let antwort = behandle(&mut pult, &mut s, "list deck1.tempo"); + + let zeile = antwort.lines().next().unwrap(); + assert!(zeile.contains("zahl"), "Typ fehlt: {zeile}"); + assert!(zeile.contains("0.92..1.08"), "Bereich fehlt: {zeile}"); + assert!(zeile.contains("faktor"), "Einheit fehlt: {zeile}"); + assert!(zeile.contains(" rw "), "Schreibbarkeit fehlt: {zeile}"); + assert!( + zeile.contains("Originalgeschwindigkeit"), + "Text fehlt: {zeile}" + ); + } + + #[test] + fn normiertes_setzen_ist_fuer_midi_da() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + // Ein MIDI-Regler auf Anschlag: 127/127 = 1.0 normiert. + assert_eq!( + behandle(&mut pult, &mut s, "setn channel1.trim 1.0"), + "ok channel1.trim 2" + ); + // In der Raste bei einem bipolaren Control. + assert_eq!( + behandle(&mut pult, &mut s, "setn channel1.filter 0.5"), + "ok channel1.filter 0" + ); + } + + #[test] + fn hot_cues_lassen_sich_ueber_das_protokoll_leeren() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + behandle(&mut pult, &mut s, "set deck1.cue1 8.25"); + assert_eq!( + behandle(&mut pult, &mut s, "get deck1.cue1"), + "value deck1.cue1 8.250000" + ); + + assert_eq!( + behandle(&mut pult, &mut s, "set deck1.cue1 -"), + "ok deck1.cue1 -" + ); + } + + #[test] + fn fehler_sind_immer_als_solche_erkennbar() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + for zeile in [ + "quatsch", + "get", + "get deck1.quatsch", + "get nichtsda.play", + "set channel1.fader", + "set channel1.fader laut", + "set deck1.duration 5", + "setn channel1.fader viel", + ] { + let antwort = behandle(&mut pult, &mut s, zeile); + assert!( + antwort.starts_with("err"), + "'{zeile}' hätte einen Fehler geben müssen, gab: {antwort}" + ); + } + } + + #[test] + fn leere_zeilen_erzeugen_kein_rauschen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + assert_eq!(behandle(&mut pult, &mut s, ""), ""); + assert_eq!(behandle(&mut pult, &mut s, " "), ""); + } + + #[test] + fn ueberzaehlige_leerzeichen_stoeren_nicht() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + assert_eq!( + behandle(&mut pult, &mut s, " set channel1.fader 0.5 "), + "ok channel1.fader 0.500000" + ); + } +} + +#[cfg(test)] +mod abo_tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + + #[test] + fn ein_abo_meldet_zuerst_den_ist_zustand() { + // Sonst müsste jeder Bediener nach dem Abonnieren noch einmal `get` + // sagen, um überhaupt zu wissen, wo er steht. + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + assert_eq!( + behandle(&mut pult, &mut s, "sub deck1.play"), + "ok sub 1 neu, 1 gesamt" + ); + assert_eq!(s.aenderungen(&pult), vec!["value deck1.play 0"]); + } + + #[test] + fn gemeldet_wird_nur_was_sich_geaendert_hat() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + behandle(&mut pult, &mut s, "sub deck1.play channel1.fader"); + + // Erster Durchgang: der Ist-Zustand beider. + assert_eq!(s.aenderungen(&pult).len(), 2); + // Zweiter ohne Änderung: nichts. Sonst wäre das Abo nur ein Poller + // mit mehr Verkehr. + assert!(s.aenderungen(&pult).is_empty()); + + behandle(&mut pult, &mut s, "set channel1.fader 0.4"); + assert_eq!(s.aenderungen(&pult), vec!["value channel1.fader 0.400000"]); + } + + #[test] + fn mehrere_controls_in_einer_zeile() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + let antwort = behandle(&mut pult, &mut s, "sub deck1.play deck1.finished deck2.bpm"); + assert_eq!(antwort, "ok sub 3 neu, 3 gesamt"); + + // Doppeltes Abonnieren zählt nicht doppelt. + let nochmal = behandle(&mut pult, &mut s, "sub deck1.play"); + assert_eq!(nochmal, "ok sub 0 neu, 3 gesamt"); + } + + #[test] + fn abbestellen_geht_einzeln_und_ganz() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + behandle(&mut pult, &mut s, "sub deck1.play deck2.bpm"); + + assert_eq!( + behandle(&mut pult, &mut s, "unsub deck1.play"), + "ok unsub 1 abbestellt, 1 gesamt" + ); + assert_eq!( + behandle(&mut pult, &mut s, "unsub"), + "ok unsub 1 abbestellt" + ); + assert!(!s.hat_abos()); + } + + #[test] + fn was_sich_nicht_lesen_laesst_wird_nicht_abonniert() { + // Ein Abo auf eine Aktion würde nie etwas melden und sähe trotzdem + // aus, als täte es das. + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + assert!(behandle(&mut pult, &mut s, "sub deck1.sync").starts_with("err")); + assert!(behandle(&mut pult, &mut s, "sub deck1.quatsch").starts_with("err")); + assert!(!s.hat_abos()); + } + + #[test] + fn aktionen_ueber_das_protokoll() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + let sync = behandle(&mut pult, &mut s, "do deck2.sync"); + assert!(sync.contains("sync deck2 auf deck1"), "{sync}"); + assert!(sync.ends_with("ok deck2.sync"), "{sync}"); + + let suche = behandle(&mut pult, &mut s, "do master.search techno"); + assert!( + suche.contains("track 128.00 8A /musik/techno-0.wav"), + "{suche}" + ); + + // Aktion und Wert werden nicht verwechselt. + assert!(behandle(&mut pult, &mut s, "set deck2.sync 1").starts_with("err")); + assert!(behandle(&mut pult, &mut s, "do deck1.play").starts_with("err")); + assert!(behandle(&mut pult, &mut s, "do").starts_with("err")); + } + + #[test] + fn ein_ladeauftrag_meldet_annahme_nicht_erledigung() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + let antwort = behandle(&mut pult, &mut s, "do deck1.load /musik/a.wav"); + assert!(antwort.contains("load deck1 angenommen"), "{antwort}"); + assert_eq!( + behandle(&mut pult, &mut s, "get deck1.load_status"), + "value deck1.load_status laedt" + ); + } +} + +#[cfg(test)] +mod katalog_tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + + /// Eine Aktion muss sagen, was sie erwartet. + /// + /// Ohne das steht in der Liste zwar, dass es `deck1.load` gibt, aber nicht, + /// dass sie einen Pfad will — und wer nur die Liste hat, rät. + #[test] + fn aktionen_nennen_ihr_argument() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + let antwort = behandle(&mut pult, &mut s, "list deck1.load"); + assert!( + antwort.contains("aktion "), + "das Argument fehlt: {antwort}" + ); + + let cue = behandle(&mut pult, &mut s, "list deck1.jump_cue"); + assert!(cue.contains("<1..8>"), "{cue}"); + + // Eine Aktion ohne Argument steht als solche da. + let stop = behandle(&mut pult, &mut s, "list master.record_stop"); + assert!(stop.contains("aktion -"), "{stop}"); + } +} + +#[cfg(test)] +mod plan_tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + + #[test] + fn eine_rampe_laesst_sich_ueber_das_protokoll_vormerken() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + let antwort = behandle(&mut pult, &mut s, "ramp channel1.fader 0 16"); + assert!( + antwort.starts_with("ok plan 1 ramp channel1.fader"), + "{antwort}" + ); + + // Und steht danach im gemeinsamen Blatt. + let plan = behandle(&mut pult, &mut s, "plan"); + assert!(plan.contains("plan 1 ramp channel1.fader"), "{plan}"); + assert!(plan.ends_with("ok 1 vorgemerkt"), "{plan}"); + } + + #[test] + fn ein_befehl_laesst_sich_auf_beats_legen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + let antwort = behandle(&mut pult, &mut s, "in 32 do deck2.sync"); + assert!( + antwort.starts_with("ok plan 1 in 32 Beats: do deck2.sync"), + "{antwort}" + ); + assert!(behandle(&mut pult, &mut s, "plan").contains("do deck2.sync")); + } + + #[test] + fn vorgemerktes_laesst_sich_zuruecknehmen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + behandle(&mut pult, &mut s, "ramp channel1.fader 0 16"); + behandle(&mut pult, &mut s, "ramp channel2.fader 1 16"); + + assert_eq!(behandle(&mut pult, &mut s, "cancel 1"), "ok 1 gestrichen"); + assert_eq!(behandle(&mut pult, &mut s, "cancel"), "ok 1 gestrichen"); + assert!(behandle(&mut pult, &mut s, "plan").ends_with("ok 0 vorgemerkt")); + } + + #[test] + fn unsinnige_plaene_werden_benannt_abgewiesen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + for zeile in [ + "ramp", + "ramp channel1.fader", + "ramp channel1.fader 0", + "ramp channel1.fader 0 acht", + "ramp deck1.play 1 8", + "ramp channel1.fader 0 8 channel2", + "in", + "in 16", + "in acht do deck2.sync", + "cancel siebzehn", + ] { + let antwort = behandle(&mut pult, &mut s, zeile); + assert!(antwort.starts_with("err"), "'{zeile}' → {antwort}"); + } + } + + /// **Eine Notiz ändert nichts und steht trotzdem in der Mitschrift.** + /// + /// Sie ist der einzige Befehl, der genau dafür da ist. Unter welcher + /// Vollmacht ein Agent ein Set gefahren hat, stünde sonst nirgends. + #[test] + fn eine_notiz_wird_angenommen_und_mitgeschrieben() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + let antwort = behandle(&mut pult, &mut s, "note freigabe mischen von felix"); + assert_eq!(antwort, "ok note freigabe mischen von felix"); + assert!( + aendert("note"), + "eine Notiz, die nicht mitgeschrieben wird, ist keine" + ); + } + + #[test] + fn eine_leere_notiz_wird_abgewiesen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + assert!(behandle(&mut pult, &mut s, "note").starts_with("err")); + assert!(behandle(&mut pult, &mut s, "note ").starts_with("err")); + } + + #[test] + fn die_hilfe_nennt_die_neuen_verben() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + let hilfe = behandle(&mut pult, &mut s, "help"); + + for verb in ["ramp", "in ", "plan", "cancel"] { + assert!(hilfe.contains(verb), "{verb} fehlt in der Hilfe"); + } + } +} + +#[cfg(test)] +mod warteschlangen_tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + + fn mit_liste() -> (Steuerpult, audio_engine::EngineRunner, Sitzung) { + let (mut pult, runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + behandle(&mut pult, &mut s, "do master.queue_add /musik/eins.wav"); + behandle(&mut pult, &mut s, "do master.queue_add /musik/zwei.wav"); + (pult, runner, s) + } + + #[test] + fn die_liste_wird_der_reihe_nach_abgearbeitet() { + let (mut pult, _runner, mut s) = mit_liste(); + + let liste = behandle(&mut pult, &mut s, "do master.queue"); + assert!(liste.contains("queue 1 /musik/eins.wav -"), "{liste}"); + assert!(liste.contains("queue 2 /musik/zwei.wav -"), "{liste}"); + + let auf = behandle(&mut pult, &mut s, "do master.queue_next"); + assert!(auf.contains("queue 1 abgenommen /musik/eins.wav"), "{auf}"); + + let rest = behandle(&mut pult, &mut s, "do master.queue"); + assert!(!rest.contains("eins.wav"), "{rest}"); + assert!(rest.contains("queue 2 /musik/zwei.wav"), "{rest}"); + } + + /// Der häufigste Zusammenstoß zweier Auswählender: Beide suchen, was zu + /// 128 BPM in 8A passt, und finden denselben Track. + #[test] + fn derselbe_track_kommt_nicht_zweimal_in_die_liste() { + let (mut pult, _runner, mut s) = mit_liste(); + + let nochmal = behandle(&mut pult, &mut s, "do master.queue_add /musik/eins.wav"); + assert!(nochmal.starts_with("err"), "{nochmal}"); + assert!( + nochmal.contains("Nummer 1"), + "sagt nicht, wo er steht: {nochmal}" + ); + } + + #[test] + fn aufgelegt_wird_auf_ein_deck_das_nicht_laeuft() { + let (mut pult, _runner, mut s) = mit_liste(); + behandle(&mut pult, &mut s, "set deck1.play 1"); + + behandle(&mut pult, &mut s, "do master.queue_next"); + assert_eq!( + behandle(&mut pult, &mut s, "get deck2.load_status"), + "value deck2.load_status laedt", + "der laufende Track wurde überschrieben" + ); + } + + /// Laufen alle Decks, wird gefragt statt geraten — ein Track über einen + /// laufenden zu legen, reißt den Mix ab. + #[test] + fn laufen_alle_decks_wird_gefragt_statt_geraten() { + let (mut pult, _runner, mut s) = mit_liste(); + behandle(&mut pult, &mut s, "set deck1.play 1"); + behandle(&mut pult, &mut s, "set deck2.play 1"); + + let antwort = behandle(&mut pult, &mut s, "do master.queue_next"); + assert!(antwort.starts_with("err"), "{antwort}"); + // Und der Eintrag steht noch da. + assert!(behandle(&mut pult, &mut s, "do master.queue").contains("eins.wav")); + + // Mit ausdrücklicher Deckangabe geht es trotzdem. + let mit_deck = behandle(&mut pult, &mut s, "do master.queue_next deck1"); + assert!(mit_deck.contains("abgenommen"), "{mit_deck}"); + } + + #[test] + fn ein_gescheitertes_auflegen_verliert_den_eintrag_nicht() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + // Die Testsammlung weist alles ab, was nicht nach Audio aussieht. + behandle(&mut pult, &mut s, "do master.queue_add /musik/kein.txt"); + + let antwort = behandle(&mut pult, &mut s, "do master.queue_next"); + assert!(antwort.starts_with("err"), "{antwort}"); + assert!( + behandle(&mut pult, &mut s, "do master.queue").contains("queue 1 /musik/kein.txt"), + "der Eintrag ist verschwunden, ohne gespielt worden zu sein" + ); + } + + #[test] + fn eine_notiz_kommt_beim_auflegen_mit() { + let (mut pult, _runner, mut s) = mit_liste(); + behandle( + &mut pult, + &mut s, + "do master.queue_note 2 mehr Druck nach dem Break", + ); + + let liste = behandle(&mut pult, &mut s, "do master.queue"); + assert!( + liste.contains("zwei.wav mehr Druck nach dem Break"), + "{liste}" + ); + + behandle(&mut pult, &mut s, "do master.queue_bump 2"); + let auf = behandle(&mut pult, &mut s, "do master.queue_next"); + assert!(auf.contains("notiz mehr Druck nach dem Break"), "{auf}"); + } + + #[test] + fn vorziehen_streichen_und_leeren() { + let (mut pult, _runner, mut s) = mit_liste(); + + assert!(behandle(&mut pult, &mut s, "do master.queue_bump 2").contains("Naechste")); + assert!(behandle(&mut pult, &mut s, "do master.queue_drop 2").contains("gestrichen")); + assert!(behandle(&mut pult, &mut s, "do master.queue").contains("eins.wav")); + assert!(behandle(&mut pult, &mut s, "do master.queue_clear").contains("1 gestrichen")); + assert!(behandle(&mut pult, &mut s, "do master.queue").contains("hinweis")); + } + + #[test] + fn unsinnige_listenbefehle_werden_benannt_abgewiesen() { + let (mut pult, _runner, mut s) = mit_liste(); + + for zeile in [ + "do master.queue_add", + "do master.queue_drop", + "do master.queue_drop zwei", + "do master.queue_drop 99", + "do master.queue_bump 99", + "do master.queue_note", + "do master.queue_note 2", + "do master.queue_note 99 gibt es nicht", + "do master.queue_next deck9", + "do master.queue_next channel1", + ] { + let antwort = behandle(&mut pult, &mut s, zeile); + assert!(antwort.starts_with("err"), "'{zeile}' → {antwort}"); + } + } +} + +#[cfg(test)] +mod wenn_tests { + use super::*; + use crate::testing::{pult_mit_zwei_decks, rendern, RATE}; + + /// Der eigentliche Fall: „Wenn Deck A fast durch ist, leg den nächsten auf." + /// + /// Ohne dieses Verb müsste ein Bediener `beats_left` abonnieren, zwanzig + /// Zahlen je Sekunde lesen und die Schwelle selbst heraussuchen — über eine + /// Chat-Schnittstelle ist das nicht zu bezahlen. + #[test] + fn ein_befehl_wartet_auf_einen_wert() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + behandle(&mut pult, &mut s, "set deck1.play 1"); + + // Der Testtrack ist 60 s lang, also 128 Beats bei 128 BPM. + let antwort = behandle( + &mut pult, + &mut s, + "when deck1.beats_left < 120 set channel2.fader 0.9", + ); + assert!( + antwort.starts_with("ok plan 1 wenn deck1.beats_left <"), + "{antwort}" + ); + + let mut plan = std::mem::take(&mut pult.plan); + + // Nach einer Sekunde sind erst gut zwei Beats gelaufen — zu früh. + let frueh = lauf(&mut pult, &mut plan, &mut runner, 1.0); + assert!(frueh.is_empty(), "zu früh gelaufen: {frueh:?}"); + assert!(!plan.ist_leer()); + + // Nach vier Sekunden sind es über acht Beats, die Schwelle ist gerissen. + let spaet = lauf(&mut pult, &mut plan, &mut runner, 4.0); + assert_eq!(spaet, vec!["set channel2.fader 0.9"]); + assert!(plan.ist_leer(), "der Auftrag hängt noch im Plan"); + } + + /// Lässt die Anlage laufen und gibt zurück, was der Plan ausgeführt hat. + fn lauf( + pult: &mut Steuerpult, + plan: &mut crate::Zeitplan, + runner: &mut audio_engine::EngineRunner, + sekunden: f64, + ) -> Vec { + let mut gelaufen = Vec::new(); + for _ in 0..20 { + rendern(runner, (RATE as f64 * sekunden / 20.0) as usize); + crate::zeitplan::takt(pult, plan, &mut |_, zeile| { + gelaufen.push(zeile.to_string()); + "ok".into() + }); + } + gelaufen + } + + fn takten(pult: &mut Steuerpult, plan: &mut crate::Zeitplan) -> Vec { + let mut gelaufen = Vec::new(); + crate::zeitplan::takt(pult, plan, &mut |_, zeile| { + gelaufen.push(zeile.to_string()); + "ok".into() + }); + gelaufen + } + + /// Trifft es schon zu, läuft es sofort — `when` heißt „sobald es so weit + /// ist", nicht „beim nächsten Überschreiten". + #[test] + fn eine_schon_erfuellte_bedingung_loest_beim_naechsten_takt_aus() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + behandle( + &mut pult, + &mut s, + "when deck1.beats_left < 999 do deck2.sync", + ); + + let mut plan = std::mem::take(&mut pult.plan); + assert_eq!(takten(&mut pult, &mut plan), vec!["do deck2.sync"]); + } + + /// Ein stehendes Deck hält den Takt an — ein `when` nicht, denn es hängt + /// an einem Wert und nicht an Takten. + #[test] + fn ein_wenn_braucht_keinen_taktgeber() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + // Kein Deck läuft, und dieses hier hat nicht einmal ein Grid. + behandle(&mut pult, &mut s, "set deck1.bpm_grid 0"); + behandle(&mut pult, &mut s, "when channel1.fader > 0.5 do deck2.sync"); + + let mut plan = std::mem::take(&mut pult.plan); + let ruhig = takten(&mut pult, &mut plan); + assert!(ruhig.is_empty(), "{ruhig:?}"); + assert!( + !plan.ist_leer(), + "ohne Grid abgebrochen, obwohl es keins braucht" + ); + + pult.schreibe( + &Schluessel::parse("channel1.fader").unwrap(), + crate::wert::Wert::Zahl(0.9), + ) + .unwrap(); + assert_eq!(takten(&mut pult, &mut plan), vec!["do deck2.sync"]); + } + + #[test] + fn unsinnige_bedingungen_werden_benannt_abgewiesen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + for zeile in [ + "when", + "when deck1.beats_left", + "when deck1.beats_left < 32", + "when deck1.beats_left ist 32 do deck2.sync", + "when deck1.beats_left < viele do deck2.sync", + "when deck1.quatsch < 32 do deck2.sync", + // Ein Schalter lässt sich mit keiner Schwelle vergleichen. Ihn + // anzunehmen hieße, einen Auftrag anzulegen, der stumm für immer + // wartet — schlimmer als eine Absage. + "when deck1.play < 1 do deck2.sync", + "when deck1.title < 1 do deck2.sync", + ] { + let antwort = behandle(&mut pult, &mut s, zeile); + assert!(antwort.starts_with("err"), "'{zeile}' → {antwort}"); + } + } + + #[test] + fn der_plan_zeigt_die_bedingung_mit_ist_wert() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + behandle(&mut pult, &mut s, "set channel1.fader 0.25"); + behandle(&mut pult, &mut s, "when channel1.fader > 0.8 do deck2.sync"); + + let plan = behandle(&mut pult, &mut s, "plan"); + assert!(plan.contains("wenn channel1.fader > 0.8"), "{plan}"); + assert!( + plan.contains("steht bei 0.25"), + "der Ist-Wert fehlt: {plan}" + ); + } + + #[test] + fn die_hilfe_nennt_das_neue_verb() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + assert!(behandle(&mut pult, &mut s, "help").contains("when ")); + } +} + +#[cfg(test)] +mod phrasen_tests { + use super::*; + use crate::testing::{pult_mit_zwei_decks, rendern}; + + /// Der eigentliche Zweck: Ein Übergang beginnt auf der Eins einer Phrase. + /// + /// Mit `in 32` trifft man irgendwo hin, und der Mix klingt danach — egal + /// wie sauber alles andere sitzt. + #[test] + fn in_phrase_legt_den_befehl_auf_die_naechste_eins() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + behandle(&mut pult, &mut s, "set deck1.play 1"); + + // Vier Beats hinein: bis zur nächsten Sechzehnergruppe sind es zwölf. + behandle(&mut pult, &mut s, "do deck1.beatjump 4"); + rendern(&mut runner, 1024); + let bis = match pult.lies(&Schluessel::parse("deck1.beats_to_phrase").unwrap()) { + Ok(crate::wert::Wert::Zahl(v)) => v, + andere => panic!("{andere:?}"), + }; + assert!((bis - 12.0).abs() < 0.2, "Vorbedingung: {bis}"); + + let antwort = behandle(&mut pult, &mut s, "in phrase do deck2.sync"); + assert!(antwort.starts_with("ok plan 1 in 1"), "{antwort}"); + + // Der Plan zählt dieselben Beats herunter, die beats_to_phrase nennt. + let plan = behandle(&mut pult, &mut s, "plan"); + assert!(plan.contains("do deck2.sync"), "{plan}"); + } + + #[test] + fn phrase_plus_versatz_liegt_eine_phrase_spaeter() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + behandle(&mut pult, &mut s, "set deck1.play 1"); + rendern(&mut runner, 1024); + + let ohne = behandle(&mut pult, &mut s, "in phrase do deck2.sync"); + let mit = behandle(&mut pult, &mut s, "in phrase+16 do deck2.sync"); + + let zahl = |z: &str| -> f64 { + z.split_whitespace() + .nth(4) + .and_then(|w| w.parse().ok()) + .unwrap_or(-1.0) + }; + let (a, b) = (zahl(&ohne), zahl(&mit)); + assert!( + (b - a - 16.0).abs() < 0.5, + "erwartet 16 Beats Abstand, war {a} und {b}" + ); + } + + /// Und der Punkt daran: Eine Rampe lässt sich damit auf die Eins legen, + /// ohne dass `ramp` selbst etwas von Phrasen wissen müsste. + #[test] + fn eine_rampe_laesst_sich_auf_die_phrase_legen() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + behandle(&mut pult, &mut s, "set deck1.play 1"); + rendern(&mut runner, 1024); + + let antwort = behandle(&mut pult, &mut s, "in phrase ramp master.crossfader 1.0 32"); + assert!(antwort.starts_with("ok plan 1 in "), "{antwort}"); + assert!( + antwort.contains("ramp master.crossfader 1.0 32"), + "{antwort}" + ); + } + + #[test] + fn unsinnige_bezuege_werden_benannt_abgewiesen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + + for zeile in [ + "in takt do deck2.sync", + "in phrase+viel do deck2.sync", + "in phrasen do deck2.sync", + "in phrase", + ] { + let antwort = behandle(&mut pult, &mut s, zeile); + assert!(antwort.starts_with("err"), "'{zeile}' → {antwort}"); + } + } + + /// Ohne Beatgrid gibt es keine Phrase — und das muss dastehen, statt + /// stillschweigend auf null zu fallen. + #[test] + fn ohne_grid_hat_phrase_keinen_bezugspunkt() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + behandle(&mut pult, &mut s, "set deck1.bpm_grid 0"); + behandle(&mut pult, &mut s, "set deck2.bpm_grid 0"); + + let antwort = behandle(&mut pult, &mut s, "in phrase do deck2.sync"); + assert!(antwort.starts_with("err"), "{antwort}"); + assert!( + antwort.contains("Bezugspunkt") || antwort.contains("Beatgrid"), + "{antwort}" + ); + } + + #[test] + fn die_hilfe_nennt_den_phrasenbezug() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + assert!(behandle(&mut pult, &mut s, "help").contains("in phrase")); + } +} + +/// Zwei Bediener an einem Pult. +/// +/// **Der Zweck dieses Projekts, und bis hierher sein am wenigsten geprüfter +/// Teil.** Die Schutzmechanismen — eine Rampe gibt auf, wenn jemand anders den +/// Regler anfasst; derselbe Pfad wird nicht zweimal aus der Liste genommen — +/// waren einzeln geprüft, ihr Zusammenspiel nie. Und beim ersten Hinsehen fiel +/// auf, dass der Verlierer eines Griffs **gar nichts erfuhr**: Die Meldung des +/// Taktgebers ging an einen Aufrufer, der sie wegwarf. +#[cfg(test)] +mod team_tests { + + /// Ein Griff geht durch dasselbe Protokoll wie alles andere: Er landet im + /// Plan, die Antwort nennt jede Zeile, und `cancel` nimmt ihn zurück. + #[test] + fn ein_griff_landet_im_plan_und_nennt_seine_zeilen() { + let (mut pult, _runner) = crate::testing::pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + behandle(&mut pult, &mut s, "set deck1.play 1"); + + let antwort = behandle(&mut pult, &mut s, "do master.uebergang blende"); + assert!(antwort.contains("uebergang blende"), "{antwort}"); + // Jede Zeile steht mit ihrer Antwort da — nichts geschieht im + // Verborgenen. + assert!( + antwort.contains("in deck1 phrase set deck2.play 1 → ok plan"), + "{antwort}" + ); + assert!(antwort.contains("master.crossfader"), "{antwort}"); + + let plan = behandle(&mut pult, &mut s, "plan"); + // Drei: Deck starten, blenden, und am Ende das ausgehende Deck + // stoppen. Ohne das letzte wäre der nächste Griff nicht möglich. + assert!(plan.contains("ok 3 vorgemerkt"), "{plan}"); + + // Und zurücknehmen geht wie bei allem anderen. + assert_eq!(behandle(&mut pult, &mut s, "cancel"), "ok 3 gestrichen"); + } + + /// Ein unbekannter Griff nennt die Auswahl, statt still nichts zu tun. + #[test] + fn ein_unbekannter_griff_nennt_die_auswahl() { + let (mut pult, _runner) = crate::testing::pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + let antwort = behandle(&mut pult, &mut s, "do master.uebergang wumms"); + assert!(antwort.starts_with("err"), "{antwort}"); + assert!(antwort.contains("bassswap"), "{antwort}"); + } + + /// Die Länge lässt sich mitgeben; ohne Angabe nimmt der Griff seine übliche. + #[test] + fn die_laenge_laesst_sich_mitgeben() { + let (mut pult, _runner) = crate::testing::pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + behandle(&mut pult, &mut s, "set deck1.play 1"); + + let lang = behandle(&mut pult, &mut s, "do master.uebergang blende"); + assert!(lang.contains("über 32 Beats"), "{lang}"); + behandle(&mut pult, &mut s, "cancel"); + + let kurz = behandle(&mut pult, &mut s, "do master.uebergang blende 8"); + assert!(kurz.contains("über 8 Beats"), "{kurz}"); + } + + /// Deck und Form stehen hinten, in beliebiger Reihenfolge. + /// + /// Eine feste Reihenfolge wäre nur eine Falle für den, der die Form angeben + /// will, aber kein Deck — und genau das ist der häufigere Fall. + #[test] + fn deck_und_form_stehen_in_beliebiger_reihenfolge() { + for zeile in [ + "ramp channel1.fader 0 16 weich", + "ramp channel1.fader 0 16 deck1 weich", + "ramp channel1.fader 0 16 weich deck1", + ] { + let (mut pult, _runner) = crate::testing::pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + let antwort = behandle(&mut pult, &mut s, zeile); + assert!(antwort.contains("weich"), "{zeile}: {antwort}"); + assert_eq!(pult.plan.auftraege().len(), 1, "{zeile}"); + } + } + + /// Ohne Angabe bleibt es linear — die Form ist eine Zutat, keine Pflicht. + #[test] + fn ohne_angabe_bleibt_die_rampe_linear() { + let (mut pult, _runner) = crate::testing::pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + let antwort = behandle(&mut pult, &mut s, "ramp channel1.fader 0 16"); + assert!(antwort.contains("linear"), "{antwort}"); + } + + /// Ein Tippfehler wird gemeldet und nennt, was es gäbe — statt still + /// linear zu fahren, was ja auch „funktioniert" hätte. + #[test] + fn eine_unbekannte_form_wird_gemeldet_statt_verschluckt() { + let (mut pult, _runner) = crate::testing::pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + let antwort = behandle(&mut pult, &mut s, "ramp channel1.fader 0 16 sanft"); + assert!(antwort.starts_with("err"), "{antwort}"); + assert!(antwort.contains("weich"), "die Auswahl fehlt: {antwort}"); + assert!(pult.plan.ist_leer(), "die Rampe wurde trotzdem vorgemerkt"); + } + + /// Und die Form steht im Plan — sonst sähen zwei verschieden gemeinte + /// Bewegungen für einen zweiten Bediener gleich aus. + #[test] + fn die_form_steht_im_plan() { + let (mut pult, _runner) = crate::testing::pult_mit_zwei_decks(); + let mut s = Sitzung::neu(); + behandle(&mut pult, &mut s, "ramp channel1.fader 0 16 spaet"); + let plan = behandle(&mut pult, &mut s, "plan"); + assert!(plan.contains("spaet"), "{plan}"); + } + + use super::*; + use crate::testing::{pult_mit_zwei_decks, rendern, RATE}; + use crate::zeitplan::takt; + + fn k(text: &str) -> Schluessel { + Schluessel::parse(text).unwrap() + } + + /// Ein Bediener mit eigener Sitzung. + struct Bediener { + sitzung: Sitzung, + } + + impl Bediener { + fn neu() -> Bediener { + Bediener { + sitzung: Sitzung::neu(), + } + } + + fn sagt(&mut self, pult: &mut Steuerpult, zeile: &str) -> String { + behandle(pult, &mut self.sitzung, zeile) + } + + /// Was seit dem letzten Blick hereinkam — wie über den Socket. + fn hoert(&mut self, pult: &Steuerpult) -> Vec { + self.sitzung.aenderungen(pult) + } + + fn ereignisse(&mut self, pult: &Steuerpult) -> Vec { + self.hoert(pult) + .into_iter() + .filter(|z| z.starts_with("event ") || z.starts_with("warnung ")) + .collect() + } + } + + /// Lässt den Plan laufen, während Deck 1 spielt. + fn takten(pult: &mut Steuerpult, runner: &mut audio_engine::EngineRunner, frames: usize) { + for _ in 0..10 { + rendern(runner, frames / 10); + let mut plan = std::mem::take(&mut pult.plan); + takt(pult, &mut plan, &mut |p, zeile| { + let mut s = Sitzung::neu(); + behandle(p, &mut s, zeile) + }); + let neu = std::mem::take(&mut pult.plan); + pult.plan = plan; + for a in neu.auftraege() { + pult.plan.uebernehmen(a.clone()); + } + } + } + + /// **Der Fall, für den es P2 gibt.** Zwei greifen denselben Fader, einer + /// verliert — und muss es erfahren. + #[test] + fn wer_den_regler_verliert_erfaehrt_es() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + pult.schreibe(&k("channel1.fader"), Wert::Zahl(1.0)) + .unwrap(); + + let (mut a, mut b) = (Bediener::neu(), Bediener::neu()); + a.sagt(&mut pult, "sub master.events"); + b.sagt(&mut pult, "sub master.events"); + a.hoert(&pult); + b.hoert(&pult); + + // A fährt eine lange Blende. + let antwort = a.sagt(&mut pult, "ramp channel1.fader 0 32"); + assert!(antwort.starts_with("ok plan 1"), "{antwort}"); + takten(&mut pult, &mut runner, RATE as usize / 2); + + // B greift mitten hinein. + b.sagt(&mut pult, "set channel1.fader 0.5"); + takten(&mut pult, &mut runner, RATE as usize / 4); + + let gemeldet = a.ereignisse(&pult); + assert!( + gemeldet.iter().any(|z| z.contains("abgeloest")), + "A erfährt nicht, dass ihm der Regler weggenommen wurde: {gemeldet:?}" + ); + // Und der Plan ist wirklich leer — nicht nur gemeldet. + assert!(pult.plan.ist_leer(), "die Rampe läuft weiter"); + } + + /// Der Plan ist das gemeinsame Blatt: Auch wer den Griff *gewonnen* hat, + /// sieht, dass er etwas abgelöst hat. Sonst fährt B weiter in dem Glauben, + /// niemandem in die Quere gekommen zu sein. + #[test] + fn auch_der_gewinner_sieht_was_er_abgeloest_hat() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + pult.schreibe(&k("channel1.fader"), Wert::Zahl(1.0)) + .unwrap(); + + let (mut a, mut b) = (Bediener::neu(), Bediener::neu()); + a.sagt(&mut pult, "ramp channel1.fader 0 32"); + b.sagt(&mut pult, "sub master.events"); + b.hoert(&pult); + + takten(&mut pult, &mut runner, RATE as usize / 2); + b.sagt(&mut pult, "set channel1.fader 0.5"); + takten(&mut pult, &mut runner, RATE as usize / 4); + + assert!( + b.ereignisse(&pult).iter().any(|z| z.contains("abgeloest")), + "B erfährt nicht, dass er jemandem dazwischengekommen ist" + ); + } + + /// Wer nicht abonniert hat, bekommt keine unaufgeforderten Zeilen — sonst + /// verwirrte man jeden einfachen Bediener, der nur `get` sagt. + #[test] + fn ohne_abo_kommt_nichts_ungefragt() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + pult.schreibe(&k("channel1.fader"), Wert::Zahl(1.0)) + .unwrap(); + + let mut stumm = Bediener::neu(); + stumm.sagt(&mut pult, "ramp channel1.fader 0 8"); + // Acht Beats sind bei 128 BPM 3,75 s — mit zwei Sekunden wäre die + // Rampe noch gar nicht fertig, und der Test prüfte nichts. + takten(&mut pult, &mut runner, RATE as usize * 5); + + assert!(stumm.hoert(&pult).is_empty(), "ungefragte Zeilen"); + // Abrufbar ist es trotzdem — nur eben auf Nachfrage. + let antwort = stumm.sagt(&mut pult, "get master.events"); + assert!(antwort.contains("fertig"), "{antwort}"); + } + + /// Streicht einer den Auftrag eines anderen, sieht der eine Lücke im Plan + /// und sonst nichts. Für ein Team ist das dieselbe stille Niederlage. + #[test] + fn ein_gestrichener_auftrag_faellt_dem_urheber_auf() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let (mut a, mut b) = (Bediener::neu(), Bediener::neu()); + a.sagt(&mut pult, "sub master.events"); + a.hoert(&pult); + + let antwort = a.sagt(&mut pult, "when deck1.beats_left < 8 do master.queue_next"); + assert!(antwort.starts_with("ok plan 1"), "{antwort}"); + + b.sagt(&mut pult, "cancel 1"); + + let gemeldet = a.ereignisse(&pult); + assert!( + gemeldet.iter().any(|z| z.contains("gestrichen")), + "A erfährt nichts vom Streichen: {gemeldet:?}" + ); + } + + /// Zwei Aufträge auf derselben Phrasengrenze müssen **beide** feuern. Wenn + /// zwei Agenten sich auf dieselbe Eins verabreden, darf nicht einer + /// gewinnen. + #[test] + fn zwei_auftraege_auf_derselben_grenze_feuern_beide() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + + let (mut a, mut b) = (Bediener::neu(), Bediener::neu()); + assert!(a + .sagt(&mut pult, "in phrase set channel1.fader 0.3") + .starts_with("ok plan")); + assert!(b + .sagt(&mut pult, "in phrase set channel2.fader 0.7") + .starts_with("ok plan")); + + // Bei 128 BPM ist eine Phrase 7,5 s. + takten(&mut pult, &mut runner, RATE as usize * 8); + + assert_eq!(pult.lies(&k("channel1.fader")).unwrap(), Wert::Zahl(0.3)); + assert_eq!(pult.lies(&k("channel2.fader")).unwrap(), Wert::Zahl(0.7)); + assert!( + pult.plan.ist_leer(), + "einer hing fest: {:?}", + pult.plan.auftraege().len() + ); + } + + /// Zwei nehmen denselben Track. Der zweite muss abgewiesen werden, statt + /// ihn auf ein zweites Deck zu legen. + #[test] + fn derselbe_track_wird_nicht_zweimal_abgenommen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let (mut a, mut b) = (Bediener::neu(), Bediener::neu()); + + a.sagt(&mut pult, "do master.queue_add /musik/a.wav"); + let zweite = b.sagt(&mut pult, "do master.queue_add /musik/a.wav"); + assert!( + zweite.starts_with("err"), + "derselbe Pfad wurde zweimal angenommen: {zweite}" + ); + } + + /// Wer zu langsam liest, verliert Zeilen — und muss das erfahren. + #[test] + fn wer_zu_langsam_liest_wird_gewarnt() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut a = Bediener::neu(); + a.sagt(&mut pult, "sub master.events"); + a.hoert(&pult); + + for i in 0..crate::ereignis::PLATZ + 5 { + pult.ereignisse.melden(&format!("plan {i} fertig")); + } + + let gemeldet = a.ereignisse(&pult); + assert!( + gemeldet + .iter() + .any(|z| z.starts_with("warnung") && z.contains("verloren")), + "die Lücke wurde verschwiegen: {} Zeilen", + gemeldet.len() + ); + } + + /// Abbestellt heißt abbestellt. + #[test] + fn nach_dem_abbestellen_kommt_nichts_mehr() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut a = Bediener::neu(); + a.sagt(&mut pult, "sub master.events"); + a.hoert(&pult); + a.sagt(&mut pult, "unsub master.events"); + + pult.ereignisse.melden("plan 1 abgeloest channel1.fader"); + assert!(a.hoert(&pult).is_empty(), "nach unsub kam noch etwas"); + } + + /// Wer sich mitten im Set verbindet, bekommt nicht die Vergangenheit + /// nachgereicht. + #[test] + fn wer_spaeter_dazukommt_bekommt_nur_neues() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.ereignisse.melden("plan 1 fertig channel1.fader"); + pult.ereignisse.melden("plan 2 fertig channel2.fader"); + + let mut spaet = Bediener::neu(); + spaet.sagt(&mut pult, "sub master.events"); + assert!( + spaet.ereignisse(&pult).is_empty(), + "alte Ereignisse kamen mit" + ); + + pult.ereignisse.melden("plan 3 abgeloest channel1.fader"); + assert_eq!( + spaet.ereignisse(&pult), + vec!["event plan 3 abgeloest channel1.fader"] + ); + } +} diff --git a/crates/control/src/pult.rs b/crates/control/src/pult.rs new file mode 100644 index 0000000..40ce2f2 --- /dev/null +++ b/crates/control/src/pult.rs @@ -0,0 +1,3926 @@ +//! Das Steuerpult: ein benannter Zugriff auf alles, was bedienbar ist. +//! +//! Bisher lag die Bedienung an drei Stellen — Transport in den Atomics des +//! `DeckState`, Mixer in der Kommandoschlange, und die gespiegelten Reglerwerte +//! in der Oberfläche. Wer von außen steuern wollte, hätte alle drei anfassen +//! müssen, und die Oberfläche war die einzige Stelle, die wusste, wo ein Fader +//! gerade steht. +//! +//! Das Pult dreht das um: Es **besitzt** den Spiegel, schickt jede Änderung +//! selbst in die Schlange und lässt sich unter einem Namen ansprechen. Die +//! Oberfläche wird damit zu einem von mehreren Bedienern — gleichberechtigt mit +//! einem MIDI-Controller, einem Skript oder einem Agenten. +//! +//! Der Mixer selbst wird nie direkt gelesen: Er lebt im Audio-Callback, und +//! dort etwas zu lesen hieße, ein Lock zu nehmen. Stattdessen gilt der +//! Spiegelwert als Wahrheit — er ist es auch, denn niemand sonst schreibt. + +use std::sync::Arc; + +use audio_core::deck::DeckState; +use audio_core::Beatgrid; +use audio_core::Tonart; +use audio_engine::{Assign, Aufnahme, Command, Effekt, EngineHandle}; + +use crate::katalog::{self, Beschreibung}; +use crate::schluessel::{Gruppe, Schluessel}; +use crate::wert::{Art, Wert}; + +/// Ein Treffer aus der Sammlung, so knapp wie er über die Leitung geht. +#[derive(Debug, Clone, PartialEq)] +pub struct Treffer { + pub pfad: String, + pub titel: String, + pub artist: Option, + pub bpm: Option, + /// Tonart, wie sie in der Sammlung steht — `None`, wenn keine bekannt ist. + pub tonart: Option, + /// Wie viel Energie der Track trägt, 0 bis 1 — aus seiner Gliederung. + /// + /// `None`, solange er nicht analysiert ist. Das ist keine mittlere Energie + /// und darf auch nicht als eine behandelt werden: Ein Track, über den + /// nichts bekannt ist, taucht bei der Auswahl nach Energie hinten auf und + /// sagt warum, statt sich in die Mitte zu mogeln. + pub energie: Option, +} + +/// Was das Pult nicht selbst kann: suchen und laden. +/// +/// Beides braucht Dinge, die hier nichts zu suchen haben — eine SQLite-Datei +/// und einen Dekodierer. Statt `control` von beidem abhängig zu machen, +/// reicht der Betreiber sie herein. +/// +/// `laden` **darf nicht blockieren.** Dekodieren und Analysieren dauern +/// Sekunden, und das Pult liegt währenddessen unter einem Mutex, an dem die +/// Oberfläche hängt. Die Umsetzung nimmt den Auftrag an und arbeitet +/// woanders; der Fortschritt kommt über `deckN.load_status` zurück. +pub trait Sammlung: Send { + fn suchen(&self, text: &str, grenze: usize) -> Vec; + /// Tracks, die tempomäßig zu `bpm` passen. + fn suchen_mischbar(&self, bpm: f32, grenze: usize) -> Vec; + /// Tracks, deren Tonart harmonisch zu `tonart` passt. + fn suchen_harmonisch(&self, tonart: Tonart, grenze: usize) -> Vec; + /// Namen aller Playlists. + fn playlists(&self) -> Vec; + /// Die Tracks einer Playlist, in ihrer Reihenfolge. + fn playlist(&self, name: &str, grenze: usize) -> Vec; + fn laden(&self, deck: usize, pfad: &str) -> Result<(), String>; + + /// Schreibt die Hot Cues eines Tracks zurück. + /// + /// Anders als [`Sammlung::laden`] **darf** das blockieren: Es sind acht + /// Zeilen in einer SQLite-Datei, keine Sekunden Dekodierarbeit. Dafür muss + /// es sofort geschehen — ein Cue, der erst beim Beenden gespeichert wird, + /// ist bei einem Absturz weg, und abgestürzt wird beim Auflegen. + fn hot_cues_speichern(&self, pfad: &str, cues: &[(usize, f64)]) -> Result<(), String>; + + /// Schreibt ein korrigiertes Beatgrid zurück — Tempo und Anker in Sekunden. + fn grid_speichern(&self, pfad: &str, bpm: f32, anker_sekunden: f64) -> Result<(), String>; +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Fehler { + UnbekanntesControl(String), + NichtSchreibbar(String), + FalscherTyp { + control: String, + erwartet: Art, + }, + UnbekannteAuswahl { + control: String, + erlaubt: Vec, + }, + /// Eine Aktion lässt sich nicht wie ein Wert setzen und umgekehrt. + IstEineAktion(String), + IstKeineAktion(String), + /// Die Aktion braucht ein Argument, oder es taugt nicht. + Argument { + control: String, + erwartet: String, + }, + /// Die Aktion war zulässig, ging aber schief. + Gescheitert(String), +} + +impl std::fmt::Display for Fehler { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Fehler::UnbekanntesControl(k) => write!(f, "unbekanntes Control: {k}"), + Fehler::NichtSchreibbar(k) => write!(f, "{k} lässt sich nur lesen"), + Fehler::FalscherTyp { control, erwartet } => { + write!(f, "{control} erwartet {}", erwartet.name()) + } + Fehler::UnbekannteAuswahl { control, erlaubt } => { + write!(f, "{control} kennt nur: {}", erlaubt.join(", ")) + } + Fehler::IstEineAktion(k) => write!(f, "{k} ist eine Aktion — mit 'do' auslösen"), + Fehler::IstKeineAktion(k) => write!(f, "{k} ist keine Aktion — mit 'set' setzen"), + Fehler::Argument { control, erwartet } => { + write!(f, "{control} braucht ein Argument: {erwartet}") + } + Fehler::Gescheitert(text) => f.write_str(text), + } + } +} + +impl std::error::Error for Fehler {} + +/// Ein Deck, wie das Pult es sieht. +pub struct DeckEintrag { + pub state: Arc, + /// Welcher Kanalzug dieses Deck führt. + pub kanal: usize, + pub sample_rate: u32, + pub frames: u64, + pub titel: String, + pub artist: String, + /// Tonart des geladenen Tracks, sofern die Analyse eine gefunden hat. + /// + /// Sie liegt hier und nicht im `DeckState`: Der ist der Echtzeitteil und + /// kennt nur Atomics; eine Tonart wird nie pro Sample gebraucht. + pub tonart: Option, + /// Gliederung des geladenen Tracks — siehe [`audio_core::struktur`]. + /// + /// `None`, solange keine erkannt wurde. Das ist kein Mangel, sondern eine + /// Auskunft: Ohne Beatgrid oder bei sehr kurzem Material gibt es keine, und + /// eine geratene wäre schlechter als keine. + pub struktur: Option, + /// Woher der geladene Track kommt — leer, solange keiner liegt. + /// + /// Ohne den Pfad ließe sich nichts zurückschreiben: Die Sammlung kennt + /// Tracks über ihn, und das Deck ist die einzige Stelle, die weiß, was + /// gerade darauf liegt. + pub pfad: String, + /// Was der letzte Ladeauftrag macht: `bereit`, `laedt` oder ein Fehler. + pub lade_status: String, + /// Wie lang eine Phrase ist, in Beats. + /// + /// Nicht im `DeckState`, weil der Audio-Thread sie nie braucht — und je + /// Deck und nicht global, weil sie eine Eigenschaft der Musik ist: Ein + /// Stück in Achtergruppen und eines in Sechzehnern liegen gleichzeitig auf + /// den Decks. + pub phrase_beats: f64, + /// Namen der Einzelspuren, in der Reihenfolge, in der sie liegen. + /// + /// Leer heißt: Der Track hat keine. Die Pegel dazu stehen im `DeckState`, + /// weil der Audio-Thread sie in jedem Block liest — die Namen nie. + pub stems: Vec, +} + +pub use audio_core::struktur::PHRASE_BEATS; + +/// Wie weit `arc_trend` vorausschaut, in Minuten. +/// +/// Fünf Minuten sind ein bis zwei Tracks — die Strecke, über die die Wahl des +/// nächsten überhaupt etwas ändern kann. Wer weiter schaut, bekommt eine +/// Richtung, auf die er heute nicht mehr reagieren kann. +pub const VORAUS_MINUTEN: f64 = 5.0; + +impl DeckEintrag { + pub fn neu(state: Arc, kanal: usize, sample_rate: u32) -> DeckEintrag { + DeckEintrag { + state, + kanal, + sample_rate, + frames: 0, + titel: String::new(), + artist: String::new(), + tonart: None, + struktur: None, + pfad: String::new(), + lade_status: "bereit".into(), + phrase_beats: PHRASE_BEATS, + stems: Vec::new(), + } + } +} + +/// Gespiegelte Reglerstellung eines Kanalzuges. +#[derive(Debug, Clone, PartialEq)] +pub struct KanalSpiegel { + pub name: String, + pub trim: f64, + pub eq_low: f64, + pub eq_mid: f64, + pub eq_high: f64, + pub filter: f64, + pub fader: f64, + pub cue: bool, + pub assign: Assign, + pub fx: Effekt, + pub fx_mix: f64, + pub fx_amount: f64, + pub fx_time: f64, +} + +impl KanalSpiegel { + pub fn neu(name: impl Into, assign: Assign) -> KanalSpiegel { + KanalSpiegel { + name: name.into(), + trim: 1.0, + eq_low: 1.0, + eq_mid: 1.0, + eq_high: 1.0, + filter: 0.0, + // Zu: ein Kanal, der beim Start auf die Anlage geht, ist ein Unfall. + fader: 0.0, + cue: false, + assign, + fx: Effekt::Aus, + fx_mix: 0.0, + fx_amount: 0.5, + fx_time: 0.5, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MasterSpiegel { + pub crossfader: f64, + pub crossfader_curve: f64, + pub gain: f64, + pub cue_gain: f64, + pub cue_mix: f64, +} + +impl Default for MasterSpiegel { + fn default() -> Self { + MasterSpiegel { + crossfader: 0.0, + crossfader_curve: 0.0, + gain: 1.0, + cue_gain: 1.0, + cue_mix: 0.0, + } + } +} + +pub struct Steuerpult { + /// Was später geschehen soll — siehe [`crate::zeitplan`]. + pub plan: crate::zeitplan::Zeitplan, + /// Was als Nächstes gespielt werden soll — siehe [`crate::warteschlange`]. + pub liste: crate::warteschlange::Warteschlange, + /// Was von außen hereinkommt — siehe [`crate::signal`]. + pub signale: [crate::signal::Signal; crate::signal::SIGNALE], + /// Was geschehen ist, ohne dass jemand danach gefragt hat — siehe + /// [`crate::ereignis`]. + pub ereignisse: crate::ereignis::Ereignisse, + /// Was das Set vorhat — siehe [`crate::bogen`]. + pub bogen: crate::bogen::Bogen, + /// Was zuletzt gefahren wurde — siehe [`crate::vielfalt`]. + pub vielfalt: crate::vielfalt::Vielfalt, + /// Welches Signal den Bogen beugt — siehe [`crate::raum`]. `None` heißt: + /// Der Raum wird gehört, aber es folgt nichts daraus. + pub raum: Option, + /// Wie die Rampe heißt, die gerade schreibt; sonst `None`. + /// + /// Der letzte Schritt einer Blende und ein Schnitt schreiben denselben + /// Wert. Nur der Schreiber weiß, welches von beidem es war, und das steht + /// hier — gesetzt von [`crate::zeitplan`] genau um den einen Schreibvorgang + /// herum. + pub(crate) rampe_am_werk: Option, + /// Wann das Set angefangen hat. `None` heißt: noch nicht gestartet. + /// + /// Ohne Startzeit gibt es keinen Ort auf dem Bogen, und dann wird auch + /// keiner behauptet — eine Kurve ohne Uhr ist ein Bild, kein Maßstab. + pub set_start: Option, + decks: Vec, + kanaele: Vec, + master: MasterSpiegel, + handle: EngineHandle, + sammlung: Option>, + aufnahme: Option, + mitschrift: Option, +} + +impl Steuerpult { + pub fn neu(handle: EngineHandle) -> Steuerpult { + Steuerpult { + plan: crate::zeitplan::Zeitplan::neu(), + liste: crate::warteschlange::Warteschlange::neu(), + signale: std::array::from_fn(|_| crate::signal::Signal::neu()), + ereignisse: crate::ereignis::Ereignisse::neu(), + bogen: crate::bogen::Bogen::neu(), + vielfalt: crate::vielfalt::Vielfalt::neu(), + rampe_am_werk: None, + raum: None, + set_start: None, + decks: Vec::new(), + kanaele: Vec::new(), + master: MasterSpiegel::default(), + handle, + sammlung: None, + aufnahme: None, + mitschrift: None, + } + } + + /// Hängt den Mitschnitt an. Ohne das antwortet `record` mit einem Fehler, + /// statt stillschweigend nichts aufzunehmen. + pub fn aufnahme_setzen(&mut self, aufnahme: Aufnahme) { + self.aufnahme = Some(aufnahme); + } + + /// Hängt Suche und Laden an. Ohne das antworten beide mit einem Fehler, + /// statt stillschweigend nichts zu tun. + pub fn sammlung_setzen(&mut self, sammlung: Box) { + self.sammlung = Some(sammlung); + } + + pub fn deck_hinzufuegen(&mut self, eintrag: DeckEintrag) -> usize { + self.decks.push(eintrag); + self.decks.len() - 1 + } + + pub fn kanal_hinzufuegen(&mut self, spiegel: KanalSpiegel) -> usize { + self.kanaele.push(spiegel); + self.kanaele.len() - 1 + } + + pub fn decks(&self) -> &[DeckEintrag] { + &self.decks + } + + pub fn deck_mut(&mut self, index: usize) -> Option<&mut DeckEintrag> { + self.decks.get_mut(index) + } + + pub fn kanaele(&self) -> &[KanalSpiegel] { + &self.kanaele + } + + pub fn master(&self) -> &MasterSpiegel { + &self.master + } + + pub fn handle_mut(&mut self) -> &mut EngineHandle { + &mut self.handle + } + + /// Alle Controls, die es gerade gibt — mit Beschreibung. + /// + /// „Gerade" ist wörtlich zu nehmen: Die Liste wächst mit den angemeldeten + /// Decks und Kanälen. Ein Agent, der zwei Decks sieht, bekommt zwei Decks + /// aufgezählt, nicht die theoretisch möglichen vier. + pub fn liste(&self) -> Vec<(Schluessel, Beschreibung)> { + let mut aus = Vec::new(); + + for (i, _) in self.decks.iter().enumerate() { + for b in katalog::DECK { + aus.push((Schluessel::neu(Gruppe::Deck(i), b.element), b.clone())); + } + for c in 0..katalog::HOT_CUES { + if let Some(b) = katalog::hot_cue_beschreibung(c) { + aus.push((Schluessel::neu(Gruppe::Deck(i), b.element), b)); + } + } + for stelle in 0..katalog::MAX_STEMS { + for feld in ["name", "level"] { + if let Some(b) = katalog::stem_beschreibung(stelle, feld) { + aus.push((Schluessel::neu(Gruppe::Deck(i), b.element), b)); + } + } + } + } + + for (i, _) in self.kanaele.iter().enumerate() { + for b in katalog::KANAL { + aus.push((Schluessel::neu(Gruppe::Kanal(i), b.element), b.clone())); + } + } + + for b in katalog::MASTER { + aus.push((Schluessel::neu(Gruppe::Master, b.element), b.clone())); + } + + aus + } + + /// Beschreibung eines einzelnen Controls, sofern es das gibt. + pub fn beschreibung(&self, k: &Schluessel) -> Option { + match k.gruppe { + Gruppe::Deck(i) if i < self.decks.len() => katalog::DECK + .iter() + .find(|b| b.element == k.element) + .cloned() + .or_else(|| { + self.hot_cue_index(&k.element) + .and_then(katalog::hot_cue_beschreibung) + }) + .or_else(|| { + Self::stem_teile(&k.element) + .and_then(|(platz, feld)| katalog::stem_beschreibung(platz, feld)) + }), + Gruppe::Kanal(i) if i < self.kanaele.len() => katalog::KANAL + .iter() + .find(|b| b.element == k.element) + .cloned(), + Gruppe::Master => katalog::MASTER + .iter() + .find(|b| b.element == k.element) + .cloned(), + _ => None, + } + } + + fn hot_cue_index(&self, element: &str) -> Option { + let rest = element.strip_prefix("cue")?; + let nummer: usize = rest.parse().ok()?; + nummer.checked_sub(1).filter(|i| *i < katalog::HOT_CUES) + } + + pub fn lies(&self, k: &Schluessel) -> Result { + match self.beschreibung(k) { + None => return Err(Fehler::UnbekanntesControl(k.to_string())), + // Ein Auslöser hat keinen Zustand. Etwas zurückzugeben hieße, + // etwas zu erfinden. + Some(b) if b.art == Art::Aktion => return Err(Fehler::IstEineAktion(k.to_string())), + Some(_) => {} + } + + match k.gruppe { + Gruppe::Deck(i) => self.lies_deck(i, &k.element), + Gruppe::Kanal(i) => self.lies_kanal(i, &k.element), + Gruppe::Master => self.lies_master(&k.element), + } + .ok_or_else(|| Fehler::UnbekanntesControl(k.to_string())) + } + + /// Beats bis zur nächsten Phrasengrenze dieses Decks. + /// + /// Steht das Deck genau auf der Grenze, ist es 0 und nicht eine ganze + /// Phrase — sonst spränge die Zahl genau dort, wo man sie abliest. + /// + /// Liegt hier und nicht bei `lies_deck`, weil auch das Protokoll sie + /// braucht: `in phrase …` rechnet damit. Zwei Rechnungen für dieselbe + /// Frage wären zwei Stellen zum Auseinanderlaufen. + pub fn beats_bis_phrase(&self, deck: usize) -> Option { + let d = self.decks.get(deck)?; + let g = d.state.grid()?; + let beat = g.beat_at(d.state.position_frames() as f64, d.sample_rate); + let laenge = d.phrase_beats.max(1.0); + Some((laenge - beat.rem_euclid(laenge)).rem_euclid(laenge)) + } + + /// Wo alle Decks gerade stehen — für die Mitschrift. + /// + /// Decks ohne Beatgrid fehlen: Sie haben keinen Beat, auf den sich später + /// etwas beziehen ließe, und ein Platzhalter in der Zeile wäre nur eine + /// Zahl, die zu einer Aussage einlädt, die sie nicht trägt. + pub fn staende(&self) -> Vec { + self.decks + .iter() + .enumerate() + .filter_map(|(i, d)| { + let grid = d.state.grid()?; + Some(crate::mitschrift::Stand { + deck: i, + beat: grid.beat_at(d.state.position_frames() as f64, d.sample_rate), + phrase_beats: d.phrase_beats, + laeuft: d.state.is_playing(), + }) + }) + .collect() + } + + /// Hält fest, was geschah — falls eine Mitschrift läuft. + /// + /// Ohne laufenden Mitschnitt tut das nichts: Eine Mitschrift ohne Klang + /// daneben hätte keinen Bezugspunkt, an dem sich ihre Frames festmachen + /// ließen. + pub fn halten(&mut self, richtung: crate::mitschrift::Richtung, text: &str) { + if self.mitschrift.is_none() { + return; + } + let frame = self.aufnahme.as_ref().map(|a| a.frames()).unwrap_or(0); + let staende = self.staende(); + if let Some(m) = self.mitschrift.as_mut() { + m.halten(frame, &staende, richtung, text); + } + } + + /// Zerlegt `stem2_level` in Platz und Feld. + fn stem_teile(element: &str) -> Option<(usize, &'static str)> { + let rest = element.strip_prefix("stem")?; + let (nummer, feld) = match rest.split_once('_') { + Some((n, "name")) => (n, "name"), + Some((n, "level")) => (n, "level"), + _ => return None, + }; + let nummer: usize = nummer.parse().ok()?; + if nummer == 0 || nummer > audio_core::MAX_STEMS { + return None; + } + Some((nummer - 1, feld)) + } + + fn lies_deck(&self, i: usize, element: &str) -> Option { + let d = self.decks.get(i)?; + + // Ein Platz ohne Spur behauptet nichts. `Leer` und nicht 0: Ein Pegel + // von 0 hieße „zugedreht", und das ist etwas anderes als „da ist + // nichts". + if let Some((platz, feld)) = Self::stem_teile(element) { + if platz >= d.stems.len() { + return Some(Wert::Leer); + } + return Some(match feld { + "name" => Wert::Text(d.stems[platz].clone()), + _ => Wert::Zahl(d.state.stem_pegel(platz) as f64), + }); + } + let rate = d.sample_rate as f64; + let sek = |frames: u64| Wert::Zahl(frames as f64 / rate); + + let wert = match element { + "play" => Wert::Schalter(d.state.is_playing()), + "position" => sek(d.state.position_frames()), + "duration" => sek(d.frames), + "bpm" => match d.state.effective_bpm() { + Some(b) => Wert::Zahl(b as f64), + None => Wert::Leer, + }, + "bpm_grid" => match d.state.grid() { + Some(g) => Wert::Zahl(g.bpm as f64), + None => Wert::Leer, + }, + "grid_anchor" => match d.state.grid() { + Some(g) => sek(g.anchor_frames), + None => Wert::Leer, + }, + "tempo" => Wert::Zahl(d.state.tempo() as f64), + "keylock" => Wert::Schalter(d.state.keylock()), + "beat_phase" => match d.state.beat_phase(d.sample_rate) { + Some(p) => Wert::Zahl(p), + None => Wert::Leer, + }, + "beat" => match d.state.grid() { + Some(g) => Wert::Zahl(g.beat_at(d.state.position_frames() as f64, d.sample_rate)), + None => Wert::Leer, + }, + // Grid-Beats, also ohne den Tempo-Regler. Ein Beat ist eine feste + // Zahl Quell-Frames; schneller abgespielt vergeht er in weniger + // Zeit, aber es werden davon nicht mehr. Genauso rechnen `in` und + // `ramp` — eine zweite Zeitrechnung im selben Steuerraum wäre eine + // Falle. Wer Sekunden will, teilt selbst durch das Tempo. + "beats_left" => match (d.state.grid(), d.frames) { + (Some(g), ende) if ende > 0 => { + let jetzt = d.state.position_frames(); + let pro_beat = g.frames_per_beat(d.sample_rate); + Wert::Zahl(ende.saturating_sub(jetzt) as f64 / pro_beat) + } + _ => Wert::Leer, + }, + "phrase_beats" => Wert::Zahl(d.phrase_beats), + "beats_to_phrase" => match self.beats_bis_phrase(i) { + Some(b) => Wert::Zahl(b), + None => Wert::Leer, + }, + "stems" => Wert::Zahl(d.stems.len() as f64), + "loop_active" => Wert::Schalter(d.state.is_looping()), + "loop_beats" => match d.state.loop_range() { + // Aus der Schleifenlänge zurückgerechnet: Das Deck merkt sich + // Frames, nicht Beats. + Some((a, b)) => match d.state.grid() { + Some(g) => Wert::Zahl((b - a) as f64 / g.frames_per_beat(d.sample_rate)), + None => Wert::Leer, + }, + None => Wert::Leer, + }, + "title" => Wert::Text(d.titel.clone()), + "artist" => Wert::Text(d.artist.clone()), + // Zwei Felder statt eines: Menschen lesen `Am`, gemischt wird nach + // `8A`. Beides in eine Zeichenkette zu packen hieße, dass jeder + // Leser sie wieder auseinandernehmen muss. + "key" => match d.tonart { + Some(t) => Wert::Text(t.name()), + None => Wert::Leer, + }, + "key_camelot" => match d.tonart { + Some(t) => Wert::Text(t.camelot()), + None => Wert::Leer, + }, + // Die Gliederung. Alle vier antworten `Leer` statt 0, wenn nichts + // bekannt ist — „kein Outro" und „das Outro fängt jetzt an" sind + // zwei verschiedene Auskünfte, und eine 0 hieße beides. + "section" => match self.abschnitt(i) { + Some(a) => Wert::Text(a.art.name().to_string()), + None => Wert::Leer, + }, + "section_beats_left" => match (self.abschnitt(i), d.state.grid()) { + (Some(a), Some(g)) => { + let jetzt = d.state.position_frames() as f64; + Wert::Zahl( + ((a.bis_frames as f64 - jetzt) / g.frames_per_beat(d.sample_rate)).max(0.0), + ) + } + _ => Wert::Leer, + }, + "beats_to_outro" => match ( + d.struktur.as_ref().and_then(|s| s.outro_frames()), + d.state.grid(), + ) { + (Some(outro), Some(g)) => { + let jetzt = d.state.position_frames() as f64; + Wert::Zahl((outro as f64 - jetzt) / g.frames_per_beat(d.sample_rate)) + } + _ => Wert::Leer, + }, + "intro_beats" => match d.struktur.as_ref().and_then(|s| s.intro_beats()) { + Some(b) => Wert::Zahl(b), + None => Wert::Leer, + }, + "entry" => match d.struktur.as_ref().and_then(|s| s.einstieg_frames()) { + Some(f) => sek(f), + None => Wert::Leer, + }, + "finished" => Wert::Schalter(d.state.is_finished()), + "load_status" => Wert::Text(d.lade_status.clone()), + _ => { + let c = self.hot_cue_index(element)?; + match d.state.cue(c) { + Some(f) => sek(f), + None => Wert::Leer, + } + } + }; + + Some(wert) + } + + /// Wie weit das Set läuft, in Minuten. `None` ohne Start. + pub fn set_minuten(&self) -> Option { + self.set_start.map(|s| s.elapsed().as_secs_f64() / 60.0) + } + + /// Die Energie, die gerade wirklich läuft. + /// + /// Aus der **Art** des laufenden Abschnitts, nicht aus dem Pegel: Der ist + /// auf den lautesten Abschnitt desselben Tracks bezogen und über Tracks + /// hinweg nicht vergleichbar. Läuft mehr als ein Deck, zählt das lauteste + /// — beim Übergang ist das der, der den Raum trägt. + pub fn ist_energie(&self) -> Option { + (0..self.decks.len()) + .filter(|i| self.decks[*i].state.is_playing()) + .filter_map(|i| self.abschnitt(i)) + .map(|a| crate::bogen::energie(a.art)) + .max_by(f64::total_cmp) + } + + /// Wie weit der Raum das Ziel gerade verschiebt. + /// + /// `None`, wenn kein Raum gesetzt ist oder das Signal noch keinen Trend + /// hat — beides heißt „keine Beugung", aber aus verschiedenen Gründen, und + /// die Zahl 0 zu behaupten wäre in beiden Fällen mehr, als man weiß. + pub fn beugung(&self) -> Option { + let raum = self.raum.as_ref()?; + let signal = self.signale.get(raum.platz)?; + raum.beugung(signal.trend(std::time::Instant::now())) + } + + /// Was der Bogen laut Kurve vorsieht — ungebeugt. + pub fn soll_kurve(&self) -> Option { + self.set_minuten().and_then(|m| self.bogen.soll(m)) + } + + /// Was gerade angestrebt wird: die Kurve, um die Wirkung des Raums gebeugt. + /// + /// Begrenzt auf die Energieskala: Ein Ziel über 1 oder unter 0 wäre keins. + pub fn soll_ziel(&self) -> Option { + let kurve = self.soll_kurve()?; + Some((kurve + self.beugung().unwrap_or(0.0)).clamp(0.0, 1.0)) + } + + /// Warum als Nächstes das kommen soll, was kommen soll — in einem Satz. + /// + /// Der Satz ist der eigentliche Zweck des Raums. Eine Anlage, die ihr Ziel + /// still verschiebt, ist für ein Team unbrauchbar: Der Nächste sieht eine + /// Zahl, die nicht in der Kurve steht, und muss raten, ob ein Sender + /// gesprochen hat oder jemand die Kurve überschrieben hat. Deshalb steht + /// hier, was gerechnet wurde und woraus. + /// + /// Er behauptet nie mehr, als bekannt ist: Ohne Bogen sagt er, dass keiner + /// da ist; ohne Start, dass die Uhr fehlt; ohne Gliederung, dass die + /// Ist-Energie unbekannt ist. + pub fn begruendung(&self) -> String { + if self.bogen.ist_leer() { + return "kein Bogen gesetzt — ohne ihn ist der nächste Track Geschmackssache".into(); + } + let Some(minuten) = self.set_minuten() else { + return "Bogen steht, aber das Set hat nicht begonnen — es fehlt do master.arc_start" + .into(); + }; + let Some(kurve) = self.soll_kurve() else { + return format!("bei {minuten:.0} min endet der Bogen — er sagt nichts mehr"); + }; + + let mut satz = String::new(); + + // Was der Raum dazu sagt, wenn er etwas sagt. + if let (Some(raum), Some(beugung)) = (self.raum.as_ref(), self.beugung()) { + let signal = &self.signale[raum.platz]; + let name = if signal.name.is_empty() { + format!("signal{}", raum.platz + 1) + } else { + signal.name.clone() + }; + let trend = signal.trend(std::time::Instant::now()).unwrap_or(0.0); + let richtung = if trend > 0.0 { "steigt" } else { "fällt" }; + satz.push_str(&format!("{name} {richtung} ({trend:+.2}/min), ")); + if beugung.abs() >= 0.005 { + satz.push_str(&format!( + "Ziel {kurve:.2} → {:.2}; ", + self.soll_ziel().unwrap_or(kurve) + )); + } else { + satz.push_str(&format!("Ziel bleibt {kurve:.2}; ")); + } + } else { + satz.push_str(&format!("bei {minuten:.0} min will der Bogen {kurve:.2}; ")); + } + + match self.ist_energie() { + None => satz.push_str("was läuft, ist unbekannt — kein Deck mit Gliederung"), + Some(ist) => { + let luecke = self.soll_ziel().unwrap_or(kurve) - ist; + satz.push_str(&format!("es läuft {ist:.2}, ")); + satz.push_str(match luecke { + l if l > 0.05 => "mehr Energie gesucht", + l if l < -0.05 => "weniger gesucht", + _ => "es passt", + }); + satz.push_str(&format!(" ({luecke:+.2})")); + } + } + + // Wer viermal dasselbe gefahren hat, soll es hier lesen und nicht erst + // im Mitschnitt hören. + let wiederholung = self.vielfalt.wiederholung(); + if wiederholung >= 3 { + satz.push_str(&format!( + " — und {wiederholung}× hintereinander derselbe Griff" + )); + } + satz + } + + /// Der Abschnitt, in dem ein Deck gerade steht. + pub fn abschnitt(&self, deck: usize) -> Option { + let d = self.decks.get(deck)?; + let s = d.struktur.as_ref()?; + s.bei_frames(d.state.position_frames()).copied() + } + + fn lies_kanal(&self, i: usize, element: &str) -> Option { + let k = self.kanaele.get(i)?; + let wert = match element { + "trim" => Wert::Zahl(k.trim), + "eq_low" => Wert::Zahl(k.eq_low), + "eq_mid" => Wert::Zahl(k.eq_mid), + "eq_high" => Wert::Zahl(k.eq_high), + "filter" => Wert::Zahl(k.filter), + "fader" => Wert::Zahl(k.fader), + "cue" => Wert::Schalter(k.cue), + "assign" => Wert::Auswahl(assign_name(k.assign).to_string()), + "fx" => Wert::Auswahl(k.fx.name().to_string()), + "fx_mix" => Wert::Zahl(k.fx_mix), + "fx_amount" => Wert::Zahl(k.fx_amount), + "fx_time" => Wert::Zahl(k.fx_time), + _ => return None, + }; + Some(wert) + } + + /// Zerlegt `signal2_trend` in Platz und Feld. + fn signal_teile(element: &str) -> Option<(usize, &'static str)> { + let rest = element.strip_prefix("signal")?; + let (zahl, feld) = match rest.find('_') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, ""), + }; + let nummer: usize = zahl.parse().ok()?; + if nummer == 0 || nummer > crate::signal::SIGNALE { + return None; + } + let feld = match feld { + "" => "wert", + "_name" => "name", + "_trend" => "trend", + "_age" => "age", + _ => return None, + }; + Some((nummer - 1, feld)) + } + + fn lies_signal(&self, element: &str) -> Option { + let (i, feld) = Self::signal_teile(element)?; + let s = &self.signale[i]; + // Einmal genommen und für alle Felder benutzt: Sonst lägen `wert` und + // `age` desselben Signals an minimal verschiedenen Zeitpunkten. + let jetzt = std::time::Instant::now(); + + let wert = match feld { + "name" => Wert::Text(s.name.clone()), + "wert" => match s.wert() { + Some(v) => Wert::Zahl(v), + None => Wert::Leer, + }, + "trend" => match s.trend(jetzt) { + Some(v) => Wert::Zahl(v), + None => Wert::Leer, + }, + _ => match s.alter(jetzt) { + Some(v) => Wert::Zahl(v), + None => Wert::Leer, + }, + }; + Some(wert) + } + + fn lies_master(&self, element: &str) -> Option { + if element.starts_with("signal") { + return self.lies_signal(element); + } + let m = &self.master; + + // Der Mitschnitt hat seinen eigenen Zustand, nicht den des Mixers. + match element { + "events" => { + return Some(match self.ereignisse.letzte() { + Some(z) => Wert::Text(z.to_string()), + None => Wert::Leer, + }); + } + "event_count" => return Some(Wert::Zahl(self.ereignisse.nummer() as f64)), + "repeats" => { + return Some(Wert::Zahl(self.vielfalt.wiederholung() as f64)); + } + "transitions" => { + return Some(if self.vielfalt.ist_leer() { + Wert::Leer + } else { + Wert::Text(self.vielfalt.text()) + }); + } + "arc" => { + return Some(if self.bogen.ist_leer() { + Wert::Leer + } else { + Wert::Text(crate::bogen::text(&self.bogen)) + }); + } + "arc_minutes" => { + return Some(match self.set_minuten() { + Some(m) => Wert::Zahl(m), + None => Wert::Leer, + }); + } + // Das Ziel ist die Kurve **plus** dem, was der Raum sagt. Die + // Kurve selbst bleibt unter `arc_curve` stehen: Am Ende des Abends + // soll noch vergleichbar sein, was geplant war und was geschah. + "arc_target" => { + return Some(match self.soll_ziel() { + Some(e) => Wert::Zahl(e), + None => Wert::Leer, + }); + } + "arc_curve" => { + return Some(match self.soll_kurve() { + Some(e) => Wert::Zahl(e), + None => Wert::Leer, + }); + } + "room" => { + return Some(match &self.raum { + Some(r) => Wert::Text(r.text()), + None => Wert::Leer, + }); + } + "room_bend" => { + return Some(match self.beugung() { + Some(b) => Wert::Zahl(b), + None => Wert::Leer, + }); + } + "why" => return Some(Wert::Text(self.begruendung())), + "arc_actual" => { + return Some(match self.ist_energie() { + Some(e) => Wert::Zahl(e), + None => Wert::Leer, + }); + } + // Die Zahl, nach der gehandelt wird: positiv heißt, der Bogen will + // mehr, als gerade läuft. + "arc_gap" => { + let soll = self.soll_ziel(); + return Some(match (soll, self.ist_energie()) { + (Some(s), Some(i)) => Wert::Zahl(s - i), + _ => Wert::Leer, + }); + } + "arc_trend" => { + return Some( + match self + .set_minuten() + .and_then(|m| self.bogen.verlauf(m, VORAUS_MINUTEN)) + { + Some(v) => Wert::Text(v.name().to_string()), + None => Wert::Leer, + }, + ); + } + "recording" => { + return Some(Wert::Schalter( + self.aufnahme.as_ref().is_some_and(|a| a.laeuft()), + )) + } + "record_seconds" => { + return Some(Wert::Zahl( + self.aufnahme.as_ref().map(|a| a.sekunden()).unwrap_or(0.0), + )) + } + "record_dropped" => { + return Some(Wert::Zahl( + self.aufnahme.as_ref().map(|a| a.verworfen()).unwrap_or(0) as f64, + )) + } + _ => {} + } + + let wert = match element { + "crossfader" => Wert::Zahl(m.crossfader), + "crossfader_curve" => Wert::Zahl(m.crossfader_curve), + "gain" => Wert::Zahl(m.gain), + "cue_gain" => Wert::Zahl(m.cue_gain), + "cue_mix" => Wert::Zahl(m.cue_mix), + _ => return None, + }; + Some(wert) + } + + /// Löst eine Aktion aus. + /// + /// Antwortet mit Zeilen, die der Aufrufer zu sehen bekommt — `sync` meldet + /// zurück, wie weit die Phase danebenlag, `search` gibt seine Treffer aus. + /// Ein leerer Rückgabewert heißt schlicht: hat geklappt. + pub fn ausloesen( + &mut self, + k: &Schluessel, + argument: Option<&str>, + ) -> Result, Fehler> { + let Some(b) = self.beschreibung(k) else { + return Err(Fehler::UnbekanntesControl(k.to_string())); + }; + if b.art != Art::Aktion { + return Err(Fehler::IstKeineAktion(k.to_string())); + } + + let fehlt = || Fehler::Argument { + control: k.to_string(), + erwartet: b.argument.to_string(), + }; + + match (k.gruppe, k.element.as_str()) { + (Gruppe::Deck(i), "sync") => self.sync(i, argument), + (Gruppe::Deck(i), "load") => { + let pfad = argument.ok_or_else(fehlt)?; + self.laden(i, pfad) + } + (Gruppe::Deck(i), "jump_cue") => { + let nummer: usize = argument.and_then(|a| a.parse().ok()).ok_or_else(fehlt)?; + self.hot_cue_anspringen(i, nummer) + } + (Gruppe::Deck(i), "jump_entry") => self.einstieg_anspringen(i), + (Gruppe::Deck(i), "beatjump") => { + let beats: f64 = argument.and_then(|a| a.parse().ok()).ok_or_else(fehlt)?; + self.beatjump(i, beats) + } + (Gruppe::Deck(i), "grid_here") => self.grid_hierher(i), + (Gruppe::Deck(i), "grid_scale") => { + let faktor: f64 = argument.and_then(|a| a.parse().ok()).ok_or_else(fehlt)?; + self.grid_skalieren(i, faktor) + } + (Gruppe::Kanal(i), "fx_sync") => { + let beats: f64 = argument.and_then(|a| a.parse().ok()).ok_or_else(fehlt)?; + self.fx_sync(i, beats) + } + (Gruppe::Master, "record") => { + let pfad = argument.ok_or_else(fehlt)?; + self.aufnehmen(pfad) + } + (Gruppe::Master, "arc_start") => { + self.set_start = Some(std::time::Instant::now()); + Ok(vec!["arc gestartet".into()]) + } + (Gruppe::Master, "uebergang") => self.uebergang(argument), + (Gruppe::Master, "record_stop") => self.aufnahme_stoppen(), + (Gruppe::Master, "search") => { + let treffer = self.suche(argument.unwrap_or("")); + Ok(treffer_zeilen(&treffer)) + } + (Gruppe::Master, "playlists") => { + let namen = self.playlists(); + if namen.is_empty() { + return Ok(vec!["hinweis keine Playlists in der Sammlung".into()]); + } + Ok(namen.into_iter().map(|n| format!("playlist {n}")).collect()) + } + (Gruppe::Master, "playlist") => { + let name = argument.ok_or_else(fehlt)?; + let treffer = self.playlist(name); + if treffer.is_empty() { + return Err(Fehler::Gescheitert(format!( + "Playlist '{name}' ist leer oder gibt es nicht" + ))); + } + Ok(treffer_zeilen(&treffer)) + } + (Gruppe::Master, "search_mixable") => { + let bpm: f32 = argument.and_then(|a| a.parse().ok()).ok_or_else(fehlt)?; + let treffer = self.suche_mischbar(bpm); + Ok(treffer_zeilen(&treffer)) + } + (Gruppe::Master, "search_harmonic") => { + let roh = argument.ok_or_else(fehlt)?; + // Eine unlesbare Tonart muss auffallen. Sie stillschweigend + // als „keine Treffer" zu beantworten hieße, einen Tippfehler + // wie eine leere Sammlung aussehen zu lassen. + let tonart = Tonart::parse(roh).ok_or_else(|| Fehler::Argument { + control: k.to_string(), + erwartet: format!("eine Tonart wie Am, F# oder 8A — '{roh}' ist keine"), + })?; + let treffer = self.suche_harmonisch(tonart); + Ok(treffer_zeilen(&treffer)) + } + (Gruppe::Master, "search_next") => self.search_next(), + (Gruppe::Master, "queue") => Ok(self.listen_zeilen()), + (Gruppe::Master, "queue_add") => { + let pfad = argument.ok_or_else(fehlt)?; + self.liste_anhaengen(pfad) + } + (Gruppe::Master, "queue_note") => { + let roh = argument.ok_or_else(fehlt)?; + let (nr, text) = roh.split_once(char::is_whitespace).ok_or_else(fehlt)?; + let nr = Self::listen_nummer(k, nr)?; + if !self.liste.notieren(nr, text.trim().to_string()) { + return Err(Fehler::Gescheitert(format!( + "Nummer {nr} steht nicht in der Liste" + ))); + } + Ok(vec![format!("queue {nr} notiert")]) + } + (Gruppe::Master, "queue_bump") => { + let nr = Self::listen_nummer(k, argument.ok_or_else(fehlt)?)?; + if !self.liste.vorziehen(nr) { + return Err(Fehler::Gescheitert(format!( + "Nummer {nr} steht nicht in der Liste" + ))); + } + Ok(vec![format!("queue {nr} ist jetzt der Naechste")]) + } + (Gruppe::Master, "queue_drop") => { + let nr = Self::listen_nummer(k, argument.ok_or_else(fehlt)?)?; + match self.liste.streichen(nr) { + Some(e) => Ok(vec![format!("queue {nr} gestrichen {}", e.pfad)]), + None => Err(Fehler::Gescheitert(format!( + "Nummer {nr} steht nicht in der Liste" + ))), + } + } + (Gruppe::Master, "queue_clear") => { + Ok(vec![format!("ok {} gestrichen", self.liste.leeren())]) + } + (Gruppe::Master, "queue_next") => self.liste_auflegen(k, argument), + _ => Err(Fehler::UnbekanntesControl(k.to_string())), + } + } + + /// Zieht ein Deck auf ein anderes — Tempo **und** Phase. + /// + /// Ohne Argument das jeweils andere, was bei zwei Decks das Erwartbare + /// ist. Bei mehr Decks muss man sagen, welches gemeint ist, statt dass + /// eine Reihenfolge entscheidet. + fn sync(&mut self, slave: usize, argument: Option<&str>) -> Result, Fehler> { + let master = match argument { + Some(name) => match Gruppe::parse(name) { + Some(Gruppe::Deck(i)) => i, + _ => { + return Err(Fehler::Argument { + control: format!("deck{}.sync", slave + 1), + erwartet: "ein Deck, etwa deck1".into(), + }) + } + }, + None if self.decks.len() == 2 => 1 - slave.min(1), + None => { + return Err(Fehler::Argument { + control: format!("deck{}.sync", slave + 1), + erwartet: "bei mehr als zwei Decks das Ziel benennen".into(), + }) + } + }; + + if master == slave { + return Err(Fehler::Gescheitert( + "ein Deck lässt sich nicht auf sich selbst ziehen".into(), + )); + } + + let (Some(m), Some(s)) = (self.decks.get(master), self.decks.get(slave)) else { + return Err(Fehler::UnbekanntesControl(format!("deck{}", master + 1))); + }; + let rate = s.sample_rate; + + match audio_engine::sync(&m.state, &s.state, rate) { + Some(plan) => Ok(vec![format!( + "sync deck{} auf deck{} tempo {:.5} phase {:+.4}", + slave + 1, + master + 1, + plan.tempo, + plan.phase_error_beats + )]), + None => Err(Fehler::Gescheitert( + "Sync braucht auf beiden Decks ein Beatgrid".into(), + )), + } + } + + fn laden(&mut self, deck: usize, pfad: &str) -> Result, Fehler> { + if deck >= self.decks.len() { + return Err(Fehler::UnbekanntesControl(format!("deck{}", deck + 1))); + } + let Some(sammlung) = self.sammlung.as_ref() else { + return Err(Fehler::Gescheitert( + "keine Sammlung angeschlossen — Laden geht nicht".into(), + )); + }; + + sammlung.laden(deck, pfad).map_err(Fehler::Gescheitert)?; + // Der Auftrag ist angenommen, nicht erledigt. Wer wissen will, wann + // der Track liegt, fragt `load_status` oder abonniert es. + self.decks[deck].lade_status = "laedt".into(); + Ok(vec![format!("load deck{} angenommen", deck + 1)]) + } + + /// Die Liste als Zeilen: `queue `. + /// + /// Pfad vor Notiz und getrennt an der Dateiendung — dieselbe Form wie bei + /// den Suchtreffern, damit ein Leser nur eine Regel braucht. + fn listen_zeilen(&self) -> Vec { + if self.liste.ist_leer() { + return vec!["hinweis die Liste ist leer".into()]; + } + self.liste + .eintraege() + .iter() + .map(|e| { + let notiz = if e.notiz.is_empty() { "-" } else { &e.notiz }; + format!("queue {} {} {notiz}", e.id, e.pfad) + }) + .collect() + } + + fn listen_nummer(k: &Schluessel, roh: &str) -> Result { + roh.trim().parse::().map_err(|_| Fehler::Argument { + control: k.to_string(), + erwartet: format!("eine Nummer aus der Liste — '{}' ist keine", roh.trim()), + }) + } + + fn liste_anhaengen(&mut self, pfad: &str) -> Result, Fehler> { + match self.liste.anhaengen(pfad.to_string(), String::new()) { + Ok(nr) => Ok(vec![format!("queue {nr} angehaengt {pfad}")]), + Err(schon) => Err(Fehler::Gescheitert(format!( + "steht schon als Nummer {schon} in der Liste" + ))), + } + } + + /// Ein Deck, auf das gefahrlos geladen werden kann. + /// + /// Zuerst ein durchgelaufenes, sonst irgendeines, das steht. + fn freies_deck(&self) -> Option { + let steht = |i: &usize| !self.decks[*i].state.is_playing(); + (0..self.decks.len()) + .find(|i| steht(i) && self.decks[*i].state.is_finished()) + .or_else(|| (0..self.decks.len()).find(steht)) + } + + /// Legt den vordersten Eintrag auf. + /// + /// Ohne Deckangabe auf eines, das gerade nicht läuft. Laufen alle, wird das + /// gesagt statt geraten: Ein Track, der über einen laufenden gelegt wird, + /// reißt den Mix ab, und das ist keine Entscheidung, die eine Vorgabe + /// treffen darf. + fn liste_auflegen( + &mut self, + k: &Schluessel, + argument: Option<&str>, + ) -> Result, Fehler> { + let deck = match argument { + Some(name) => match Gruppe::parse(name) { + Some(Gruppe::Deck(i)) if i < self.decks.len() => i, + _ => { + return Err(Fehler::Argument { + control: k.to_string(), + erwartet: format!("ein Deck, etwa deck1 — '{name}' ist keins"), + }) + } + }, + None => self.freies_deck().ok_or_else(|| { + Fehler::Gescheitert( + "alle Decks laufen — Deck nennen, wenn wirklich darüber gelegt werden soll" + .into(), + ) + })?, + }; + + let Some(eintrag) = self.liste.abnehmen() else { + return Err(Fehler::Gescheitert("die Liste ist leer".into())); + }; + + // Scheitert das Laden, kommt der Eintrag zurück nach vorn. Sonst wäre + // er weg, ohne gespielt worden zu sein. + match self.laden(deck, &eintrag.pfad) { + Ok(mut zeilen) => { + zeilen.push(format!("queue {} abgenommen {}", eintrag.id, eintrag.pfad)); + if !eintrag.notiz.is_empty() { + zeilen.push(format!("notiz {}", eintrag.notiz)); + } + Ok(zeilen) + } + Err(e) => { + self.liste.zuruecklegen(eintrag); + Err(e) + } + } + } + + fn hot_cue_anspringen(&mut self, deck: usize, nummer: usize) -> Result, Fehler> { + let Some(d) = self.decks.get(deck) else { + return Err(Fehler::UnbekanntesControl(format!("deck{}", deck + 1))); + }; + let Some(index) = nummer.checked_sub(1).filter(|i| *i < katalog::HOT_CUES) else { + return Err(Fehler::Argument { + control: format!("deck{}.jump_cue", deck + 1), + erwartet: format!("1 bis {}", katalog::HOT_CUES), + }); + }; + + // Ein ungesetzter Cue springt nirgendwohin — das stumm an den Anfang + // zu deuten wäre auf einer Anlage ein Unfall. + if d.state.cue(index).is_none() { + return Err(Fehler::Gescheitert(format!( + "deck{}.cue{nummer} ist nicht gesetzt", + deck + 1 + ))); + } + + d.state.jump_to_cue(index); + Ok(Vec::new()) + } + + /// Setzt die Effektzeit auf so viele Beats des zugehörigen Decks. + /// + /// Ein Delay, das nicht im Takt steht, klingt nach Fehler. Die Umrechnung + /// braucht das Beatgrid, und das kennt nur das Deck — deshalb liegt sie + /// hier und nicht im Mixer, der von Decks nichts weiß. + fn fx_sync(&mut self, kanal: usize, beats: f64) -> Result, Fehler> { + if beats <= 0.0 { + return Err(Fehler::Argument { + control: format!("channel{}.fx_sync", kanal + 1), + erwartet: "eine positive Zahl von Beats".into(), + }); + } + + // Welches Deck hängt an diesem Zug? Ein AUX-Kanal hat keins. + let Some(deck) = self.decks.iter().find(|d| d.kanal == kanal) else { + return Err(Fehler::Gescheitert(format!( + "an channel{} hängt kein Deck — fx_time direkt setzen", + kanal + 1 + ))); + }; + let Some(bpm) = deck.state.effective_bpm() else { + return Err(Fehler::Gescheitert( + "das Deck hat kein Beatgrid — fx_time direkt setzen".into(), + )); + }; + + let sekunden = beats * 60.0 / bpm as f64; + let schluessel = Schluessel::neu(Gruppe::Kanal(kanal), "fx_time"); + self.schreibe(&schluessel, Wert::Zahl(sekunden))?; + + Ok(vec![format!( + "fx_sync channel{} {beats} Beats bei {bpm:.2} BPM = {sekunden:.4} s", + kanal + 1 + )]) + } + + /// Springt auf den Einstiegspunkt des Tracks. + /// + /// **Das ist der Griff, für den es S2 gibt.** Vorher fing ein eingehender + /// Track bei Frame 0 an, und die Mitschrift hat beim ersten gemessenen + /// Übergang gezeigt, was das kostet: Deck 2 setzte fünfzehn Beats neben + /// seiner eigenen Eins ein. + fn einstieg_anspringen(&mut self, deck: usize) -> Result, Fehler> { + let Some(d) = self.decks.get(deck) else { + return Err(Fehler::UnbekanntesControl(format!("deck{}", deck + 1))); + }; + let Some(frames) = d.struktur.as_ref().and_then(|s| s.einstieg_frames()) else { + return Err(Fehler::Gescheitert(format!( + "deck{} kennt keinen Einstiegspunkt — ohne Gliederung gibt es keinen", + deck + 1 + ))); + }; + d.state.seek_frames(frames); + Ok(vec![format!( + "jump_entry deck{} auf {:.3}s", + deck + 1, + frames as f64 / d.sample_rate as f64 + )]) + } + + fn beatjump(&mut self, deck: usize, beats: f64) -> Result, Fehler> { + let Some(d) = self.decks.get(deck) else { + return Err(Fehler::UnbekanntesControl(format!("deck{}", deck + 1))); + }; + let Some(grid) = d.state.grid() else { + return Err(Fehler::Gescheitert("Beatjump braucht ein Beatgrid".into())); + }; + + let je_beat = grid.frames_per_beat(d.sample_rate); + let jetzt = d.state.position_frames() as f64; + let ziel = (jetzt + beats * je_beat).clamp(0.0, d.frames as f64); + d.state.seek_frames(ziel as u64); + Ok(Vec::new()) + } + + /// Merkt einen benannten Übergang vor. + /// + /// **Es gibt hier nichts, was ein Bediener nicht auch selbst tippen + /// könnte.** Der Griff spart ihm eine Handvoll Zeilen, keine Magie — und + /// die Antwort nennt jede davon, damit er sie lesen, ändern und beim + /// nächsten Mal von Hand anders setzen kann. Sie laufen durch dasselbe + /// Protokoll wie alles andere, tauchen also im Plan auf, in der Mitschrift + /// und bei den Ereignissen. + fn uebergang(&mut self, argument: Option<&str>) -> Result, Fehler> { + let roh = argument.unwrap_or("").trim(); + let (name, rest) = roh.split_once(char::is_whitespace).unwrap_or((roh, "")); + + let Some(griff) = crate::uebergang::Griff::parse(name) else { + let alle: Vec<&str> = crate::uebergang::Griff::ALLE + .iter() + .map(|g| g.name()) + .collect(); + return Err(Fehler::Argument { + control: "master.uebergang".into(), + erwartet: format!("einen Griff: {}", alle.join(", ")), + }); + }; + + let beats = match rest.trim() { + "" => griff.beats(), + text => text.parse::().map_err(|_| Fehler::Argument { + control: "master.uebergang".into(), + erwartet: "eine Länge in Beats".into(), + })?, + }; + + let zeilen = crate::uebergang::zeilen(self, griff, beats)?; + // Der Name des Griffs sagt mehr als die Form seiner letzten Rampe. + // Eingetragen wird er erst, wenn der Crossfader wirklich ankommt. + self.vielfalt.vormerken(griff.name()); + let mut aus = vec![format!( + "uebergang {} über {beats} Beats, {} Zeilen", + griff.name(), + zeilen.len() + )]; + for z in zeilen { + // Durch denselben Weg wie alles andere — nicht an ihm vorbei. + let mut sitzung = crate::Sitzung::neu(); + let antwort = crate::behandle(self, &mut sitzung, &z); + aus.push(format!(" {z} → {}", antwort.lines().next().unwrap_or(""))); + } + Ok(aus) + } + + fn aufnehmen(&mut self, pfad: &str) -> Result, Fehler> { + let Some(aufnahme) = self.aufnahme.as_mut() else { + return Err(Fehler::Gescheitert("kein Mitschnitt angeschlossen".into())); + }; + + aufnahme + .starten(std::path::Path::new(pfad)) + .map_err(Fehler::Gescheitert)?; + let rate = aufnahme.rate(); + let mut zeilen = vec![format!("record läuft nach {pfad}")]; + + // Die Mitschrift gehört zum Mitschnitt und wird deshalb nicht einzeln + // eingeschaltet. Sie kostet ein paar Kilobyte je Set, und ein Set, von + // dem hinterher niemand mehr weiß, was gemeint war, kostet mehr. + let ziel = std::path::Path::new(pfad).with_extension(crate::mitschrift::ENDUNG); + match crate::mitschrift::Mitschrift::starten(&ziel, std::path::Path::new(pfad), rate) { + Ok(m) => { + zeilen.push(format!("mitschrift {}", m.pfad().display())); + self.mitschrift = Some(m); + } + // Ohne Mitschrift läuft der Mitschnitt trotzdem: Der Klang ist das + // Werk, die Mitschrift die Fußnote. Verschwiegen wird es nicht. + Err(e) => zeilen.push(format!("warnung Mitschrift nicht möglich — {e}")), + } + Ok(zeilen) + } + + fn aufnahme_stoppen(&mut self) -> Result, Fehler> { + let Some(aufnahme) = self.aufnahme.as_mut() else { + return Err(Fehler::Gescheitert("kein Mitschnitt angeschlossen".into())); + }; + + let sekunden = aufnahme.sekunden(); + let verworfen = aufnahme.verworfen(); + let Some(pfad) = aufnahme.stoppen() else { + return Err(Fehler::Gescheitert("es läuft kein Mitschnitt".into())); + }; + + let mut zeilen = vec![format!("record {} · {sekunden:.1} s", pfad.display())]; + // Lücken müssen dastehen, nicht erst beim Anhören auffallen. + if verworfen > 0 { + zeilen.push(format!( + "warnung {verworfen} Frames fehlen — der Mitschnitt hat Lücken" + )); + } + if let Some(m) = self.mitschrift.take() { + zeilen.push(format!("mitschrift {}", m.pfad().display())); + if m.fehler() > 0 { + zeilen.push(format!( + "warnung {} Zeilen der Mitschrift fehlen", + m.fehler() + )); + } + } + Ok(zeilen) + } + + /// Freitextsuche. Auch die Oberfläche geht hier durch — zwei Suchwege + /// wären zwei Stellen, an denen sich das Ergebnis unterscheiden könnte. + pub fn suche(&self, text: &str) -> Vec { + match self.sammlung.as_ref() { + Some(s) => s.suchen(text, GRENZE), + None => Vec::new(), + } + } + + pub fn suche_mischbar(&self, bpm: f32) -> Vec { + match self.sammlung.as_ref() { + Some(s) => s.suchen_mischbar(bpm, GRENZE), + None => Vec::new(), + } + } + + /// Tracks, deren Tonart harmonisch zu `tonart` passt. + pub fn suche_harmonisch(&self, tonart: Tonart) -> Vec { + match self.sammlung.as_ref() { + Some(s) => s.suchen_harmonisch(tonart, GRENZE), + None => Vec::new(), + } + } + + /// Was als Nächstes passt — nach Tempo, Tonart **und** dem, was der Bogen + /// und der Raum gerade wollen. + /// + /// Das ist die Stelle, an der sich die Schleife schließt: Bis hierher + /// konnte der Raum ein Ziel verschieben, aber die Auswahl lief weiter über + /// Tempo und Tonart allein — zwei Größen, die nichts darüber sagen, ob es + /// gerade lauter oder ruhiger werden soll. + /// + /// **Ausgewählt wird trotzdem nicht.** Die Liste ist sortiert und jede + /// Zeile sagt, warum sie dort steht; welcher Track es wird, entscheidet, + /// wer es begründen kann. Ein `do master.search_next`, das selbst lädt, + /// wäre etwas anderes — und genau das, was dieses Projekt nicht baut. + fn search_next(&self) -> Result, Fehler> { + let Some(deck) = (0..self.decks.len()).find(|i| self.decks[*i].state.is_playing()) else { + return Err(Fehler::Gescheitert( + "kein Deck läuft — ohne Tempo gibt es nichts zu vergleichen".into(), + )); + }; + let Some(bpm) = self.decks[deck].state.effective_bpm() else { + return Err(Fehler::Gescheitert(format!( + "deck{} hat kein Tempo — ohne das gibt es nichts zu vergleichen", + deck + 1 + ))); + }; + let Some(ziel) = self.soll_ziel() else { + return Err(Fehler::Gescheitert( + "kein laufender Bogen — ohne Ziel ist die Reihenfolge Geschmackssache. \ +set master.arc und do master.arc_start" + .into(), + )); + }; + + let tonart = self.decks[deck].tonart; + let mut treffer = self.suche_mischbar(bpm); + // Erst das Ziel, dann die Harmonie. Andersherum wäre die Liste eine + // harmonische mit Energie-Beiwerk, und die gibt es schon. + treffer.sort_by(|a, b| { + let d = |t: &Treffer| t.energie.map(|e| (e - ziel).abs()); + match (d(a), d(b)) { + (Some(x), Some(y)) if (x - y).abs() > 0.02 => x.total_cmp(&y), + // Ohne Analyse ans Ende: Ein Track, über den nichts bekannt + // ist, soll sich nicht in die Mitte mogeln. + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + _ => { + let passt = |t: &Treffer| match (tonart, t.tonart) { + (Some(a), Some(b)) => !a.passt_zu(&b), + _ => true, + }; + passt(a).cmp(&passt(b)) + } + } + }); + + let mut zeilen = vec![format!("weil {}", self.begruendung())]; + for t in treffer.iter().take(GRENZE) { + let bpm_text = match t.bpm { + Some(b) => format!("{b:.2}"), + None => "-".into(), + }; + let key = match t.tonart { + Some(k) => k.camelot(), + None => "-".into(), + }; + let grund = match t.energie { + Some(e) => format!("Energie {e:.2} ({:+.2} zum Ziel {ziel:.2})", e - ziel), + None => "nicht analysiert — Energie unbekannt".into(), + }; + let harmonie = match (tonart, t.tonart) { + (Some(a), Some(b)) if a.passt_zu(&b) => ", harmonisch", + (Some(_), Some(_)) => ", tonartfremd", + _ => "", + }; + zeilen.push(format!( + "track {bpm_text} {key} {} {} — {grund}{harmonie}", + t.pfad, t.titel + )); + } + if treffer.len() >= GRENZE { + zeilen.push(format!("hinweis auf {GRENZE} Treffer begrenzt")); + } + Ok(zeilen) + } + + /// Tonart eines Decks — der übliche Ausgangspunkt für die harmonische + /// Suche. + pub fn deck_tonart(&self, deck: usize) -> Option { + self.decks.get(deck).and_then(|d| d.tonart) + } + + /// Namen aller Playlists. + pub fn playlists(&self) -> Vec { + match self.sammlung.as_ref() { + Some(s) => s.playlists(), + None => Vec::new(), + } + } + + /// Die Tracks einer Playlist. + pub fn playlist(&self, name: &str) -> Vec { + match self.sammlung.as_ref() { + Some(s) => s.playlist(name, GRENZE), + None => Vec::new(), + } + } + + pub fn schreibe(&mut self, k: &Schluessel, wert: Wert) -> Result<(), Fehler> { + let Some(b) = self.beschreibung(k) else { + return Err(Fehler::UnbekanntesControl(k.to_string())); + }; + if b.art == Art::Aktion { + return Err(Fehler::IstEineAktion(k.to_string())); + } + if !b.schreibbar { + return Err(Fehler::NichtSchreibbar(k.to_string())); + } + + match k.gruppe { + Gruppe::Deck(i) => self.schreibe_deck(i, &b, k, wert), + Gruppe::Kanal(i) => self.schreibe_kanal(i, &b, k, wert), + Gruppe::Master => self.schreibe_master(&b, k, wert), + } + } + + /// Schreiben mit einem Wert aus 0..1, in den echten Bereich gedehnt. + /// + /// Das ist der Weg, den ein MIDI-Controller nimmt: Er kennt nur 0 bis 127 + /// und weiß nichts über Faktoren oder Bereiche. + pub fn schreibe_normiert(&mut self, k: &Schluessel, norm: f64) -> Result<(), Fehler> { + let Some(b) = self.beschreibung(k) else { + return Err(Fehler::UnbekanntesControl(k.to_string())); + }; + + let wert = match b.art { + Art::Schalter => Wert::Schalter(norm >= 0.5), + _ => Wert::Zahl(b.aus_normiert(norm)), + }; + self.schreibe(k, wert) + } + + fn zahl(b: &Beschreibung, k: &Schluessel, wert: &Wert) -> Result { + wert.als_zahl() + .map(|v| b.begrenzen(v)) + .ok_or_else(|| Fehler::FalscherTyp { + control: k.to_string(), + erwartet: b.art, + }) + } + + fn schalter(b: &Beschreibung, k: &Schluessel, wert: &Wert) -> Result { + wert.als_schalter().ok_or_else(|| Fehler::FalscherTyp { + control: k.to_string(), + erwartet: b.art, + }) + } + + fn schreibe_deck( + &mut self, + i: usize, + b: &Beschreibung, + k: &Schluessel, + wert: Wert, + ) -> Result<(), Fehler> { + let Some(d) = self.decks.get(i) else { + return Err(Fehler::UnbekanntesControl(k.to_string())); + }; + let rate = d.sample_rate as f64; + + // Ein Pegel für eine Spur, die es nicht gibt, wird abgewiesen statt + // stillschweigend geschluckt: Wer `stem3_level 0` schickt und nichts + // hört, soll erfahren, dass der Track nur zwei Spuren hat. + if let Some((platz, feld)) = Self::stem_teile(&k.element) { + if feld != "level" { + return Err(Fehler::NichtSchreibbar(k.to_string())); + } + if platz >= d.stems.len() { + return Err(Fehler::Gescheitert(format!( + "deck{} hat {} Einzelspuren", + i + 1, + d.stems.len() + ))); + } + let v = Self::zahl(b, k, &wert)?; + d.state.set_stem_pegel(platz, v as f32); + return Ok(()); + } + + match k.element.as_str() { + "play" => d.state.set_playing(Self::schalter(b, k, &wert)?), + "keylock" => d.state.set_keylock(Self::schalter(b, k, &wert)?), + "phrase_beats" => { + let laenge = Self::zahl(b, k, &wert)?; + // Über den Index und nicht über `d`: `d` ist nur geliehen, und + // die Phrasenlänge liegt im Eintrag, nicht im DeckState. + self.decks[i].phrase_beats = laenge; + return Ok(()); + } + "loop_active" => { + d.state.set_loop_active(Self::schalter(b, k, &wert)?); + } + "tempo" => d.state.set_tempo(Self::zahl(b, k, &wert)? as f32), + "position" => d + .state + .seek_frames((Self::zahl(b, k, &wert)? * rate).max(0.0) as u64), + "loop_beats" => { + let beats = Self::zahl(b, k, &wert)?; + // Ohne Grid gibt es keine Beats — dann passiert nichts, statt + // eine Schleife an einer geratenen Stelle zu setzen. + if d.state.set_loop_beats(beats, d.sample_rate) { + d.state.set_loop_active(true); + } + } + "bpm_grid" => { + let bpm = Self::zahl(b, k, &wert)? as f32; + // Der Anker bleibt stehen: Wer das Tempo korrigiert, meint fast + // immer den Oktavfehler, nicht die Lage der Eins. + let anker = d.state.grid().map(|g| g.anchor_frames).unwrap_or(0); + d.state.set_grid(Some(Beatgrid::new(bpm, anker, 1.0))); + self.grid_sichern(i); + } + "grid_anchor" => { + let sekunden = Self::zahl(b, k, &wert)?; + let Some(vorher) = d.state.grid() else { + return Err(Fehler::Gescheitert( + "ohne Tempo kein Anker — erst bpm_grid setzen".into(), + )); + }; + let frames = (sekunden * rate).max(0.0) as u64; + d.state + .set_grid(Some(Beatgrid::new(vorher.bpm, frames, 1.0))); + self.grid_sichern(i); + } + _ => { + let Some(c) = self.hot_cue_index(&k.element) else { + return Err(Fehler::UnbekanntesControl(k.to_string())); + }; + match wert { + // Ein Hot Cue lässt sich löschen, indem man ihn leert. + Wert::Leer => d.state.set_cue(c, None), + _ => { + let sek = Self::zahl(b, k, &wert)?; + d.state.set_cue(c, Some((sek * rate).max(0.0) as u64)); + } + } + // Sofort zurückschreiben, nicht beim Beenden: Ein Cue, der nur + // im Speicher steht, ist nach einem Absturz weg — und abgestürzt + // wird beim Auflegen. + self.cues_sichern(i); + } + } + + Ok(()) + } + + /// Legt den Anker auf die aktuelle Abspielposition. + /// + /// Der klassische Handgriff: Man hört, wo die Eins liegt, und sagt es dem + /// Raster. Das Tempo bleibt, wie es war — verschoben wird nur die Phase. + fn grid_hierher(&mut self, deck: usize) -> Result, Fehler> { + let Some(d) = self.decks.get(deck) else { + return Err(Fehler::UnbekanntesControl(format!("deck{}", deck + 1))); + }; + let Some(grid) = d.state.grid() else { + return Err(Fehler::Gescheitert( + "ohne Tempo kein Anker — erst bpm_grid setzen".into(), + )); + }; + + // Das Ziel, nicht die Anzeige: Wer eben gesprungen ist und dann sagt + // „hier ist die Eins", meint die Stelle, auf die er gesprungen ist. + let position = d.state.ziel_position(); + let sekunden = position as f64 / d.sample_rate as f64; + d.state + .set_grid(Some(Beatgrid::new(grid.bpm, position, 1.0))); + self.grid_sichern(deck); + + Ok(vec![format!( + "grid deck{} Anker auf {sekunden:.3} s", + deck + 1 + )]) + } + + /// Multipliziert das Grid-Tempo — 0.5 und 2 räumen Oktavfehler auf. + /// + /// Der Anker bleibt: Bei einer Halbierung liegt jede zweite Eins weiterhin + /// richtig, und beim Verdoppeln erst recht. + fn grid_skalieren(&mut self, deck: usize, faktor: f64) -> Result, Fehler> { + if faktor <= 0.0 { + return Err(Fehler::Argument { + control: format!("deck{}.grid_scale", deck + 1), + erwartet: "eine positive Zahl, meist 0.5 oder 2".into(), + }); + } + let Some(d) = self.decks.get(deck) else { + return Err(Fehler::UnbekanntesControl(format!("deck{}", deck + 1))); + }; + let Some(grid) = d.state.grid() else { + return Err(Fehler::Gescheitert( + "kein Grid zum Skalieren — erst bpm_grid setzen".into(), + )); + }; + + let neu = grid.bpm as f64 * faktor; + let begrenzt = neu.clamp(0.0, 400.0) as f32; + d.state + .set_grid(Some(Beatgrid::new(begrenzt, grid.anchor_frames, 1.0))); + self.grid_sichern(deck); + + Ok(vec![format!( + "grid deck{} {:.2} → {:.2} BPM", + deck + 1, + grid.bpm, + begrenzt + )]) + } + + /// Schreibt ein korrigiertes Beatgrid in die Sammlung zurück. + /// + /// Still, aus demselben Grund wie bei den Cues: Wer das Grid gerade zieht, + /// hat das Grid gemeint. Was schiefging, steht in `load_status`. + fn grid_sichern(&mut self, deck: usize) { + let Some(d) = self.decks.get(deck) else { + return; + }; + if d.pfad.is_empty() { + return; + } + let Some(grid) = d.state.grid() else { return }; + let Some(sammlung) = self.sammlung.as_ref() else { + return; + }; + + let anker = grid.anchor_frames as f64 / d.sample_rate as f64; + if let Err(e) = sammlung.grid_speichern(&d.pfad, grid.bpm, anker) { + self.decks[deck].lade_status = format!("grid nicht gespeichert: {e}"); + } + } + + /// Schreibt die Hot Cues eines Decks in die Sammlung zurück. + /// + /// Still: Wer einen Cue setzt, hat den Cue gemeint, nicht einen + /// Datenbankvorgang. Scheitert das Schreiben, steht der Cue trotzdem am + /// Deck und die laufende Nummer geht weiter — sichtbar wird es beim + /// nächsten Laden, und dort ist es auch zu reparieren. + fn cues_sichern(&mut self, deck: usize) { + let Some(d) = self.decks.get(deck) else { + return; + }; + if d.pfad.is_empty() { + return; + } + let Some(sammlung) = self.sammlung.as_ref() else { + return; + }; + + let rate = d.sample_rate as f64; + let cues: Vec<(usize, f64)> = (0..katalog::HOT_CUES) + .filter_map(|c| d.state.cue(c).map(|f| (c, f as f64 / rate))) + .collect(); + + if let Err(e) = sammlung.hot_cues_speichern(&d.pfad, &cues) { + self.decks[deck].lade_status = format!("cues nicht gespeichert: {e}"); + } + } + + fn schreibe_kanal( + &mut self, + i: usize, + b: &Beschreibung, + k: &Schluessel, + wert: Wert, + ) -> Result<(), Fehler> { + if i >= self.kanaele.len() { + return Err(Fehler::UnbekanntesControl(k.to_string())); + } + + // Erst prüfen und umrechnen, dann spiegeln, dann senden. Scheitert die + // Prüfung, ist der Spiegel unberührt und stimmt weiter mit dem Mixer + // überein. + let element = k.element.as_str(); + if element == "fx" { + let name = wert.als_text().ok_or_else(|| Fehler::FalscherTyp { + control: k.to_string(), + erwartet: Art::Auswahl, + })?; + let effekt = Effekt::aus_name(name).ok_or_else(|| Fehler::UnbekannteAuswahl { + control: k.to_string(), + erlaubt: b.auswahl.iter().map(|s| s.to_string()).collect(), + })?; + self.kanaele[i].fx = effekt; + self.handle.send(Command::Fx(i, effekt)); + return Ok(()); + } + + if element == "assign" { + let name = wert.als_text().ok_or_else(|| Fehler::FalscherTyp { + control: k.to_string(), + erwartet: Art::Auswahl, + })?; + let assign = assign_aus_name(name).ok_or_else(|| Fehler::UnbekannteAuswahl { + control: k.to_string(), + erlaubt: b.auswahl.iter().map(|s| s.to_string()).collect(), + })?; + self.kanaele[i].assign = assign; + self.handle.send(Command::Assign(i, assign)); + return Ok(()); + } + + if element == "cue" { + let an = Self::schalter(b, k, &wert)?; + self.kanaele[i].cue = an; + self.handle.send(Command::Cue(i, an)); + return Ok(()); + } + + let v = Self::zahl(b, k, &wert)?; + let kanal = &mut self.kanaele[i]; + + let befehl = match element { + "trim" => { + kanal.trim = v; + Command::Trim(i, v as f32) + } + "fader" => { + kanal.fader = v; + Command::Fader(i, v as f32) + } + "filter" => { + kanal.filter = v; + Command::Filter(i, v as f32) + } + "fx_mix" => { + kanal.fx_mix = v; + Command::FxMix(i, v as f32) + } + "fx_amount" => { + kanal.fx_amount = v; + Command::FxAmount(i, v as f32) + } + "fx_time" => { + kanal.fx_time = v; + Command::FxTime(i, v as f32) + } + "eq_low" | "eq_mid" | "eq_high" => { + match element { + "eq_low" => kanal.eq_low = v, + "eq_mid" => kanal.eq_mid = v, + _ => kanal.eq_high = v, + } + // Der EQ wird als Ganzes geschickt — die drei Bänder hängen im + // Mixer an einem Filtersatz. + Command::Eq { + channel: i, + low: kanal.eq_low as f32, + mid: kanal.eq_mid as f32, + high: kanal.eq_high as f32, + } + } + _ => return Err(Fehler::UnbekanntesControl(k.to_string())), + }; + + self.handle.send(befehl); + Ok(()) + } + + fn schreibe_master( + &mut self, + b: &Beschreibung, + k: &Schluessel, + wert: Wert, + ) -> Result<(), Fehler> { + // Signale gehen nicht in den Mixer, sondern in die Ablage daneben. + if let Some((i, feld)) = Self::signal_teile(&k.element) { + return match feld { + "name" => { + let Wert::Text(name) = wert else { + return Err(Fehler::FalscherTyp { + control: k.to_string(), + erwartet: crate::wert::Art::Text, + }); + }; + self.signale[i].name = name; + Ok(()) + } + "wert" => { + let v = Self::zahl(b, k, &wert)?; + self.signale[i].setzen(b.begrenzen(v), std::time::Instant::now()); + Ok(()) + } + // Trend und Alter rechnen sich aus den Proben; sie zu setzen + // hieße, die Vergangenheit zu behaupten. + _ => Err(Fehler::NichtSchreibbar(k.to_string())), + }; + } + + // Der Bogen ist Text und geht nicht in den Mixer. + if k.element == "arc" { + let Wert::Text(text) = wert else { + return Err(Fehler::FalscherTyp { + control: k.to_string(), + erwartet: crate::wert::Art::Text, + }); + }; + // Ein unlesbarer Bogen wird abgewiesen, nicht halb übernommen: Ein + // Set gegen eine Kurve zu fahren, die niemand gemeint hat, ist + // schlimmer als eines ohne Kurve. + self.bogen = crate::bogen::parse(&text).map_err(Fehler::Gescheitert)?; + return Ok(()); + } + + if k.element == "room" { + let Wert::Text(text) = wert else { + return Err(Fehler::FalscherTyp { + control: k.to_string(), + erwartet: crate::wert::Art::Text, + }); + }; + // Abschalten muss sagbar sein, sonst bliebe ein einmal gesetzter + // Sender bis zum Neustart am Ruder. Nicht nur leer: Über MCP kommt + // eine leere Zeichenkette gar nicht erst an — das Werkzeug verlangt + // mindestens ein Zeichen. Ein Weg, den nur der Socket kennt, ist + // für ein Team von Agenten keiner. + if matches!(text.trim(), "" | "-" | "aus") { + self.raum = None; + return Ok(()); + } + // Ein Platz, den es nicht gibt, wird abgewiesen statt auf einen + // anderen umgebogen — derselbe Grund wie beim Bogen. + let raum = + crate::raum::Raum::parse(&text, crate::signal::SIGNALE).ok_or_else(|| { + Fehler::Gescheitert(format!( + "unlesbar: erwartet 'signalN [gewicht]', N von 1 bis {}", + crate::signal::SIGNALE + )) + })?; + self.raum = Some(raum); + return Ok(()); + } + + let v = Self::zahl(b, k, &wert)?; + + let befehl = match k.element.as_str() { + "crossfader" => { + // Angekommen ist er, wenn er die Seite wechselt — einmal je + // Übergang, egal ob die Bewegung aus einem Sprung bestand oder + // aus achtzig Rampenschritten. Wie er ankam, weiß nur der + // Schreiber: Fährt eine Rampe, ist es ihre Form; sonst war es + // ein Schnitt. + let neue_seite = crate::vielfalt::seite(v); + // Und nur, wenn es überhaupt etwas zu wechseln gab: Wer vor dem + // Set den Fader auf die Seite des einzigen laufenden Decks + // stellt, richtet ein, er blendet nicht. Alle fünf Griffe + // starten das eingehende Deck, bevor der Fader sich bewegt — + // zwei laufende Decks sind also nicht die Ausnahme, sondern das + // Kennzeichen. + let spielende = self.decks.iter().filter(|d| d.state.is_playing()).count(); + if neue_seite != 0 + && neue_seite != crate::vielfalt::seite(self.master.crossfader) + && spielende >= 2 + { + let wie = self.rampe_am_werk.clone(); + self.vielfalt + .angekommen(wie.as_deref().unwrap_or("schnitt")); + } + self.master.crossfader = v; + Command::Crossfader(v as f32) + } + "crossfader_curve" => { + self.master.crossfader_curve = v; + Command::CrossfaderCurve(v as f32) + } + "gain" => { + self.master.gain = v; + Command::MasterGain(v as f32) + } + "cue_gain" => { + self.master.cue_gain = v; + Command::CueGain(v as f32) + } + "cue_mix" => { + self.master.cue_mix = v; + Command::CueMix(v as f32) + } + _ => return Err(Fehler::UnbekanntesControl(k.to_string())), + }; + + self.handle.send(befehl); + Ok(()) + } +} + +/// Wie viele Treffer eine Suche höchstens zurückgibt. +pub const GRENZE: usize = 200; + +fn treffer_zeilen(treffer: &[Treffer]) -> Vec { + let mut zeilen: Vec = treffer + .iter() + .map(|t| { + let bpm = match t.bpm { + Some(b) => format!("{b:.2}"), + None => "-".into(), + }; + // Camelot, nicht der Name: Wer die Zeile liest, will mischen, und + // dafür ist die Zahl auf dem Rad die brauchbare Form. + let key = match t.tonart { + Some(k) => k.camelot(), + None => "-".into(), + }; + format!("track {bpm} {key} {} {}", t.pfad, t.titel) + }) + .collect(); + + // Wenn die Grenze greift, muss das dastehen. Eine abgeschnittene Liste, + // die wie eine vollständige aussieht, ist eine Falschaussage. + if treffer.len() >= GRENZE { + zeilen.push(format!("hinweis auf {GRENZE} Treffer begrenzt")); + } + zeilen +} + +pub fn assign_name(assign: Assign) -> &'static str { + match assign { + Assign::A => "a", + Assign::B => "b", + Assign::Thru => "thru", + } +} + +pub fn assign_aus_name(name: &str) -> Option { + match name { + "a" | "A" => Some(Assign::A), + "b" | "B" => Some(Assign::B), + "thru" | "Thru" => Some(Assign::Thru), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + + fn k(text: &str) -> Schluessel { + Schluessel::parse(text).unwrap() + } + + /// Vergleich mit Toleranz. + /// + /// Tempo und Reglerwerte liegen im Deck und im Mixer als `f32` — dort + /// gehört die Genauigkeit hin, denn sie werden pro Sample benutzt. Auf + /// exakte Gleichheit zu prüfen hieße, eine Genauigkeit zu behaupten, die + /// es nicht gibt: 1.04 als `f32` und zurück ist 1.0399999618530273. + fn gleich(a: &Wert, b: &Wert) -> bool { + match (a, b) { + (Wert::Zahl(x), Wert::Zahl(y)) => (x - y).abs() < 1e-6, + _ => a == b, + } + } + + #[test] + fn geschriebene_werte_lassen_sich_wieder_lesen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + for (control, wert) in [ + ("channel1.fader", Wert::Zahl(0.75)), + ("channel1.eq_low", Wert::Zahl(0.0)), + ("channel2.trim", Wert::Zahl(1.5)), + ("master.crossfader", Wert::Zahl(-0.5)), + ("master.gain", Wert::Zahl(0.9)), + ("deck1.tempo", Wert::Zahl(1.04)), + ("deck2.keylock", Wert::Schalter(true)), + ("deck1.play", Wert::Schalter(true)), + ] { + pult.schreibe(&k(control), wert.clone()).expect(control); + let gelesen = pult.lies(&k(control)).expect(control); + assert!( + gleich(&gelesen, &wert), + "{control}: {gelesen:?} statt {wert:?}" + ); + } + } + + #[test] + fn der_spiegel_bleibt_bei_einem_fehler_unberuehrt() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("channel1.fader"), Wert::Zahl(0.6)) + .unwrap(); + + // Ein Text auf einem Zahlen-Control muss scheitern … + let fehler = pult.schreibe(&k("channel1.fader"), Wert::Text("laut".into())); + assert!(fehler.is_err()); + + // … und darf den vorherigen Wert nicht angetastet haben, sonst liefen + // Spiegel und Mixer auseinander. + assert_eq!(pult.lies(&k("channel1.fader")).unwrap(), Wert::Zahl(0.6)); + } + + #[test] + fn werte_ausserhalb_des_bereichs_werden_begrenzt() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + pult.schreibe(&k("channel1.fader"), Wert::Zahl(9.0)) + .unwrap(); + assert_eq!(pult.lies(&k("channel1.fader")).unwrap(), Wert::Zahl(1.0)); + + pult.schreibe(&k("deck1.tempo"), Wert::Zahl(3.0)).unwrap(); + assert!(gleich( + &pult.lies(&k("deck1.tempo")).unwrap(), + &Wert::Zahl(katalog::TEMPO_MAX) + )); + } + + #[test] + fn nur_lesbare_controls_weisen_das_schreiben_ab() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let fehler = pult.schreibe(&k("deck1.duration"), Wert::Zahl(10.0)); + assert_eq!( + fehler, + Err(Fehler::NichtSchreibbar("deck1.duration".into())) + ); + } + + #[test] + fn unbekannte_controls_werden_benannt_abgewiesen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + assert_eq!( + pult.lies(&k("deck1.quatsch")), + Err(Fehler::UnbekanntesControl("deck1.quatsch".into())) + ); + // Ein Deck, das es nicht gibt, ist kein stiller Fehlschlag. + assert_eq!( + pult.lies(&k("deck9.play")), + Err(Fehler::UnbekanntesControl("deck9.play".into())) + ); + assert!(pult + .schreibe(&k("channel9.fader"), Wert::Zahl(1.0)) + .is_err()); + } + + #[test] + fn die_auswahl_nimmt_nur_ihre_eigenen_namen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + pult.schreibe(&k("channel1.assign"), Wert::Auswahl("thru".into())) + .unwrap(); + assert_eq!( + pult.lies(&k("channel1.assign")).unwrap(), + Wert::Auswahl("thru".into()) + ); + + let fehler = pult.schreibe(&k("channel1.assign"), Wert::Auswahl("mitte".into())); + assert!(matches!(fehler, Err(Fehler::UnbekannteAuswahl { .. }))); + } + + #[test] + fn normiertes_schreiben_trifft_die_enden_und_die_mitte() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + // Ein MIDI-Regler in der Raste steht bei einem bipolaren Control auf 0. + pult.schreibe_normiert(&k("channel1.filter"), 0.5).unwrap(); + assert_eq!(pult.lies(&k("channel1.filter")).unwrap(), Wert::Zahl(0.0)); + + pult.schreibe_normiert(&k("channel1.filter"), 1.0).unwrap(); + assert_eq!(pult.lies(&k("channel1.filter")).unwrap(), Wert::Zahl(1.0)); + + // Auf einem Schalter ist die obere Hälfte „an". + pult.schreibe_normiert(&k("deck1.play"), 1.0).unwrap(); + assert_eq!(pult.lies(&k("deck1.play")).unwrap(), Wert::Schalter(true)); + pult.schreibe_normiert(&k("deck1.play"), 0.0).unwrap(); + assert_eq!(pult.lies(&k("deck1.play")).unwrap(), Wert::Schalter(false)); + } + + /// Ein gesetzter Cue muss den Trackwechsel überleben. + /// + /// Vorher lag er nur in den Atomics des Decks: acht Cues gesetzt, Track neu + /// geladen, weg. Und die Sammlung hatte die Tabelle die ganze Zeit. + #[test] + fn ein_gesetzter_cue_landet_in_der_sammlung() { + let (mut pult, _runner, protokoll, _) = crate::testing::pult_mit_protokoll(); + pult.deck_mut(0).unwrap().pfad = "/musik/a.wav".into(); + + pult.schreibe(&k("deck1.cue2"), Wert::Zahl(10.0)).unwrap(); + pult.schreibe(&k("deck1.cue5"), Wert::Zahl(30.5)).unwrap(); + + let gesichert = protokoll.lock().unwrap()["/musik/a.wav"].clone(); + assert_eq!(gesichert, vec![(1, 10.0), (4, 30.5)], "{gesichert:?}"); + + // Löschen wird genauso weitergereicht, sonst käme ein gelöschter Cue + // beim nächsten Laden wieder. + pult.schreibe(&k("deck1.cue2"), Wert::Leer).unwrap(); + assert_eq!( + protokoll.lock().unwrap()["/musik/a.wav"], + vec![(4, 30.5)], + "ein gelöschter Cue käme sonst beim nächsten Laden wieder" + ); + } + + /// Ohne geladenen Track gibt es nichts zurückzuschreiben. + #[test] + fn ein_leeres_deck_schreibt_keine_cues() { + let (mut pult, _runner, protokoll, _) = crate::testing::pult_mit_protokoll(); + pult.schreibe(&k("deck1.cue1"), Wert::Zahl(5.0)).unwrap(); + assert!(protokoll.lock().unwrap().is_empty()); + } + + /// Ein fehlgeschlagenes Speichern darf den Cue nicht verschlucken — und + /// nicht stillschweigend durchgehen. + #[test] + fn ein_gescheitertes_speichern_steht_im_status() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.deck_mut(0).unwrap().pfad = "/musik/a.schreibgeschuetzt".into(); + + pult.schreibe(&k("deck1.cue1"), Wert::Zahl(5.0)).unwrap(); + + // Der Cue steht am Deck … + assert_eq!(pult.lies(&k("deck1.cue1")).unwrap(), Wert::Zahl(5.0)); + // … und dass er nicht in der Sammlung ankam, ist ablesbar. + let Wert::Text(status) = pult.lies(&k("deck1.load_status")).unwrap() else { + panic!("load_status ist kein Text"); + }; + assert!(status.contains("cues nicht gespeichert"), "{status}"); + } + + #[test] + fn hot_cues_lassen_sich_setzen_und_wieder_leeren() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + assert_eq!(pult.lies(&k("deck1.cue3")).unwrap(), Wert::Leer); + + pult.schreibe(&k("deck1.cue3"), Wert::Zahl(12.5)).unwrap(); + assert_eq!(pult.lies(&k("deck1.cue3")).unwrap(), Wert::Zahl(12.5)); + + pult.schreibe(&k("deck1.cue3"), Wert::Leer).unwrap(); + assert_eq!(pult.lies(&k("deck1.cue3")).unwrap(), Wert::Leer); + + // Über die Anzahl hinaus gibt es keine. + assert!(pult.lies(&k("deck1.cue9")).is_err()); + } + + #[test] + fn die_liste_waechst_mit_den_angemeldeten_geraeten() { + let (pult, _runner) = pult_mit_zwei_decks(); + let liste = pult.liste(); + + // Zwei Decks, drei Kanäle, ein Master — nichts Erfundenes. + assert!(liste.iter().any(|(k, _)| k.to_string() == "deck2.play")); + assert!(liste.iter().any(|(k, _)| k.to_string() == "channel3.fader")); + assert!(!liste.iter().any(|(k, _)| k.to_string() == "deck3.play")); + assert!(!liste.iter().any(|(k, _)| k.to_string() == "channel4.fader")); + + // Und jedes aufgezählte Control lässt sich auch wirklich lesen — + // Aktionen ausgenommen, die haben keinen Zustand. + for (schluessel, b) in &liste { + if b.art == Art::Aktion { + continue; + } + pult.lies(schluessel) + .unwrap_or_else(|e| panic!("{schluessel} steht in der Liste, aber: {e}")); + } + } + + #[test] + fn jedes_schreibbare_control_laesst_sich_auch_schreiben() { + // Der Katalog verspricht Schreibbarkeit — das hier prüft, dass die + // Umsetzung dahinter existiert und nicht nur die Beschreibung. + let (mut pult, _runner) = pult_mit_zwei_decks(); + // Deck 1 bekommt Einzelspuren, damit deren Pegel wirklich geschrieben + // werden und nicht bloß übersprungen. + pult.deck_mut(0).unwrap().stems = vec!["vocals".into(), "drums".into()]; + + for (schluessel, b) in pult.liste() { + if !b.schreibbar || b.art == Art::Aktion { + continue; + } + // Ein Spur-Pegel steht im Katalog auf jedem Deck — der ist + // statisch, mit Absicht. Schreibbar ist er nur, wenn der Track die + // Spur auch hat, und eine Absage ist dort die richtige Antwort, + // nicht ein stiller Erfolg. Deck 2 hat oben keine bekommen. + if let Some((platz, _)) = Steuerpult::stem_teile(&schluessel.element) { + let hat = match schluessel.gruppe { + Gruppe::Deck(i) => pult.decks()[i].stems.len(), + _ => 0, + }; + if platz >= hat { + assert!( + pult.schreibe(&schluessel, Wert::Zahl(0.5)).is_err(), + "{schluessel} nahm einen Pegel an, obwohl es die Spur nicht gibt" + ); + continue; + } + } + let wert = match b.art { + Art::Schalter => Wert::Schalter(true), + Art::Auswahl => Wert::Auswahl(b.auswahl[0].to_string()), + // Ein Textfeld nimmt keine Zahl: Der Typ ist die Zusage, und + // sie stillschweigend zu dehnen hieße, sie aufzugeben. + // + // Nicht jeder Text ist beliebig: Der Bogen ist eine Kurve in + // Textform, und „Probe" ist keine. Dass dieser Wächter das + // gemerkt hat, ist der Punkt — ein Feld, das Text *annimmt*, + // ist nicht dasselbe wie eines, das jeden Text annimmt. + Art::Text if schluessel.element == "arc" => Wert::Text("0 0.3, 60 0.9".into()), + Art::Text if schluessel.element == "room" => Wert::Text("signal1 0.3".into()), + Art::Text => Wert::Text("Probe".into()), + _ => Wert::Zahl(b.aus_normiert(0.5)), + }; + pult.schreibe(&schluessel, wert) + .unwrap_or_else(|e| panic!("{schluessel} ist als schreibbar gemeldet, aber: {e}")); + } + } + + #[test] + fn jede_aufgezaehlte_aktion_ist_auch_umgesetzt() { + // Die Gegenprobe zur Liste: Eine Aktion, die im Katalog steht, aber + // im Pult nicht verdrahtet ist, würde sich mit „unbekanntes Control" + // melden — und das wäre eine Lüge, denn aufgezählt hat sie sich. + let (mut pult, _runner) = pult_mit_zwei_decks(); + + for (schluessel, b) in pult.liste() { + if b.art != Art::Aktion { + continue; + } + // Mit einem plausiblen Argument, damit nicht schon daran scheitert. + let argument = match schluessel.element.as_str() { + "load" => Some("/musik/track.wav"), + "jump_cue" => Some("1"), + "beatjump" => Some("4"), + "search" => Some("test"), + "search_mixable" => Some("128"), + "search_harmonic" => Some("Am"), + "grid_scale" => Some("2"), + "playlist" => Some("Freitag"), + "fx_sync" => Some("1"), + "record" => Some("/tmp/musik-katalogtest.wav"), + "queue_add" => Some("/musik/vorgemerkt.wav"), + "queue_note" => Some("1 passt harmonisch"), + "queue_bump" | "queue_drop" => Some("1"), + "uebergang" => Some("blende"), + _ => None, + }; + + match pult.ausloesen(&schluessel, argument) { + Ok(_) => {} + // Ein sachlicher Fehlschlag ist in Ordnung — ein nicht + // gesetzter Hot Cue etwa. Ein unbekanntes Control nicht. + Err(Fehler::Gescheitert(_)) => {} + Err(e) => panic!("{schluessel} steht im Katalog, aber: {e}"), + } + } + } +} + +#[cfg(test)] +mod aktions_tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + + fn k(text: &str) -> Schluessel { + Schluessel::parse(text).unwrap() + } + + #[test] + fn sync_zieht_das_deck_auf_das_andere() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.decks()[0] + .state + .set_grid(Some(audio_core::Beatgrid::new(128.0, 0, 1.0))); + pult.decks()[1] + .state + .set_grid(Some(audio_core::Beatgrid::new(124.0, 0, 1.0))); + + let zeilen = pult.ausloesen(&k("deck2.sync"), None).expect("sync"); + assert!(zeilen[0].starts_with("sync deck2 auf deck1"), "{zeilen:?}"); + + // Das Tempo muss wirklich angekommen sein: 128/124 ≈ 1.0323. + let Wert::Zahl(tempo) = pult.lies(&k("deck2.tempo")).unwrap() else { + panic!("Tempo ist keine Zahl"); + }; + assert!((tempo - 128.0 / 124.0).abs() < 1e-3, "Tempo {tempo}"); + } + + #[test] + fn sync_ohne_beatgrid_sagt_das_statt_zu_raten() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.decks()[1].state.set_grid(None); + + let fehler = pult.ausloesen(&k("deck2.sync"), None); + assert!(matches!(fehler, Err(Fehler::Gescheitert(_))), "{fehler:?}"); + } + + #[test] + fn ein_deck_laesst_sich_nicht_auf_sich_selbst_ziehen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let fehler = pult.ausloesen(&k("deck1.sync"), Some("deck1")); + assert!(matches!(fehler, Err(Fehler::Gescheitert(_))), "{fehler:?}"); + } + + #[test] + fn hot_cues_lassen_sich_ausloesen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("deck1.cue2"), Wert::Zahl(10.0)).unwrap(); + + // Der Sprung selbst ist erst ein Wunsch, den der Audio-Thread + // ausführt — dass er ankommt, prüfen die Tests in `audio-core`. Hier + // zählt, dass die Aktion überhaupt durchgereicht wird. + assert!(pult.ausloesen(&k("deck1.jump_cue"), Some("2")).is_ok()); + } + + #[test] + fn ein_ungesetzter_hot_cue_springt_nirgendwohin() { + // Stumm an den Anfang zu springen wäre auf einer Anlage ein Unfall. + let (mut pult, _runner) = pult_mit_zwei_decks(); + let fehler = pult.ausloesen(&k("deck1.jump_cue"), Some("5")); + assert!(matches!(fehler, Err(Fehler::Gescheitert(_))), "{fehler:?}"); + + let daneben = pult.ausloesen(&k("deck1.jump_cue"), Some("99")); + assert!( + matches!(daneben, Err(Fehler::Argument { .. })), + "{daneben:?}" + ); + } + + /// Der häufigste Fall: ein Oktavfehler des Detektors. + /// + /// 180 statt 90 ist kein Randfall — bei einem der fünf Testtracks lag die + /// Tempo-Konfidenz bei 0,05, und ohne Korrekturmöglichkeit wäre er + /// unbrauchbar. + #[test] + fn ein_oktavfehler_laesst_sich_halbieren() { + let (mut pult, _runner, _, grid) = crate::testing::pult_mit_protokoll(); + pult.deck_mut(0).unwrap().pfad = "/musik/a.wav".into(); + pult.decks()[0] + .state + .set_grid(Some(audio_core::Beatgrid::new(180.0, 4_800, 1.0))); + + let zeilen = pult.ausloesen(&k("deck1.grid_scale"), Some("0.5")).unwrap(); + assert!(zeilen[0].contains("180.00 → 90.00"), "{zeilen:?}"); + assert_eq!(pult.lies(&k("deck1.bpm_grid")).unwrap(), Wert::Zahl(90.0)); + + // Der Anker bleibt stehen — jede zweite Eins lag ja richtig. + let Wert::Zahl(anker) = pult.lies(&k("deck1.grid_anchor")).unwrap() else { + panic!("kein Anker"); + }; + assert!((anker - 0.1).abs() < 1e-6, "Anker {anker}"); + + // Und die Korrektur überlebt den Trackwechsel. + let (pfad, bpm, sekunden) = grid.lock().unwrap().clone().expect("nichts gespeichert"); + assert_eq!(pfad, "/musik/a.wav"); + assert_eq!(bpm, 90.0); + assert!((sekunden - 0.1).abs() < 1e-6, "{sekunden}"); + } + + /// „Der Beat ist hier" — der Handgriff, wenn die Eins verschoben liegt. + #[test] + fn der_anker_laesst_sich_auf_die_position_legen() { + let (mut pult, _runner, _, grid) = crate::testing::pult_mit_protokoll(); + pult.deck_mut(0).unwrap().pfad = "/musik/a.wav".into(); + pult.schreibe(&k("deck1.position"), Wert::Zahl(12.5)) + .unwrap(); + + let zeilen = pult.ausloesen(&k("deck1.grid_here"), None).unwrap(); + assert!(zeilen[0].contains("12.500 s"), "{zeilen:?}"); + + let Wert::Zahl(anker) = pult.lies(&k("deck1.grid_anchor")).unwrap() else { + panic!("kein Anker"); + }; + assert!((anker - 12.5).abs() < 1e-3, "Anker {anker}"); + + // Das Tempo hat sich dabei nicht verändert — verschoben wurde die Phase. + assert_eq!(pult.lies(&k("deck1.bpm_grid")).unwrap(), Wert::Zahl(128.0)); + let (_, bpm, sekunden) = grid.lock().unwrap().clone().expect("nichts gespeichert"); + assert_eq!(bpm, 128.0); + assert!((sekunden - 12.5).abs() < 1e-3); + } + + /// Ohne Grid gibt es nichts zu korrigieren — und nichts zu raten. + #[test] + fn ohne_grid_wird_keine_korrektur_erfunden() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.decks()[0].state.set_grid(None); + + for (control, argument) in [("deck1.grid_here", None), ("deck1.grid_scale", Some("2"))] { + let fehler = pult.ausloesen(&k(control), argument); + assert!( + matches!(fehler, Err(Fehler::Gescheitert(_))), + "{control}: {fehler:?}" + ); + } + + let anker = pult.schreibe(&k("deck1.grid_anchor"), Wert::Zahl(5.0)); + assert!(matches!(anker, Err(Fehler::Gescheitert(_))), "{anker:?}"); + + // Das Tempo dagegen lässt sich auch aus dem Nichts setzen — sonst käme + // man aus einem fehlenden Grid nie wieder heraus. + pult.schreibe(&k("deck1.bpm_grid"), Wert::Zahl(174.0)) + .unwrap(); + assert_eq!(pult.lies(&k("deck1.bpm_grid")).unwrap(), Wert::Zahl(174.0)); + } + + #[test] + fn beatjump_braucht_ein_grid() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.ausloesen(&k("deck1.beatjump"), Some("4")).unwrap(); + + pult.decks()[0].state.set_grid(None); + let fehler = pult.ausloesen(&k("deck1.beatjump"), Some("4")); + assert!(matches!(fehler, Err(Fehler::Gescheitert(_))), "{fehler:?}"); + } + + #[test] + fn laden_meldet_sich_als_angenommen_und_nicht_als_fertig() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + let zeilen = pult + .ausloesen(&k("deck1.load"), Some("/musik/track.wav")) + .unwrap(); + assert_eq!(zeilen, vec!["load deck1 angenommen"]); + assert_eq!( + pult.lies(&k("deck1.load_status")).unwrap(), + Wert::Text("laedt".into()) + ); + } + + #[test] + fn ein_abgelehnter_ladeauftrag_faelscht_den_status_nicht() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let fehler = pult.ausloesen(&k("deck1.load"), Some("/musik/liste.txt")); + + assert!(matches!(fehler, Err(Fehler::Gescheitert(_))), "{fehler:?}"); + assert_eq!( + pult.lies(&k("deck1.load_status")).unwrap(), + Wert::Text("bereit".into()), + "ein abgelehnter Auftrag darf nicht als laufend dastehen" + ); + } + + #[test] + fn laden_ohne_pfad_wird_abgewiesen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let fehler = pult.ausloesen(&k("deck1.load"), None); + assert!(matches!(fehler, Err(Fehler::Argument { .. })), "{fehler:?}"); + } + + #[test] + fn die_sammlung_laesst_sich_durchsuchen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let zeilen = pult.ausloesen(&k("master.search"), Some("techno")).unwrap(); + assert_eq!(zeilen.len(), 3); + assert!( + zeilen[0].starts_with("track 128.00 8A /musik/techno-0.wav"), + "{zeilen:?}" + ); + } + + #[test] + fn die_tonart_eines_decks_steht_in_beiden_schreibweisen() { + let (pult, _runner) = pult_mit_zwei_decks(); + + assert_eq!(pult.lies(&k("deck1.key")).unwrap(), Wert::Text("Am".into())); + assert_eq!( + pult.lies(&k("deck1.key_camelot")).unwrap(), + Wert::Text("8A".into()) + ); + } + + #[test] + fn ein_deck_ohne_tonart_erfindet_keine() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.deck_mut(0).unwrap().tonart = None; + + assert_eq!(pult.lies(&k("deck1.key")).unwrap(), Wert::Leer); + assert_eq!(pult.lies(&k("deck1.key_camelot")).unwrap(), Wert::Leer); + } + + #[test] + fn harmonisch_suchen_nimmt_beide_schreibweisen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + // Die Test-Sammlung gibt die angefragte Tonart im Pfad zurück — so + // lässt sich prüfen, dass `Am` und `8A` dieselbe Anfrage ergeben. + for eingabe in ["Am", "8A", " am "] { + let zeilen = pult + .ausloesen(&k("master.search_harmonic"), Some(eingabe)) + .unwrap_or_else(|e| panic!("{eingabe}: {e}")); + assert!( + zeilen[0].contains("/musik/8A-0.wav"), + "{eingabe}: {zeilen:?}" + ); + } + } + + #[test] + fn eine_unlesbare_tonart_wird_gemeldet_statt_leer_zu_antworten() { + // Ein Tippfehler darf nicht wie eine leere Sammlung aussehen. + let (mut pult, _runner) = pult_mit_zwei_decks(); + + let fehler = pult.ausloesen(&k("master.search_harmonic"), Some("H-Dur")); + assert!(matches!(fehler, Err(Fehler::Argument { .. })), "{fehler:?}"); + + let ohne = pult.ausloesen(&k("master.search_harmonic"), None); + assert!(matches!(ohne, Err(Fehler::Argument { .. })), "{ohne:?}"); + } + + #[test] + fn aktionen_und_werte_werden_nicht_verwechselt() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + // Eine Aktion hat keinen Zustand, den man lesen könnte. + assert!(matches!( + pult.lies(&k("deck1.sync")), + Err(Fehler::IstEineAktion(_)) + )); + assert!(matches!( + pult.schreibe(&k("deck1.sync"), Wert::Schalter(true)), + Err(Fehler::IstEineAktion(_)) + )); + // Und umgekehrt. + assert!(matches!( + pult.ausloesen(&k("deck1.play"), None), + Err(Fehler::IstKeineAktion(_)) + )); + } + + #[test] + fn das_ende_eines_tracks_ist_von_aussen_sichtbar() { + // Ohne das erfährt ein Agent nie, wann er nachlegen muss. + let (pult, _runner) = pult_mit_zwei_decks(); + assert_eq!( + pult.lies(&k("deck1.finished")).unwrap(), + Wert::Schalter(false) + ); + } +} + +#[cfg(test)] +mod fx_tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + + fn k(text: &str) -> Schluessel { + Schluessel::parse(text).unwrap() + } + + #[test] + fn effekte_lassen_sich_beim_namen_nennen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + pult.schreibe(&k("channel1.fx"), Wert::Auswahl("delay".into())) + .unwrap(); + assert_eq!( + pult.lies(&k("channel1.fx")).unwrap(), + Wert::Auswahl("delay".into()) + ); + + let fehler = pult.schreibe(&k("channel1.fx"), Wert::Auswahl("hall".into())); + assert!( + matches!(fehler, Err(Fehler::UnbekannteAuswahl { .. })), + "{fehler:?}" + ); + } + + #[test] + fn fx_sync_rechnet_beats_in_sekunden() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + // Die Testdecks laufen auf 128 BPM: ein Beat = 60/128 = 0,46875 s. + let zeilen = pult.ausloesen(&k("channel1.fx_sync"), Some("1")).unwrap(); + assert!(zeilen[0].contains("128.00 BPM"), "{zeilen:?}"); + + let Wert::Zahl(zeit) = pult.lies(&k("channel1.fx_time")).unwrap() else { + panic!("fx_time ist keine Zahl"); + }; + assert!((zeit - 60.0 / 128.0).abs() < 1e-6, "{zeit}"); + + // Ein Achtel ist halb so lang. + pult.ausloesen(&k("channel1.fx_sync"), Some("0.5")).unwrap(); + let Wert::Zahl(halb) = pult.lies(&k("channel1.fx_time")).unwrap() else { + panic!() + }; + assert!((halb - 30.0 / 128.0).abs() < 1e-6, "{halb}"); + } + + #[test] + fn ohne_deck_sagt_fx_sync_das_statt_zu_raten() { + // Kanal 3 ist AUX — der hat keinen Abspieler und damit kein Tempo. + let (mut pult, _runner) = pult_mit_zwei_decks(); + let fehler = pult.ausloesen(&k("channel3.fx_sync"), Some("1")); + assert!(matches!(fehler, Err(Fehler::Gescheitert(_))), "{fehler:?}"); + } + + #[test] + fn ohne_beatgrid_wird_die_effektzeit_nicht_geraten() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.decks()[0].state.set_grid(None); + + let fehler = pult.ausloesen(&k("channel1.fx_sync"), Some("1")); + assert!(matches!(fehler, Err(Fehler::Gescheitert(_))), "{fehler:?}"); + } + + #[test] + fn eine_effektzeit_von_null_beats_ergibt_keinen_sinn() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let fehler = pult.ausloesen(&k("channel1.fx_sync"), Some("0")); + assert!(matches!(fehler, Err(Fehler::Argument { .. })), "{fehler:?}"); + } +} + +#[cfg(test)] +mod aufnahme_tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + + fn k(text: &str) -> Schluessel { + Schluessel::parse(text).unwrap() + } + + fn scratch(name: &str) -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!("musik-pult-{}-{name}.wav", std::process::id())); + p + } + + #[test] + fn ein_mitschnitt_laesst_sich_von_aussen_starten_und_beenden() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let pfad = scratch("lauf"); + + assert_eq!( + pult.lies(&k("master.recording")).unwrap(), + Wert::Schalter(false) + ); + + let zeilen = pult + .ausloesen(&k("master.record"), Some(&pfad.to_string_lossy())) + .expect("Start"); + assert!(zeilen[0].starts_with("record läuft nach"), "{zeilen:?}"); + assert_eq!( + pult.lies(&k("master.recording")).unwrap(), + Wert::Schalter(true) + ); + + let zeilen = pult.ausloesen(&k("master.record_stop"), None).unwrap(); + assert!(zeilen[0].starts_with("record "), "{zeilen:?}"); + assert_eq!( + pult.lies(&k("master.recording")).unwrap(), + Wert::Schalter(false) + ); + + let _ = std::fs::remove_file(&pfad); + } + + #[test] + fn stoppen_ohne_mitschnitt_sagt_das() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let fehler = pult.ausloesen(&k("master.record_stop"), None); + assert!(matches!(fehler, Err(Fehler::Gescheitert(_))), "{fehler:?}"); + } + + #[test] + fn ohne_pfad_wird_nicht_aufgenommen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let fehler = pult.ausloesen(&k("master.record"), None); + assert!(matches!(fehler, Err(Fehler::Argument { .. })), "{fehler:?}"); + } + + #[test] + fn verlorene_frames_sind_von_aussen_sichtbar() { + // Der Zähler muss abfragbar sein, sonst merkt man Lücken erst beim + // Anhören des fertigen Mitschnitts. + let (pult, _runner) = pult_mit_zwei_decks(); + assert_eq!( + pult.lies(&k("master.record_dropped")).unwrap(), + Wert::Zahl(0.0) + ); + assert_eq!( + pult.lies(&k("master.record_seconds")).unwrap(), + Wert::Zahl(0.0) + ); + } +} + +#[cfg(test)] +mod musikalische_groessen_tests { + use super::*; + use crate::testing::{pult_mit_zwei_decks, rendern, RATE}; + + fn k(text: &str) -> Schluessel { + Schluessel::parse(text).unwrap() + } + + fn zahl_von(pult: &Steuerpult, name: &str) -> f64 { + match pult.lies(&k(name)) { + Ok(Wert::Zahl(v)) => v, + andere => panic!("{name} ist keine Zahl: {andere:?}"), + } + } + + /// Der Rest zählt Grid-Beats und lässt sich vom Pitchfader nicht bewegen. + /// + /// Ein Beat ist eine feste Zahl Quell-Frames. Schneller abgespielt vergeht + /// er in weniger Zeit, aber es werden davon nicht mehr — wer bei 1.08 auf + /// einmal mehr Beats übrig hätte, hätte Zeit mit Takten verwechselt. + /// Entscheidend ist, dass `in` und `ramp` genauso rechnen: „noch 32 Beats" + /// und „in 32 Beats" müssen dieselbe Strecke meinen. + #[test] + fn der_rest_zaehlt_grid_beats_und_nicht_zeit() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let vorher = zahl_von(&pult, "deck1.beats_left"); + // 60 s bei 128 BPM. + assert!((vorher - 128.0).abs() < 0.5, "{vorher}"); + + pult.schreibe(&k("deck1.tempo"), Wert::Zahl(1.08)).unwrap(); + let schneller = zahl_von(&pult, "deck1.beats_left"); + assert!( + (schneller - vorher).abs() < 0.01, + "der Tempo-Regler hat die Beats verschoben: {vorher:.1} → {schneller:.1}" + ); + } + + #[test] + fn der_rest_schrumpft_beim_abspielen_und_wird_nicht_negativ() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + + let vorher = zahl_von(&pult, "deck1.beats_left"); + rendern(&mut runner, RATE as usize * 2); + let nachher = zahl_von(&pult, "deck1.beats_left"); + assert!(nachher < vorher, "{vorher:.1} → {nachher:.1}"); + + // Über das Ende hinaus läuft es nicht ins Negative. Genau null wird es + // nicht: Der Abspieler steht auf dem letzten Frame, nicht dahinter. + rendern(&mut runner, RATE as usize * 90); + let am_ende = zahl_von(&pult, "deck1.beats_left"); + assert!((0.0..0.01).contains(&am_ende), "am Ende: {am_ende}"); + } + + /// Auf der Grenze sind es null Beats bis zur Grenze — nicht eine ganze + /// Phrase. Sonst spränge die Zahl genau dort, wo man sie abliest. + #[test] + fn auf_der_phrasengrenze_ist_der_abstand_null() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + assert_eq!(zahl_von(&pult, "deck1.beat"), 0.0); + assert_eq!(zahl_von(&pult, "deck1.beats_to_phrase"), 0.0); + + // Vier Beats weiter sind es noch zwölf bis zur nächsten Sechzehnergruppe. + // Ein Sprung ist nur ein Wunsch, den der Audio-Thread im nächsten Block + // ausführt, und ein stehendes Deck rendert keinen. Also laufen lassen — + // und mit einer Toleranz messen, weil in demselben Block schon wieder + // gespielt wird. + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + pult.ausloesen(&k("deck1.beatjump"), Some("4")).unwrap(); + rendern(&mut runner, 1024); + + let beat = zahl_von(&pult, "deck1.beat"); + assert!((beat - 4.0).abs() < 0.1, "beat={beat}"); + let bis = zahl_von(&pult, "deck1.beats_to_phrase"); + assert!((bis - 12.0).abs() < 0.1, "bis zur Phrase={bis}"); + } + + #[test] + fn die_phrasenlaenge_laesst_sich_umstellen() { + // Nicht jede Musik ist in Sechzehnergruppen gebaut, und zwei Decks + // können unterschiedlich liegen. + let (mut pult, mut runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + pult.ausloesen(&k("deck1.beatjump"), Some("4")).unwrap(); + rendern(&mut runner, 1024); + assert!((zahl_von(&pult, "deck1.beats_to_phrase") - 12.0).abs() < 0.1); + + pult.schreibe(&k("deck1.phrase_beats"), Wert::Zahl(8.0)) + .unwrap(); + assert!((zahl_von(&pult, "deck1.beats_to_phrase") - 4.0).abs() < 0.1); + // Das andere Deck bleibt, wo es war. + assert_eq!(zahl_von(&pult, "deck2.phrase_beats"), PHRASE_BEATS); + } + + #[test] + fn ohne_beatgrid_gibt_es_keine_beats() { + // Leer und nicht null: Null hieße „gerade auf der Eins", und das wäre + // eine Behauptung über einen Track, von dem niemand das Raster kennt. + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("deck1.bpm_grid"), Wert::Zahl(0.0)).ok(); + + for name in ["deck1.beat", "deck1.beats_left", "deck1.beats_to_phrase"] { + assert_eq!(pult.lies(&k(name)).unwrap(), Wert::Leer, "{name}"); + } + } +} + +#[cfg(test)] +mod signal_tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + + fn k(text: &str) -> Schluessel { + Schluessel::parse(text).unwrap() + } + + /// Der ganze Zweck: Ein Signal von außen wird zu einem Control wie jedes + /// andere — und damit lassen sich `when`, `sub` und `ramp` darauf anwenden, + /// ohne dass hier eine Zeile dafür geschrieben werden müsste. + #[test] + fn ein_signal_von_aussen_ist_ein_control_wie_jedes_andere() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + + pult.schreibe(&k("master.signal1_name"), Wert::Text("Energie".into())) + .unwrap(); + pult.schreibe(&k("master.signal1"), Wert::Zahl(0.4)) + .unwrap(); + + assert_eq!( + pult.lies(&k("master.signal1_name")).unwrap(), + Wert::Text("Energie".into()) + ); + assert_eq!(pult.lies(&k("master.signal1")).unwrap(), Wert::Zahl(0.4)); + // Und es lässt sich als Bedingung verwenden — ohne Sonderweg. + let mut plan = crate::Zeitplan::neu(); + crate::zeitplan::wenn_planen( + &pult, + &mut plan, + k("master.signal1"), + crate::zeitplan::Vergleich::Unter, + 0.2, + "do master.queue_next".into(), + ) + .expect("ein Signal muss sich als Bedingung eignen"); + } + + /// Solange nichts gemeldet wurde, ist der Wert leer — und nicht null. + /// + /// Null wäre eine Aussage über den Raum („keine Energie"), die niemand + /// getroffen hat. + #[test] + fn ein_ungenutztes_signal_behauptet_nichts() { + let (pult, _runner) = pult_mit_zwei_decks(); + for feld in ["", "_trend", "_age"] { + assert_eq!( + pult.lies(&k(&format!("master.signal2{feld}"))).unwrap(), + Wert::Leer, + "signal2{feld}" + ); + } + assert_eq!( + pult.lies(&k("master.signal2_name")).unwrap(), + Wert::Text(String::new()) + ); + } + + #[test] + fn ein_trend_entsteht_erst_aus_mehreren_meldungen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("master.signal1"), Wert::Zahl(0.3)) + .unwrap(); + assert_eq!(pult.lies(&k("master.signal1_trend")).unwrap(), Wert::Leer); + + pult.schreibe(&k("master.signal1"), Wert::Zahl(0.9)) + .unwrap(); + // Zwei Meldungen fast gleichzeitig: eine Richtung gibt es, ihre Größe + // ist bei diesem Abstand beliebig — geprüft wird deshalb nur, dass + // überhaupt eine dasteht. + assert!(matches!( + pult.lies(&k("master.signal1_trend")).unwrap(), + Wert::Zahl(_) + )); + } + + #[test] + fn was_sich_ausrechnet_laesst_sich_nicht_setzen() { + // Trend und Alter folgen aus den Proben. Sie zu setzen hieße, die + // Vergangenheit zu behaupten. + let (mut pult, _runner) = pult_mit_zwei_decks(); + assert!(pult + .schreibe(&k("master.signal1_trend"), Wert::Zahl(1.0)) + .is_err()); + assert!(pult + .schreibe(&k("master.signal1_age"), Wert::Zahl(0.0)) + .is_err()); + } + + #[test] + fn es_gibt_genau_so_viele_signale_wie_angekuendigt() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let letztes = format!("master.signal{}", crate::signal::SIGNALE); + pult.schreibe(&k(&letztes), Wert::Zahl(0.5)).unwrap(); + + // Eins darüber gibt es nicht — und das muss ein Fehler sein, kein + // stillschweigend angelegter Platz. + let zuviel = format!("master.signal{}", crate::signal::SIGNALE + 1); + assert!(pult.lies(&k(&zuviel)).is_err(), "{zuviel} wurde angenommen"); + assert!(pult.schreibe(&k(&zuviel), Wert::Zahl(0.5)).is_err()); + } + + #[test] + fn signale_stehen_im_katalog_mit_ihrer_bedeutung() { + // Sonst wüsste ein Agent nicht, dass es sie gibt. + let (pult, _runner) = pult_mit_zwei_decks(); + let namen: Vec = pult + .liste() + .iter() + .map(|(s, _)| s.to_string()) + .filter(|n| n.contains("signal")) + .collect(); + assert_eq!(namen.len(), crate::signal::SIGNALE * 4, "{namen:?}"); + assert!(namen.contains(&"master.signal1_trend".to_string())); + } +} + +/// Die Mitschrift im Zusammenspiel: Mitschnitt an, Befehl geben, nachlesen. +/// +/// Getrennt von den Mitschrift-Tests im eigenen Modul: Dort geht es um die +/// Form der Datei, hier darum, dass überhaupt und nur das Richtige hineinkommt. +#[cfg(test)] +mod mitschrift_tests { + use super::*; + use crate::mitschrift::{self, Richtung}; + use crate::testing::{pult_mit_zwei_decks, rendern, RATE}; + + fn k(text: &str) -> Schluessel { + Schluessel::parse(text).unwrap() + } + + fn scratch(name: &str) -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "musik-mitschrift-{}-{name}.wav", + std::process::id() + )); + p + } + + fn aufraeumen(wav: &std::path::Path) { + let _ = std::fs::remove_file(wav); + let _ = std::fs::remove_file(wav.with_extension(mitschrift::ENDUNG)); + } + + fn sagen(pult: &mut Steuerpult, zeile: &str) -> String { + let mut sitzung = crate::Sitzung::neu(); + crate::behandle(pult, &mut sitzung, zeile) + } + + #[test] + fn die_mitschrift_entsteht_neben_dem_mitschnitt() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let wav = scratch("neben"); + aufraeumen(&wav); + + let zeilen = pult + .ausloesen(&k("master.record"), Some(&wav.to_string_lossy())) + .expect("Start"); + assert!( + zeilen.iter().any(|z| z.starts_with("mitschrift ")), + "der Start verschweigt die Mitschrift: {zeilen:?}" + ); + + let neben = wav.with_extension(mitschrift::ENDUNG); + let p = mitschrift::lesen(&neben).expect("Mitschrift lesbar"); + assert_eq!(p.kopf.mitschnitt, wav.display().to_string()); + assert_eq!(p.kopf.rate, RATE); + + let zeilen = pult + .ausloesen(&k("master.record_stop"), None) + .expect("Stop"); + assert!( + zeilen.iter().any(|z| z.starts_with("mitschrift ")), + "das Ende verschweigt, wo die Mitschrift liegt: {zeilen:?}" + ); + + aufraeumen(&wav); + } + + /// Was fragt, gehört nicht hinein. Sonst steht in jeder zweiten Zeile ein + /// `get`, und die Bewegungen gehen darin unter. + #[test] + fn was_sich_aendert_steht_drin_was_nur_fragt_nicht() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let wav = scratch("auswahl"); + aufraeumen(&wav); + pult.ausloesen(&k("master.record"), Some(&wav.to_string_lossy())) + .expect("Start"); + + sagen(&mut pult, "get channel1.fader"); + sagen(&mut pult, "list channel1"); + sagen(&mut pult, "plan"); + sagen(&mut pult, "set channel1.fader 0.5"); + + let p = mitschrift::lesen(&wav.with_extension(mitschrift::ENDUNG)).expect("lesbar"); + let texte: Vec<&str> = p.ereignisse.iter().map(|e| e.text.as_str()).collect(); + assert_eq!( + texte, + vec!["set channel1.fader 0.5", "ok channel1.fader 0.500000"], + "in der Mitschrift steht das Falsche" + ); + assert_eq!(p.ereignisse[0].richtung, Richtung::Befehl); + assert_eq!(p.ereignisse[1].richtung, Richtung::Meldung); + + pult.ausloesen(&k("master.record_stop"), None) + .expect("Stop"); + aufraeumen(&wav); + } + + /// Der Punkt der ganzen Übung: Wo standen die Decks, als es geschah. + #[test] + fn die_mitschrift_haelt_frame_und_beat_fest() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let wav = scratch("stand"); + aufraeumen(&wav); + pult.ausloesen(&k("master.record"), Some(&wav.to_string_lossy())) + .expect("Start"); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + + // Zwei Sekunden: bei 128 BPM gut vier Beats. + rendern(&mut runner, RATE as usize * 2); + sagen(&mut pult, "ramp master.crossfader 1 32"); + + let p = mitschrift::lesen(&wav.with_extension(mitschrift::ENDUNG)).expect("lesbar"); + let befehl = p + .ereignisse + .iter() + .find(|e| e.text.starts_with("ramp ")) + .expect("der Befehl fehlt"); + + let sek = befehl.sekunden(p.kopf.rate); + assert!( + (1.5..2.5).contains(&sek), + "der Griff steht bei {sek:.2} s statt bei rund 2 s" + ); + let stand = befehl + .stand(0) + .expect("Deck 1 hat ein Grid, steht aber nicht drin"); + assert!( + (3.5..5.0).contains(&stand.beat), + "Beat {:.2} passt nicht zu zwei Sekunden bei 128 BPM", + stand.beat + ); + assert_eq!(stand.phrase_beats, 16.0); + + pult.ausloesen(&k("master.record_stop"), None) + .expect("Stop"); + aufraeumen(&wav); + } + + /// Ohne Mitschnitt gibt es nichts, worauf sich ein Frame beziehen könnte. + /// Dann wird auch nichts geschrieben — und schon gar nicht irgendwohin. + #[test] + fn ohne_mitschnitt_wird_nichts_mitgeschrieben() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let wav = scratch("ohne"); + aufraeumen(&wav); + + sagen(&mut pult, "set channel1.fader 0.5"); + + assert!(!wav.with_extension(mitschrift::ENDUNG).exists()); + assert_eq!(pult.lies(&k("channel1.fader")).unwrap(), Wert::Zahl(0.5)); + } + + /// Nach dem Stoppen ist Schluss. Sonst schriebe die Mitschrift weiter in + /// eine Datei, zu der es keinen Klang mehr gibt. + #[test] + fn nach_dem_stoppen_kommt_nichts_mehr_dazu() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let wav = scratch("danach"); + aufraeumen(&wav); + pult.ausloesen(&k("master.record"), Some(&wav.to_string_lossy())) + .expect("Start"); + sagen(&mut pult, "set channel1.fader 0.5"); + pult.ausloesen(&k("master.record_stop"), None) + .expect("Stop"); + + sagen(&mut pult, "set channel1.fader 0.9"); + + let p = mitschrift::lesen(&wav.with_extension(mitschrift::ENDUNG)).expect("lesbar"); + assert!( + p.ereignisse.iter().all(|e| !e.text.contains("0.9")), + "nach dem Stoppen kam noch etwas dazu: {:?}", + p.ereignisse + ); + + aufraeumen(&wav); + } +} + +/// Die Gliederung im Steuerraum: Wo steht das Deck, und wo darf der Übergang +/// liegen. +#[cfg(test)] +mod struktur_tests { + use super::*; + use crate::testing::{pult_mit_zwei_decks, rendern, RATE}; + use audio_core::struktur::{Abschnitt, Art, Struktur}; + + fn k(text: &str) -> Schluessel { + Schluessel::parse(text).unwrap() + } + + fn je_beat() -> f64 { + 60.0 / 128.0 * RATE as f64 + } + + /// Stellt das Deck auf einen Beat — und rendert einen Block. + /// + /// Ohne das Rendern stünde die alte Position noch da: Ein Sprung wird erst + /// veröffentlicht, wenn das Deck ihn abgearbeitet hat. Genau der Umstand, + /// den die Mitschrift bei einem stehenden Deck schon einmal ans Licht + /// gebracht hat. + fn stellen(pult: &Steuerpult, runner: &mut audio_engine::EngineRunner, beat: f64) { + pult.decks()[0].state.seek_frames((beat * je_beat()) as u64); + rendern(runner, 256); + } + + /// Vier Abschnitte zu je acht Sekunden auf dem Testdeck (60 s, 128 BPM). + fn beispiel() -> Struktur { + let je_beat = 60.0 / 128.0 * RATE as f64; + let abschnitt = |art: Art, von_beat: f64, bis_beat: f64| Abschnitt { + von_frames: (von_beat * je_beat) as u64, + bis_frames: (bis_beat * je_beat) as u64, + von_beat, + bis_beat, + art, + pegel: 0.5, + bass: 0.5, + dichte: 0.5, + }; + Struktur { + phrase_beats: PHRASE_BEATS, + abschnitte: vec![ + abschnitt(Art::Intro, 0.0, 32.0), + abschnitt(Art::Drop, 32.0, 64.0), + abschnitt(Art::Outro, 64.0, 96.0), + ], + } + } + + fn pult_mit_gliederung() -> (Steuerpult, audio_engine::EngineRunner) { + let (mut pult, runner) = pult_mit_zwei_decks(); + pult.deck_mut(0).unwrap().struktur = Some(beispiel()); + (pult, runner) + } + + #[test] + fn der_laufende_abschnitt_steht_im_steuerraum() { + let (pult, mut runner) = pult_mit_gliederung(); + assert_eq!( + pult.lies(&k("deck1.section")).unwrap(), + Wert::Text("intro".into()) + ); + + stellen(&pult, &mut runner, 40.0); + assert_eq!( + pult.lies(&k("deck1.section")).unwrap(), + Wert::Text("drop".into()) + ); + + stellen(&pult, &mut runner, 80.0); + assert_eq!( + pult.lies(&k("deck1.section")).unwrap(), + Wert::Text("outro".into()) + ); + } + + /// Die Zahl, an der ein `when` hängt: „sobald das Outro läuft". + #[test] + fn bis_zum_outro_wird_gezaehlt_und_danach_negativ() { + let (pult, mut runner) = pult_mit_gliederung(); + + let Wert::Zahl(vorher) = pult.lies(&k("deck1.beats_to_outro")).unwrap() else { + panic!("keine Zahl") + }; + assert!((vorher - 64.0).abs() < 0.5, "{vorher}"); + + stellen(&pult, &mut runner, 72.0); + let Wert::Zahl(drin) = pult.lies(&k("deck1.beats_to_outro")).unwrap() else { + panic!("keine Zahl") + }; + assert!( + drin < 0.0, + "im Outro muss die Zahl negativ sein, war {drin}" + ); + } + + #[test] + fn intro_und_einstieg_stehen_da() { + let (pult, _runner) = pult_mit_gliederung(); + assert_eq!( + pult.lies(&k("deck1.intro_beats")).unwrap(), + Wert::Zahl(32.0) + ); + assert_eq!(pult.lies(&k("deck1.entry")).unwrap(), Wert::Zahl(0.0)); + } + + #[test] + fn der_rest_des_abschnitts_zaehlt_herunter() { + let (pult, mut runner) = pult_mit_gliederung(); + stellen(&pult, &mut runner, 24.0); + + let Wert::Zahl(rest) = pult.lies(&k("deck1.section_beats_left")).unwrap() else { + panic!("keine Zahl") + }; + assert!((rest - 8.0).abs() < 0.5, "{rest}"); + } + + /// Ohne Gliederung wird keine erfunden. `Leer` und nicht 0 — „kein Outro" + /// und „das Outro fängt jetzt an" sind zwei verschiedene Auskünfte. + #[test] + fn ohne_gliederung_steht_leer_und_nicht_null() { + let (pult, _runner) = pult_mit_zwei_decks(); + for name in [ + "deck1.section", + "deck1.section_beats_left", + "deck1.beats_to_outro", + "deck1.intro_beats", + "deck1.entry", + ] { + assert_eq!(pult.lies(&k(name)).unwrap(), Wert::Leer, "{name}"); + } + } + + /// Ein Track ohne Outro bekommt keines angedichtet. + #[test] + fn ohne_outro_bleibt_die_zahl_leer() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let mut s = beispiel(); + s.abschnitte.retain(|a| a.art != Art::Outro); + pult.deck_mut(0).unwrap().struktur = Some(s); + + assert_eq!(pult.lies(&k("deck1.beats_to_outro")).unwrap(), Wert::Leer); + assert_eq!( + pult.lies(&k("deck1.intro_beats")).unwrap(), + Wert::Zahl(32.0), + "das Intro gibt es weiterhin" + ); + } + + #[test] + fn der_einstieg_laesst_sich_anspringen() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let mut s = beispiel(); + // Ein Vorlauf vor der ersten Eins, wie ihn jeder echte Track hat. + let versatz = RATE as u64 / 2; + for a in &mut s.abschnitte { + a.von_frames += versatz; + a.bis_frames += versatz; + } + pult.deck_mut(0).unwrap().struktur = Some(s); + + pult.ausloesen(&k("deck1.jump_entry"), None) + .expect("Sprung"); + rendern(&mut runner, 256); + assert_eq!(pult.decks()[0].state.position_frames(), versatz); + } + + /// Ohne Gliederung sagt der Sprung das, statt still bei 0 zu landen. + #[test] + fn ohne_gliederung_sagt_der_sprung_ab() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + let fehler = pult.ausloesen(&k("deck1.jump_entry"), None); + assert!(matches!(fehler, Err(Fehler::Gescheitert(_))), "{fehler:?}"); + } +} + +/// Der Bogen im Steuerraum: Soll, Ist und die Lücke dazwischen. +#[cfg(test)] +mod bogen_tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + use audio_core::struktur::{Abschnitt, Art, Struktur}; + + fn k(text: &str) -> Schluessel { + Schluessel::parse(text).unwrap() + } + + fn abschnitt(art: Art) -> Struktur { + Struktur { + phrase_beats: PHRASE_BEATS, + abschnitte: vec![Abschnitt { + von_frames: 0, + bis_frames: u64::MAX, + von_beat: 0.0, + bis_beat: 1e9, + art, + pegel: 0.5, + bass: 0.5, + dichte: 0.5, + }], + } + } + + #[test] + fn ein_bogen_laesst_sich_setzen_und_lesen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + assert_eq!(pult.lies(&k("master.arc")).unwrap(), Wert::Leer); + + pult.schreibe(&k("master.arc"), Wert::Text("0 0.3, 60 0.9".into())) + .expect("setzen"); + let Wert::Text(zurueck) = pult.lies(&k("master.arc")).unwrap() else { + panic!("kein Text") + }; + assert_eq!(zurueck, "0:00 0.30, 60:00 0.90"); + } + + /// Ein unlesbarer Bogen wird abgewiesen, nicht halb übernommen. Ein Set + /// gegen eine Kurve zu fahren, die niemand gemeint hat, ist schlimmer als + /// eines ohne Kurve. + #[test] + fn ein_unlesbarer_bogen_wird_abgewiesen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("master.arc"), Wert::Text("0 0.3, 60 0.9".into())) + .unwrap(); + + let fehler = pult.schreibe(&k("master.arc"), Wert::Text("völlig kaputt".into())); + assert!(fehler.is_err(), "Unsinn wurde angenommen"); + + // Und der alte Bogen steht noch. + let Wert::Text(zurueck) = pult.lies(&k("master.arc")).unwrap() else { + panic!() + }; + assert!(zurueck.starts_with("0:00 0.30"), "{zurueck}"); + } + + /// Ohne Startzeit gibt es keinen Ort auf dem Bogen — und dann wird auch + /// keiner behauptet. `Leer` und nicht 0. + #[test] + fn ohne_start_gibt_es_keinen_ort_auf_dem_bogen() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("master.arc"), Wert::Text("0 0.3, 60 0.9".into())) + .unwrap(); + + for name in ["master.arc_minutes", "master.arc_target", "master.arc_gap"] { + assert_eq!(pult.lies(&k(name)).unwrap(), Wert::Leer, "{name}"); + } + + pult.ausloesen(&k("master.arc_start"), None).expect("Start"); + assert!(matches!( + pult.lies(&k("master.arc_minutes")).unwrap(), + Wert::Zahl(_) + )); + // Nicht auf die letzte Stelle: Zwischen Start und Abfrage sind + // Mikrosekunden vergangen, und der Bogen steht nicht still. + let Wert::Zahl(soll) = pult.lies(&k("master.arc_target")).unwrap() else { + panic!("keine Zahl") + }; + assert!((soll - 0.3).abs() < 1e-4, "{soll}"); + } + + /// Die Ist-Energie kommt aus der **Art** des Abschnitts, nicht aus dem + /// Pegel — der ist über Tracks hinweg nicht vergleichbar. + #[test] + fn die_ist_energie_kommt_aus_der_art_des_abschnitts() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + assert_eq!(pult.lies(&k("master.arc_actual")).unwrap(), Wert::Leer); + + pult.deck_mut(0).unwrap().struktur = Some(abschnitt(Art::Drop)); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + assert_eq!( + pult.lies(&k("master.arc_actual")).unwrap(), + Wert::Zahl(crate::bogen::energie(Art::Drop)) + ); + + // Ein stehendes Deck zählt nicht — was nicht läuft, trägt den Raum + // nicht. + pult.schreibe(&k("deck1.play"), Wert::Schalter(false)) + .unwrap(); + assert_eq!(pult.lies(&k("master.arc_actual")).unwrap(), Wert::Leer); + } + + /// Laufen zwei, zählt das lautere — beim Übergang trägt der den Raum. + #[test] + fn bei_zwei_laufenden_decks_zaehlt_das_lautere() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.deck_mut(0).unwrap().struktur = Some(abschnitt(Art::Outro)); + pult.deck_mut(1).unwrap().struktur = Some(abschnitt(Art::Drop)); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + pult.schreibe(&k("deck2.play"), Wert::Schalter(true)) + .unwrap(); + + assert_eq!( + pult.lies(&k("master.arc_actual")).unwrap(), + Wert::Zahl(crate::bogen::energie(Art::Drop)) + ); + } + + /// **Die Zahl, nach der gehandelt wird.** Positiv heißt: Der Bogen will + /// mehr, als gerade läuft. + #[test] + fn die_luecke_ist_soll_minus_ist() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("master.arc"), Wert::Text("0 0.9, 60 0.9".into())) + .unwrap(); + pult.ausloesen(&k("master.arc_start"), None).unwrap(); + pult.deck_mut(0).unwrap().struktur = Some(abschnitt(Art::Break)); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + + let Wert::Zahl(luecke) = pult.lies(&k("master.arc_gap")).unwrap() else { + panic!("keine Zahl") + }; + let erwartet = 0.9 - crate::bogen::energie(Art::Break); + assert!( + (luecke - erwartet).abs() < 1e-9, + "{luecke} statt {erwartet}" + ); + assert!(luecke > 0.0, "der Bogen will mehr — das muss positiv sein"); + } + + #[test] + fn der_trend_sagt_wohin_es_geht() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("master.arc"), Wert::Text("0 0.2, 60 1.0".into())) + .unwrap(); + pult.ausloesen(&k("master.arc_start"), None).unwrap(); + assert_eq!( + pult.lies(&k("master.arc_trend")).unwrap(), + Wert::Text("steigt".into()) + ); + + pult.schreibe(&k("master.arc"), Wert::Text("0 0.7, 60 0.7".into())) + .unwrap(); + assert_eq!( + pult.lies(&k("master.arc_trend")).unwrap(), + Wert::Text("haelt".into()) + ); + } +} + +#[cfg(test)] +mod raum_tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + use audio_core::struktur::{Abschnitt, Art, Struktur}; + use std::time::{Duration, Instant}; + + fn k(text: &str) -> Schluessel { + Schluessel::parse(text).unwrap() + } + + fn abschnitt(art: Art) -> Struktur { + Struktur { + phrase_beats: PHRASE_BEATS, + abschnitte: vec![Abschnitt { + von_frames: 0, + bis_frames: u64::MAX, + von_beat: 0.0, + bis_beat: 1e9, + art, + pegel: 0.5, + bass: 0.5, + dichte: 0.5, + }], + } + } + + /// Trägt einen Verlauf in einen Signalplatz ein, rückwärts von jetzt. + /// + /// Direkt statt über `schreibe`, weil dort die Uhr von außen nicht zu + /// setzen ist: Zwei Schreibvorgänge hintereinander liegen Mikrosekunden + /// auseinander, und daraus wird jeder Trend zur Behauptung. + fn verlauf(pult: &mut Steuerpult, platz: usize, proben: &[(u64, f64)]) { + let jetzt = Instant::now(); + for (sek_her, wert) in proben { + pult.signale[platz].setzen(*wert, jetzt - Duration::from_secs(*sek_her)); + } + } + + fn zahl(pult: &Steuerpult, control: &str) -> f64 { + match pult.lies(&k(control)).unwrap() { + Wert::Zahl(z) => z, + anderes => panic!("{control} ist {anderes:?}, keine Zahl"), + } + } + + fn text(pult: &Steuerpult, control: &str) -> String { + match pult.lies(&k(control)).unwrap() { + Wert::Text(t) => t, + anderes => panic!("{control} ist {anderes:?}, kein Text"), + } + } + + /// **Der Raum verschiebt das Ziel, nicht die Kurve.** + /// + /// Das ist der ganze Unterschied zwischen „der Raum redet mit" und „der + /// Raum schreibt um": Am Ende des Abends steht in `arc` noch der Plan, an + /// dem sich messen lässt, was tatsächlich geschah. + #[test] + fn der_raum_beugt_das_ziel_aber_nicht_die_kurve() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("master.arc"), Wert::Text("0 0.5, 60 0.5".into())) + .unwrap(); + pult.ausloesen(&k("master.arc_start"), None).unwrap(); + assert_eq!(pult.lies(&k("master.room_bend")).unwrap(), Wert::Leer); + assert_eq!(zahl(&pult, "master.arc_target"), 0.5); + + pult.schreibe(&k("master.signal1_name"), Wert::Text("Andrang".into())) + .unwrap(); + pult.schreibe(&k("master.room"), Wert::Text("signal1 0.5".into())) + .unwrap(); + // Von 0,2 auf 0,6 in einer Minute: Trend +0,4, mit Gewicht 0,5 also + // eine Beugung von +0,2. + verlauf(&mut pult, 0, &[(60, 0.2), (40, 0.33), (20, 0.47), (0, 0.6)]); + + let beugung = zahl(&pult, "master.room_bend"); + assert!((beugung - 0.2).abs() < 0.02, "Beugung {beugung:.3}"); + assert!( + (zahl(&pult, "master.arc_target") - 0.7).abs() < 0.02, + "Ziel {}", + zahl(&pult, "master.arc_target") + ); + // Die Kurve steht unverändert da. + assert_eq!(zahl(&pult, "master.arc_curve"), 0.5); + let Wert::Text(kurve) = pult.lies(&k("master.arc")).unwrap() else { + panic!() + }; + assert!(kurve.starts_with("0:00 0.50"), "{kurve}"); + } + + /// Die Lücke ist die Zahl, nach der gewählt wird — sie muss dem gebeugten + /// Ziel folgen, sonst redet der Raum mit und niemand hört zu. + #[test] + fn die_luecke_folgt_dem_gebeugten_ziel() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("master.arc"), Wert::Text("0 0.5, 60 0.5".into())) + .unwrap(); + pult.ausloesen(&k("master.arc_start"), None).unwrap(); + pult.deck_mut(0).unwrap().struktur = Some(abschnitt(Art::Aufbau)); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + let ist = crate::bogen::energie(Art::Aufbau); + assert!((zahl(&pult, "master.arc_gap") - (0.5 - ist)).abs() < 1e-9); + + // Der Raum fällt: Das Ziel sinkt, und damit die Lücke. + pult.schreibe(&k("master.room"), Wert::Text("signal1".into())) + .unwrap(); + verlauf(&mut pult, 0, &[(60, 0.8), (0, 0.6)]); + let luecke = zahl(&pult, "master.arc_gap"); + assert!( + (luecke - (0.3 - ist)).abs() < 0.02, + "Lücke {luecke:.3}, erwartet {:.3}", + 0.3 - ist + ); + } + + /// Ein Sender, der ausrastet, übernimmt das Set nicht. + #[test] + fn die_grenze_haelt_auch_am_pult() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("master.arc"), Wert::Text("0 0.5, 60 0.5".into())) + .unwrap(); + pult.ausloesen(&k("master.arc_start"), None).unwrap(); + pult.schreibe(&k("master.room"), Wert::Text("signal1 10".into())) + .unwrap(); + verlauf(&mut pult, 0, &[(60, 0.0), (0, 1.0)]); + + assert_eq!(zahl(&pult, "master.room_bend"), crate::raum::GRENZE); + assert!((zahl(&pult, "master.arc_target") - 0.75).abs() < 1e-9); + } + + /// Ein Platz, den es nicht gibt, wird abgewiesen — und leer schreiben + /// schaltet den Raum ab. Ohne das bliebe ein einmal gesetzter Sender bis + /// zum Neustart am Ruder. + #[test] + fn der_raum_laesst_sich_abweisen_und_abschalten() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("master.room"), Wert::Text("signal2 0.4".into())) + .expect("gültig"); + assert_eq!(text(&pult, "master.room"), "signal2 0.4"); + + for unsinn in ["signal9", "Andrang", "signal1 -1"] { + assert!( + pult.schreibe(&k("master.room"), Wert::Text(unsinn.into())) + .is_err(), + "{unsinn} wurde angenommen" + ); + } + // Und der alte steht noch. + assert_eq!(text(&pult, "master.room"), "signal2 0.4"); + + // Drei Wege, ihn abzuschalten. Der leere ist über MCP nicht erreichbar + // — dort verlangt `musik_set` mindestens ein Zeichen —, und ein Weg, + // den nur der Socket kennt, wäre für ein Team von Agenten keiner. + for aus in ["", "-", "aus"] { + pult.schreibe(&k("master.room"), Wert::Text("signal2 0.4".into())) + .unwrap(); + pult.schreibe(&k("master.room"), Wert::Text(aus.into())) + .unwrap_or_else(|e| panic!("{aus:?} schaltet nicht ab: {e:?}")); + assert_eq!(pult.lies(&k("master.room")).unwrap(), Wert::Leer, "{aus:?}"); + assert_eq!(pult.lies(&k("master.room_bend")).unwrap(), Wert::Leer); + } + } + + /// **Der Satz ist der Zweck.** Eine Anlage, die ihr Ziel still verschiebt, + /// ist für ein Team unbrauchbar: Der Nächste sieht eine Zahl, die nicht in + /// der Kurve steht, und muss raten, woher sie kommt. + #[test] + fn der_satz_sagt_woraus_gerechnet_wurde() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + assert!( + text(&pult, "master.why").contains("kein Bogen"), + "{}", + text(&pult, "master.why") + ); + + pult.schreibe(&k("master.arc"), Wert::Text("0 0.7, 60 0.7".into())) + .unwrap(); + assert!( + text(&pult, "master.why").contains("arc_start"), + "{}", + text(&pult, "master.why") + ); + + pult.ausloesen(&k("master.arc_start"), None).unwrap(); + pult.deck_mut(0).unwrap().struktur = Some(abschnitt(Art::Drop)); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + pult.schreibe(&k("master.signal1_name"), Wert::Text("Andrang".into())) + .unwrap(); + pult.schreibe(&k("master.room"), Wert::Text("signal1 0.5".into())) + .unwrap(); + verlauf(&mut pult, 0, &[(60, 0.8), (0, 0.4)]); + + let satz = text(&pult, "master.why"); + assert!(satz.contains("Andrang"), "{satz}"); + assert!(satz.contains("fällt"), "{satz}"); + // Kurve 0,70 minus Beugung 0,20 ergibt 0,50; es läuft ein Drop mit 0,90. + assert!(satz.contains("0.70 → 0.50"), "{satz}"); + assert!(satz.contains("weniger gesucht"), "{satz}"); + } +} + +#[cfg(test)] +mod auswahl_tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + use audio_core::struktur::{Abschnitt, Art, Struktur}; + + fn k(text: &str) -> Schluessel { + Schluessel::parse(text).unwrap() + } + + fn abschnitt(art: Art) -> Struktur { + Struktur { + phrase_beats: PHRASE_BEATS, + abschnitte: vec![Abschnitt { + von_frames: 0, + bis_frames: u64::MAX, + von_beat: 0.0, + bis_beat: 1e9, + art, + pegel: 0.5, + bass: 0.5, + dichte: 0.5, + }], + } + } + + /// Ein laufendes Deck mit Tempo und Gliederung, dazu ein Bogen. + fn aufgelegt(pult: &mut Steuerpult, kurve: &str) { + pult.deck_mut(0).unwrap().struktur = Some(abschnitt(Art::Aufbau)); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + pult.schreibe(&k("master.arc"), Wert::Text(kurve.into())) + .unwrap(); + pult.ausloesen(&k("master.arc_start"), None).unwrap(); + } + + /// **Die Zeile, um die es geht.** Sortiert wird nach dem Abstand zum Ziel, + /// und was das Ziel ist, hat der Raum mitbestimmt. + #[test] + fn was_als_naechstes_passt_steht_oben_und_sagt_warum() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + aufgelegt(&mut pult, "0 0.5, 60 0.5"); + + let zeilen = pult + .ausloesen(&k("master.search_next"), None) + .expect("Auswahl"); + + // Erste Zeile ist der Grund, nicht ein Treffer. + assert!(zeilen[0].starts_with("weil "), "{:?}", zeilen[0]); + + let tracks: Vec<&String> = zeilen.iter().filter(|z| z.starts_with("track ")).collect(); + assert_eq!(tracks.len(), 3, "{zeilen:?}"); + // Die Testsammlung liefert 0.20, 0.55 und einen ohne Analyse. Ziel + // 0.50 — also gehört der mittlere nach oben. + assert!(tracks[0].contains("Energie 0.55"), "{}", tracks[0]); + assert!(tracks[1].contains("Energie 0.20"), "{}", tracks[1]); + // Wer nicht analysiert ist, steht hinten und sagt es. + assert!(tracks[2].contains("nicht analysiert"), "{}", tracks[2]); + // Und jede Zeile trägt ihren Grund. + assert!(tracks[0].contains("+0.05 zum Ziel 0.50"), "{}", tracks[0]); + } + + /// **Der Raum verschiebt die Reihenfolge, nicht nur eine Zahl.** Das ist + /// der Punkt, an dem die Schleife sich schließt — vorher konnte ein Signal + /// ein Ziel bewegen, aber gewählt wurde weiter nach Tempo und Tonart. + #[test] + fn der_raum_verschiebt_die_reihenfolge() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + aufgelegt(&mut pult, "0 0.5, 60 0.5"); + pult.schreibe(&k("master.room"), Wert::Text("signal1".into())) + .unwrap(); + // Der Andrang fällt deutlich: Das Ziel sinkt von 0,50 auf 0,25, und + // damit ist der ruhige Track der passende. + let jetzt = std::time::Instant::now(); + pult.signale[0].setzen(0.9, jetzt - std::time::Duration::from_secs(60)); + pult.signale[0].setzen(0.5, jetzt); + + let zeilen = pult + .ausloesen(&k("master.search_next"), None) + .expect("Auswahl"); + let tracks: Vec<&String> = zeilen.iter().filter(|z| z.starts_with("track ")).collect(); + assert!( + tracks[0].contains("Energie 0.20"), + "jetzt sollte der ruhige oben stehen: {tracks:?}" + ); + assert!(zeilen[0].contains("fällt"), "{:?}", zeilen[0]); + } + + /// Ohne Bogen wird nicht sortiert, sondern gesagt, dass die Grundlage + /// fehlt. Eine nach nichts sortierte Liste, die aussieht wie eine + /// sortierte, wäre schlimmer als ein Fehler. + #[test] + fn ohne_bogen_wird_nicht_geraten() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + + let fehler = pult.ausloesen(&k("master.search_next"), None); + assert!(fehler.is_err(), "ohne Bogen wurde sortiert"); + + // Und ohne laufendes Deck erst recht nicht. + let (mut pult, _runner) = pult_mit_zwei_decks(); + aufgelegt(&mut pult, "0 0.5, 60 0.5"); + pult.schreibe(&k("deck1.play"), Wert::Schalter(false)) + .unwrap(); + assert!(pult.ausloesen(&k("master.search_next"), None).is_err()); + } +} diff --git a/crates/control/src/raum.rs b/crates/control/src/raum.rs new file mode 100644 index 0000000..158259d --- /dev/null +++ b/crates/control/src/raum.rs @@ -0,0 +1,164 @@ +//! Der Raum: was draußen geschieht, verändert, was das Set will. +//! +//! Die vier Signalplätze gab es schon, aber sie waren Deko — Werte gingen +//! hinein, und nichts hat darauf reagiert. Ein Bogen, der von der ersten Minute +//! an feststeht, ist ein Abspielplan; genau das sollte hier nie herauskommen. +//! +//! # Was gebeugt wird und was nicht +//! +//! Der Raum verschiebt **das Ziel**, nicht die Kurve. `master.arc` bleibt, was +//! jemand aufgeschrieben hat; `master.arc_curve` zeigt es unverändert. Gebeugt +//! wird `master.arc_target` — und damit `arc_gap`, die Zahl, nach der gewählt +//! wird. Wer nachsehen will, wie groß der Eingriff ist, liest +//! `master.room_bend`. +//! +//! Das ist der ganze Unterschied zwischen „der Raum redet mit" und „der Raum +//! schreibt um": Am Ende des Abends steht in `arc` immer noch der Plan, gegen +//! den sich vergleichen lässt, was tatsächlich passiert ist. +//! +//! # Der Trend beugt, nicht der Wert +//! +//! Gebeugt wird nach der **Steigung** des Signals, nicht nach seiner Höhe. Der +//! Grund ist, dass die Höhe nichts Vergleichbares ist: Was „0,6 Andrang" +//! bedeutet, weiß nur, wer den Sender geschrieben hat, und eine Anlage, die +//! diese Zahl direkt gegen die Energie des Bogens rechnet, addiert Äpfel — der +//! gleiche Fehler, der beim Ist-Wert des Bogens schon einmal auffiel. +//! +//! Eine Änderung ist dagegen eine Aussage über denselben Sender: „seit drei +//! Minuten fallend" heißt dasselbe, egal wie die Skala gemeint war. Deshalb +//! `wirkung = trend_je_minute * gewicht`, begrenzt auf [`GRENZE`]. +//! +//! # Die Grenze +//! +//! Der Raum darf das Ziel um höchstens [`GRENZE`] verschieben. Ohne Deckel +//! könnte ein hängender oder falsch skalierter Sender das Set übernehmen, und +//! das wäre schlimmer als ein Set, das den Raum ignoriert: Ein Bogen, der +//! wegen eines klemmenden Reglers auf 1,0 steht, ist kein Bogen mehr. + +/// Wie weit der Raum das Ziel höchstens verschiebt. +/// +/// 0,25 ist gut ein Viertel der Energieskala — genug, um aus „Plateau halten" +/// ein „noch etwas drauflegen" zu machen, zu wenig, um aus einem Break einen +/// Drop zu machen. Wer mehr Wirkung will, sagt das über das Gewicht; über die +/// Grenze kommt er trotzdem nicht. +pub const GRENZE: f64 = 0.25; + +/// Welches Signal den Bogen beugt und wie stark. +#[derive(Debug, Clone, PartialEq)] +pub struct Raum { + /// Der Signalplatz, 0-basiert. + pub platz: usize, + /// Wieviel Energie eine Änderung von 1,0 je Minute bewirkt. + pub gewicht: f64, +} + +impl Raum { + /// Liest `signal1 0.3` — Platz und Gewicht. + /// + /// Das Gewicht darf fehlen; dann ist es 1,0. Ein Gewicht von 0 ist erlaubt + /// und heißt: zugehört, aber nicht gefolgt. + pub fn parse(text: &str, signale: usize) -> Option { + let text = text.trim(); + if text.is_empty() { + return None; + } + let (name, rest) = text.split_once(char::is_whitespace).unwrap_or((text, "")); + let nummer: usize = name.trim().strip_prefix("signal")?.parse().ok()?; + if nummer == 0 || nummer > signale { + return None; + } + let gewicht: f64 = match rest.trim() { + "" => 1.0, + g => g.parse().ok()?, + }; + if !gewicht.is_finite() || gewicht < 0.0 { + return None; + } + Some(Raum { + platz: nummer - 1, + gewicht, + }) + } + + pub fn text(&self) -> String { + format!("signal{} {}", self.platz + 1, self.gewicht) + } + + /// Wie weit das Ziel verschoben wird. + /// + /// `None`, solange das Signal keinen Trend hat — aus einem einzelnen Wert + /// lässt sich keine Richtung ablesen, und eine Beugung um 0 zu behaupten + /// wäre etwas anderes als zu sagen, dass man nichts weiß. + pub fn beugung(&self, trend: Option) -> Option { + Some((trend? * self.gewicht).clamp(-GRENZE, GRENZE)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ein_raum_liest_sich_aus_platz_und_gewicht() { + assert_eq!( + Raum::parse("signal1 0.3", 4), + Some(Raum { + platz: 0, + gewicht: 0.3 + }) + ); + // Ohne Gewicht: volle Wirkung. + assert_eq!( + Raum::parse("signal4", 4), + Some(Raum { + platz: 3, + gewicht: 1.0 + }) + ); + assert_eq!(Raum::parse("signal1 0.3", 4).unwrap().text(), "signal1 0.3"); + } + + /// Ein Platz, den es nicht gibt, wird abgewiesen statt stillschweigend auf + /// einen anderen umgebogen: Ein Bogen, der einem Signal folgt, das niemand + /// gemeint hat, ist schlimmer als einer ohne Raum. + #[test] + fn unsinn_wird_abgewiesen() { + for text in [ + "", + " ", + "signal0", + "signal5", + "signal", + "energie 1", + "signal1 -1", + "signal1 viel", + "signal1 inf", + ] { + assert_eq!(Raum::parse(text, 4), None, "{text:?}"); + } + } + + #[test] + fn der_trend_beugt_und_die_grenze_haelt() { + let r = Raum { + platz: 0, + gewicht: 0.5, + }; + assert_eq!(r.beugung(Some(0.4)), Some(0.2)); + assert_eq!(r.beugung(Some(-0.4)), Some(-0.2)); + // Ohne Trend keine Behauptung. + assert_eq!(r.beugung(None), None); + + // Ein Sender, der ausrastet, übernimmt das Set nicht. + assert_eq!(r.beugung(Some(1000.0)), Some(GRENZE)); + assert_eq!(r.beugung(Some(-1000.0)), Some(-GRENZE)); + } + + /// Zugehört, aber nicht gefolgt — erlaubt, und der Unterschied zu „kein + /// Raum gesetzt" bleibt sichtbar. + #[test] + fn ein_gewicht_von_null_ist_erlaubt() { + let r = Raum::parse("signal2 0", 4).expect("gültig"); + assert_eq!(r.beugung(Some(0.9)), Some(0.0)); + } +} diff --git a/crates/control/src/schluessel.rs b/crates/control/src/schluessel.rs new file mode 100644 index 0000000..b570e4c --- /dev/null +++ b/crates/control/src/schluessel.rs @@ -0,0 +1,138 @@ +//! Adressierung eines Controls. +//! +//! Zweiteilig wie bei Mixxx — Gruppe und Element — aber in einer Schreibweise, +//! die man tippen und in eine URL schreiben kann: `deck1.play` statt +//! `[Channel1],play`. +//! +//! Die Gruppe ist getippt und nicht bloß eine Zeichenkette. `deck1` und +//! `channel1` sind nicht dasselbe: Das Deck ist der Abspieler, der Kanal ist +//! der Zug am Mischpult. Bei Mixxx fallen beide in `[Channel1]` zusammen, was +//! genau so lange gutgeht, bis ein Kanal etwas anderes als ein Deck führt — +//! bei uns der AUX-Eingang. + +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Gruppe { + /// Ein Abspieler. Eins-basiert nach außen, null-basiert im Inneren. + Deck(usize), + /// Ein Zug am Mischpult. Führt ein Deck oder den AUX-Eingang. + Kanal(usize), + /// Summe, Crossfader, Kopfhörer. + Master, +} + +impl fmt::Display for Gruppe { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Gruppe::Deck(i) => write!(f, "deck{}", i + 1), + Gruppe::Kanal(i) => write!(f, "channel{}", i + 1), + Gruppe::Master => f.write_str("master"), + } + } +} + +impl Gruppe { + pub fn parse(text: &str) -> Option { + if text == "master" { + return Some(Gruppe::Master); + } + + for (prefix, bauen) in [ + ("deck", Gruppe::Deck as fn(usize) -> Gruppe), + ("channel", Gruppe::Kanal as fn(usize) -> Gruppe), + ] { + if let Some(rest) = text.strip_prefix(prefix) { + // Eins-basiert nach außen: „deck0" gibt es nicht, und es + // stillschweigend als Deck 1 zu lesen würde Tippfehler + // verstecken. + let nummer: usize = rest.parse().ok()?; + return nummer.checked_sub(1).map(bauen); + } + } + + None + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Schluessel { + pub gruppe: Gruppe, + pub element: String, +} + +impl Schluessel { + pub fn neu(gruppe: Gruppe, element: impl Into) -> Schluessel { + Schluessel { + gruppe, + element: element.into(), + } + } + + pub fn parse(text: &str) -> Option { + let (gruppe, element) = text.split_once('.')?; + if element.is_empty() || element.contains('.') { + return None; + } + Some(Schluessel::neu(Gruppe::parse(gruppe)?, element)) + } +} + +impl fmt::Display for Schluessel { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}.{}", self.gruppe, self.element) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schluessel_ueberleben_hin_und_zurueck() { + for text in [ + "deck1.play", + "deck2.tempo", + "channel3.eq_low", + "master.gain", + ] { + let k = Schluessel::parse(text).unwrap_or_else(|| panic!("{text} nicht lesbar")); + assert_eq!(k.to_string(), text); + } + } + + #[test] + fn nummern_sind_nach_aussen_eins_basiert() { + assert_eq!(Gruppe::parse("deck1"), Some(Gruppe::Deck(0))); + assert_eq!(Gruppe::parse("channel4"), Some(Gruppe::Kanal(3))); + + // Kein stilles Umdeuten: Deck 0 gibt es nicht. + assert_eq!(Gruppe::parse("deck0"), None); + } + + #[test] + fn deck_und_kanal_sind_nicht_dasselbe() { + // Der AUX-Eingang ist ein Kanal ohne Deck. Fielen beide zusammen, + // gäbe es für ihn keine Adresse. + assert_ne!(Gruppe::parse("deck1"), Gruppe::parse("channel1")); + } + + #[test] + fn kaputte_schluessel_werden_abgewiesen() { + for text in [ + "", + "deck1", + "deck1.", + ".play", + "deck1.play.now", + "deckX.play", + "quatsch.play", + ] { + assert_eq!( + Schluessel::parse(text), + None, + "{text} hätte scheitern müssen" + ); + } + } +} diff --git a/crates/control/src/server.rs b/crates/control/src/server.rs new file mode 100644 index 0000000..4d6a252 --- /dev/null +++ b/crates/control/src/server.rs @@ -0,0 +1,776 @@ +//! Die Steckdose: das Protokoll über einen Unix-Socket — und, wo es den nicht +//! gibt, über die Rückschleife. +//! +//! Ein Socket im Dateisystem und nicht TCP, und das ist eine +//! Sicherheitsentscheidung, keine Bequemlichkeit. Wer hier hineinschreibt, +//! steuert die Anlage. Ein offener TCP-Port täte das für jeden im selben +//! Netz — auf einer Clubbühne mit fremdem WLAN ist das keine theoretische +//! Sorge. Ein Unix-Socket erbt dagegen die Rechte des Dateisystems: Wer die +//! Datei nicht öffnen darf, kommt nicht hinein. +//! +//! # Windows hat keine Unix-Sockets +//! +//! Dort lief die Anlage bisher **ohne jede Steuerung**: Oberfläche, Decks und +//! Mixer ja, Fernsteuerung nein — und damit auch kein MCP und keine Agenten, +//! also genau der Teil, um dessentwillen das Projekt gebaut wird. +//! +//! Deshalb gibt es einen zweiten Weg, und er ist **enger gebaut als der +//! erste**, weil TCP von sich aus weiter offen steht: +//! +//! 1. **Nur die Rückschleife.** Gebunden wird ausschließlich an `127.0.0.1` +//! oder `::1`. Eine andere Adresse wird abgelehnt, nicht stillschweigend +//! übernommen — ein Tippfehler soll die Anlage nicht ins WLAN stellen. +//! 2. **Ein Schlüssel.** Die erste Zeile einer Verbindung muss `auth +//! ` sein, sonst wird nichts ausgeführt. Der Schlüssel entsteht +//! beim Start neu und steht nur im Fenster der Anwendung. +//! +//! Der zweite Punkt ist nicht Zierrat. Die Rückschleife ist nicht privat: Jedes +//! Programm auf demselben Rechner erreicht sie, **eine Webseite im Browser +//! eingeschlossen**. Ein `fetch` an `http://127.0.0.1:` schickt Zeilen, +//! die dieses Protokoll liest; ohne Schlüssel könnte eine beliebige Seite im +//! Hintergrund den Crossfader ziehen. Lesen kann sie ihn nicht — die Antwort +//! bleibt ihr durch die Regeln des Browsers verborgen, und ohne die erste Zeile +//! kommt sie nicht weiter. +//! +//! Wo es einen Unix-Socket gibt, bleibt er die erste Wahl: Dort erledigt das +//! Dateisystem, wofür hier ein Schlüssel nötig ist. +//! +//! Ein Thread je Verbindung, ein Mutex um das Pult. Das ist grob und hier +//! völlig ausreichend: Es fließen Reglerbewegungen, keine Samples, und der +//! Audio-Thread ist an keiner Stelle beteiligt — er sieht nur die lock-freie +//! Kommandoschlange, die das Pult füllt. +//! +//! ```sh +//! nc -U /tmp/musik.sock +//! list deck1. +//! set deck1.play 1 +//! ``` +//! +//! ```text +//! # über die Rückschleife, Schlüssel zuerst +//! nc 127.0.0.1 7657 +//! auth 7f3a1c… +//! ok angemeldet +//! set deck1.play 1 +//! ``` + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use crate::protokoll::{self, Sitzung}; +use crate::pult::Steuerpult; + +/// Wie oft nach Änderungen für Abonnenten gesehen wird. +/// +/// Fünfzig Millisekunden sind für einen Menschen unmittelbar und für einen +/// Regler feiner, als die Hand ihn bewegt. Häufiger wäre nur mehr Verkehr: +/// Die Abspielposition ändert sich mit jedem Sample, die will niemand +/// vollständig. +const ABO_TAKT: Duration = Duration::from_millis(50); + +#[derive(Debug)] +pub enum ServerFehler { + Belegt(PathBuf), + /// Es sollte an etwas gebunden werden, das nicht die Rückschleife ist. + NichtLokal(SocketAddr), + Io(std::io::Error), +} + +impl std::fmt::Display for ServerFehler { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + ServerFehler::Belegt(p) => write!( + f, + "{} wird bereits von einer laufenden Instanz benutzt", + p.display() + ), + ServerFehler::NichtLokal(a) => write!( + f, + "{a} ist nicht die Rückschleife — die Anlage wird nicht ins Netz \ + gestellt. Erlaubt sind 127.0.0.1 und ::1." + ), + ServerFehler::Io(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for ServerFehler {} + +impl From for ServerFehler { + fn from(e: std::io::Error) -> Self { + ServerFehler::Io(e) + } +} + +pub struct Server { + /// Der Socketpfad — nur beim Weg über das Dateisystem, und nur der wird + /// beim Aufräumen gelöscht. + pfad: Option, + /// Wie man hinkommt, für Menschen. + adresse: String, + /// Der Schlüssel, den eine Verbindung zuerst nennen muss. `None` beim + /// Unix-Socket: Dort hat das Dateisystem die Frage schon beantwortet. + schluessel: Option, +} + +/// Eine Leitung, über die das Protokoll läuft. +/// +/// Zwei Threads teilen sie sich — einer antwortet, einer meldet —, deshalb muss +/// sie sich verdoppeln lassen. Mehr wird nicht gebraucht, und genau deshalb +/// steht hier ein eigener kleiner Vertrag statt zweier fast gleicher Kopien +/// der Bedienung. +trait Leitung: Read + Write + Send + Sized + 'static { + fn klonen(&self) -> std::io::Result; +} + +#[cfg(unix)] +impl Leitung for std::os::unix::net::UnixStream { + fn klonen(&self) -> std::io::Result { + self.try_clone() + } +} + +impl Leitung for TcpStream { + fn klonen(&self) -> std::io::Result { + self.try_clone() + } +} + +/// Ein Schlüssel aus dem Zufall des Betriebssystems. +/// +/// Nicht aus der Uhrzeit: Wer weiß, wann die Anwendung gestartet wurde, hätte +/// den Schlüssel damit fast schon. Sechzehn Byte als Hex sind kurz genug zum +/// Abtippen und lang genug, dass Raten ausscheidet. +fn schluessel_erzeugen() -> String { + let mut roh = [0u8; 16]; + getrandom::fill(&mut roh).expect("kein Zufall vom Betriebssystem"); + roh.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Ob eine Adresse nur von diesem Rechner aus erreichbar ist. +fn ist_rueckschleife(adresse: &SocketAddr) -> bool { + adresse.ip().is_loopback() +} + +impl Server { + /// Startet den Server im Hintergrund. + /// + /// Die Verbindung zum Pult läuft über den geteilten Mutex; der Aufrufer + /// behält seinen eigenen Zugriff und kann weiter bedienen. + #[cfg(unix)] + pub fn starten(pfad: &Path, pult: Arc>) -> Result { + use std::os::unix::net::{UnixListener, UnixStream}; + + // Ein liegengebliebener Socket einer abgestürzten Instanz blockiert + // sonst dauerhaft. Verbinden entscheidet, ob wirklich jemand lauscht: + // gelingt es, läuft eine zweite Instanz und wir treten zurück. + if pfad.exists() { + if UnixStream::connect(pfad).is_ok() { + return Err(ServerFehler::Belegt(pfad.to_path_buf())); + } + std::fs::remove_file(pfad)?; + } + + if let Some(ordner) = pfad.parent() { + std::fs::create_dir_all(ordner)?; + } + + let listener = UnixListener::bind(pfad)?; + let pfad_kopie = pfad.to_path_buf(); + + std::thread::Builder::new() + .name("control-server".into()) + .spawn(move || { + for verbindung in listener.incoming() { + let Ok(strom) = verbindung else { continue }; + let pult = Arc::clone(&pult); + // Ein Thread je Verbindung. Wer die Anlage steuert, hat + // selten mehr als eine Handvoll offen. + let _ = std::thread::Builder::new() + .name("control-verbindung".into()) + .spawn(move || bediene(strom, pult, None)); + } + })?; + + Ok(Server { + adresse: pfad_kopie.display().to_string(), + pfad: Some(pfad_kopie), + schluessel: None, + }) + } + + #[cfg(not(unix))] + pub fn starten(_pfad: &Path, _pult: Arc>) -> Result { + Err(ServerFehler::Io(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Unix-Sockets gibt es hier nicht — nimm starten_tcp", + ))) + } + + /// Startet den Server auf der Rückschleife, mit Schlüssel. + /// + /// Für Windows gedacht, wo es keine Unix-Sockets gibt; anderswo geht es + /// auch, ist aber der schwächere Weg — siehe den Kopf dieser Datei. + pub fn starten_tcp( + adresse: SocketAddr, + pult: Arc>, + ) -> Result { + // **Vor** dem Binden prüfen. Danach wäre der Port schon offen, und + // zwischen Öffnen und Schließen liegt genau das Fenster, das man nicht + // haben will. + if !ist_rueckschleife(&adresse) { + return Err(ServerFehler::NichtLokal(adresse)); + } + + let listener = TcpListener::bind(adresse)?; + let echte = listener.local_addr()?; + let schluessel = schluessel_erzeugen(); + let fuer_threads = Arc::new(schluessel.clone()); + + std::thread::Builder::new() + .name("control-server".into()) + .spawn(move || { + for verbindung in listener.incoming() { + let Ok(strom) = verbindung else { continue }; + // Nagle aus: Es fließen kurze Zeilen, und die sollen sofort + // ankommen statt auf Gesellschaft zu warten. + let _ = strom.set_nodelay(true); + let pult = Arc::clone(&pult); + let schluessel = Arc::clone(&fuer_threads); + let _ = std::thread::Builder::new() + .name("control-verbindung".into()) + .spawn(move || bediene(strom, pult, Some(schluessel))); + } + })?; + + Ok(Server { + pfad: None, + adresse: echte.to_string(), + schluessel: Some(schluessel), + }) + } + + /// Der Socketpfad — `None`, wenn über die Rückschleife gelauscht wird. + pub fn pfad(&self) -> Option<&Path> { + self.pfad.as_deref() + } + + /// Wie man hinkommt, als Text für Menschen. + pub fn adresse(&self) -> &str { + &self.adresse + } + + /// Der Schlüssel, falls einer nötig ist. + pub fn schluessel(&self) -> Option<&str> { + self.schluessel.as_deref() + } +} + +impl Drop for Server { + fn drop(&mut self) { + // Aufräumen, damit der nächste Start nicht über die eigene Leiche + // stolpert. Ein Port hinterlässt nichts, was aufzuräumen wäre. + if let Some(pfad) = &self.pfad { + let _ = std::fs::remove_file(pfad); + } + } +} + +/// Bedient eine Verbindung, bis sie abbricht. +/// +/// `schluessel` ist `Some`, wenn erst angemeldet werden muss — beim Weg über +/// die Rückschleife. Solange das nicht geschehen ist, wird **kein** Befehl +/// ausgeführt, auch kein lesender: Wer nicht hereindarf, soll auch nicht +/// erfahren, was gerade läuft. +fn bediene(strom: L, pult: Arc>, schluessel: Option>) { + let Ok(zum_schreiben) = strom.klonen() else { + return; + }; + + // Zwei Threads teilen sich die Leitung: dieser antwortet auf Befehle, der + // andere meldet Änderungen. Beide schreiben, also braucht der Ausgang ein + // Schloss — sonst schöben sich zwei Zeilen ineinander. + let aus = Arc::new(Mutex::new(zum_schreiben)); + let sitzung = Arc::new(Mutex::new(Sitzung::neu())); + let laeuft = Arc::new(AtomicBool::new(true)); + + let melder = { + let aus = Arc::clone(&aus); + let sitzung = Arc::clone(&sitzung); + let pult = Arc::clone(&pult); + let laeuft = Arc::clone(&laeuft); + + std::thread::Builder::new() + .name("control-abo".into()) + .spawn(move || { + while laeuft.load(Ordering::Relaxed) { + std::thread::sleep(ABO_TAKT); + + let zeilen = { + let (Ok(mut s), Ok(p)) = (sitzung.lock(), pult.lock()) else { + continue; + }; + if !s.hat_abos() { + continue; + } + s.aenderungen(&p) + }; + + for zeile in zeilen { + if !schreibe(&aus, &zeile) { + return; + } + } + } + }) + .ok() + }; + + // Beim Unix-Socket hat das Dateisystem schon entschieden; dort gilt die + // Verbindung von Anfang an als angemeldet. + let mut angemeldet = schluessel.is_none(); + + let leser = BufReader::new(strom); + for zeile in leser.lines() { + let Ok(zeile) = zeile else { break }; + + if !angemeldet { + let antwort = match anmeldung_pruefen(&zeile, schluessel.as_deref()) { + true => { + angemeldet = true; + "ok angemeldet".to_string() + } + // Kein Hinweis darauf, was falsch war, und keine Auskunft über + // die Anlage. Wer raten will, soll nichts dazulernen. + false => "err nicht angemeldet — erste Zeile: auth ".to_string(), + }; + if !schreibe(&aus, &antwort) { + break; + } + continue; + } + + let antwort = match (pult.lock(), sitzung.lock()) { + (Ok(mut p), Ok(mut s)) => protokoll::behandle(&mut p, &mut s, &zeile), + // Ein vergifteter Mutex heißt: irgendwo ist ein Thread mit dem + // Pult in der Hand gestorben. Weiterbedienen wäre geraten. + _ => "err Steuerpult ist in einem unklaren Zustand".to_string(), + }; + + if antwort.is_empty() { + continue; + } + if !schreibe(&aus, &antwort) { + break; + } + } + + // Der Melder hängt sonst an einer toten Leitung. + laeuft.store(false, Ordering::Relaxed); + if let Some(t) = melder { + let _ = t.join(); + } +} + +/// Ob diese Zeile die richtige Anmeldung ist. +/// +/// Verglichen wird über die volle Länge, nicht mit `==` auf dem ersten +/// Unterschied — ein Vergleich, der früh abbricht, verrät über die Zeit, wie +/// viele Zeichen stimmten. Hier ist das kaum auszunutzen, aber der richtige +/// Vergleich kostet nichts. +fn anmeldung_pruefen(zeile: &str, schluessel: Option<&String>) -> bool { + let Some(erwartet) = schluessel else { + return true; + }; + let Some(gegeben) = zeile.trim().strip_prefix("auth ") else { + return false; + }; + let gegeben = gegeben.trim().as_bytes(); + let erwartet = erwartet.as_bytes(); + if gegeben.len() != erwartet.len() { + return false; + } + let mut unterschied = 0u8; + for (a, b) in gegeben.iter().zip(erwartet) { + unterschied |= a ^ b; + } + unterschied == 0 +} + +fn schreibe(aus: &Mutex, zeile: &str) -> bool { + let Ok(mut strom) = aus.lock() else { + return false; + }; + writeln!(strom, "{zeile}").is_ok() && strom.flush().is_ok() +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + use std::io::BufRead; + use std::os::unix::net::UnixStream; + + /// Ein Socketpfad, der zu diesem Testlauf gehört und sonst niemandem. + fn pfad(name: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!("musik-test-{}-{name}.sock", std::process::id())); + p + } + + fn sprich(strom: &UnixStream, zeile: &str) -> String { + let mut schreiber = strom.try_clone().unwrap(); + writeln!(schreiber, "{zeile}").unwrap(); + schreiber.flush().unwrap(); + + let mut leser = BufReader::new(strom.try_clone().unwrap()); + let mut antwort = String::new(); + leser.read_line(&mut antwort).unwrap(); + antwort.trim_end().to_string() + } + + #[test] + fn ein_fremder_prozess_kann_die_anlage_bedienen() { + // Das ist der Punkt der ganzen Übung — und genau das, was Mixxx + // nicht kann. + let (pult, _runner) = pult_mit_zwei_decks(); + let pult = Arc::new(Mutex::new(pult)); + let p = pfad("bedienen"); + let server = Server::starten(&p, Arc::clone(&pult)).expect("Server startet nicht"); + + let strom = UnixStream::connect(server.pfad().unwrap()).expect("keine Verbindung"); + assert_eq!( + sprich(&strom, "set channel1.fader 0.8"), + "ok channel1.fader 0.800000" + ); + assert_eq!( + sprich(&strom, "get channel1.fader"), + "value channel1.fader 0.800000" + ); + + // Und die Änderung ist wirklich im Pult angekommen, nicht nur in der + // Antwort. + let gelesen = pult + .lock() + .unwrap() + .lies(&crate::Schluessel::parse("channel1.fader").unwrap()) + .unwrap(); + assert_eq!(gelesen, crate::Wert::Zahl(0.8)); + } + + #[test] + fn mehrere_bediener_gleichzeitig() { + let (pult, _runner) = pult_mit_zwei_decks(); + let p = pfad("mehrere"); + let server = Server::starten(&p, Arc::new(Mutex::new(pult))).unwrap(); + + let a = UnixStream::connect(server.pfad().unwrap()).unwrap(); + let b = UnixStream::connect(server.pfad().unwrap()).unwrap(); + + assert_eq!(sprich(&a, "set deck1.play 1"), "ok deck1.play 1"); + // Der zweite Bediener sieht, was der erste getan hat. + assert_eq!(sprich(&b, "get deck1.play"), "value deck1.play 1"); + } + + #[test] + fn abonnenten_bekommen_aenderungen_ungefragt() { + // Der Unterschied zum Pollen: Der Bediener sagt einmal Bescheid und + // hört danach zu. + let (pult, _runner) = pult_mit_zwei_decks(); + let pult = Arc::new(Mutex::new(pult)); + let p = pfad("abo"); + let server = Server::starten(&p, Arc::clone(&pult)).unwrap(); + + let strom = UnixStream::connect(server.pfad().unwrap()).unwrap(); + assert_eq!(sprich(&strom, "sub deck1.play"), "ok sub 1 neu, 1 gesamt"); + + let mut leser = BufReader::new(strom.try_clone().unwrap()); + let mut zeile = String::new(); + + // Erst der Ist-Zustand … + leser.read_line(&mut zeile).unwrap(); + assert_eq!(zeile.trim_end(), "value deck1.play 0"); + + // … dann die Änderung, ohne dass jemand danach fragt. + pult.lock() + .unwrap() + .schreibe( + &crate::Schluessel::parse("deck1.play").unwrap(), + crate::Wert::Schalter(true), + ) + .unwrap(); + + zeile.clear(); + leser.read_line(&mut zeile).unwrap(); + assert_eq!(zeile.trim_end(), "value deck1.play 1"); + } + + #[test] + fn ein_liegengebliebener_socket_blockiert_den_start_nicht() { + let p = pfad("leiche"); + std::fs::write(&p, b"").unwrap(); + + let (pult, _runner) = pult_mit_zwei_decks(); + let server = Server::starten(&p, Arc::new(Mutex::new(pult))); + assert!( + server.is_ok(), + "abgestürzte Vorgänger dürfen nicht dauerhaft blockieren" + ); + } + + #[test] + fn der_socket_verschwindet_beim_beenden() { + let p = pfad("aufraeumen"); + let (pult, _runner) = pult_mit_zwei_decks(); + { + let _server = Server::starten(&p, Arc::new(Mutex::new(pult))).unwrap(); + assert!(p.exists()); + } + assert!(!p.exists(), "der Socket bleibt liegen"); + } +} + +/// Zwei Verbindungen gleichzeitig, über den echten Socket. +/// +/// Die Zusammenstöße selbst prüft `protokoll::team_tests` — dort ohne Steckdose +/// und deshalb schnell und genau. Hier geht es um das, was nur der volle Weg +/// zeigt: **ob es klemmt.** Der Taktgeber nimmt den Mutex alle 5 ms, jede +/// Verbindung nimmt ihn je Befehl, und der Abo-Thread alle 50 ms. Ob sich diese +/// drei gegenseitig aushungern, sagt kein Modultest. +#[cfg(all(unix, test))] +mod zwei_verbindungen { + use super::*; + use crate::testing::pult_mit_zwei_decks; + use crate::wert::Wert; + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixStream; + use std::time::{Duration, Instant}; + + fn k(text: &str) -> crate::Schluessel { + crate::Schluessel::parse(text).unwrap() + } + + struct Draht { + schreiben: UnixStream, + lesen: BufReader, + } + + impl Draht { + fn neu(pfad: &Path) -> Draht { + let strom = UnixStream::connect(pfad).expect("verbinden"); + strom + .set_read_timeout(Some(Duration::from_millis(500))) + .expect("Zeitlimit"); + Draht { + lesen: BufReader::new(strom.try_clone().expect("klonen")), + schreiben: strom, + } + } + + fn sagt(&mut self, zeile: &str) { + writeln!(self.schreiben, "{zeile}").expect("schreiben"); + self.schreiben.flush().expect("leeren"); + } + + /// Liest, bis eine Zeile passt oder die Zeit abläuft. + fn wartet_auf(&mut self, teil: &str, frist: Duration) -> Option { + let bis = Instant::now() + frist; + while Instant::now() < bis { + let mut zeile = String::new(); + match self.lesen.read_line(&mut zeile) { + Ok(0) => return None, + Ok(_) => { + if zeile.contains(teil) { + return Some(zeile.trim_end().to_string()); + } + } + Err(_) => continue, + } + } + None + } + } + + /// Der ganze Weg: zwei Verbindungen, ein laufender Taktgeber, eine + /// abgelöste Rampe — und die Meldung kommt beim Verlierer an. + #[test] + fn eine_abgeloeste_rampe_erreicht_die_andere_verbindung() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + pult.schreibe(&k("channel1.fader"), Wert::Zahl(1.0)) + .unwrap(); + + let pfad = std::env::temp_dir().join(format!("musik-team-{}.sock", std::process::id())); + let _ = std::fs::remove_file(&pfad); + + let pult = Arc::new(Mutex::new(pult)); + let server = Server::starten(&pfad, Arc::clone(&pult)).expect("Server"); + let _takt = crate::zeitplan::takt_starten(Arc::clone(&pult)); + + // Das Deck muss laufen, sonst steht auch der Plan. + let laeuft = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let weiter = Arc::clone(&laeuft); + let audio = std::thread::spawn(move || { + let mut puffer = vec![0.0f32; 256 * 4]; + while weiter.load(std::sync::atomic::Ordering::Relaxed) { + runner.render(&mut puffer, 4); + std::thread::sleep(Duration::from_millis(4)); + } + }); + + let mut a = Draht::neu(server.pfad().unwrap()); + let mut b = Draht::neu(server.pfad().unwrap()); + + a.sagt("sub master.events"); + assert!(a.wartet_auf("ok sub", Duration::from_secs(2)).is_some()); + + a.sagt("ramp channel1.fader 0 64"); + assert!( + a.wartet_auf("ok plan", Duration::from_secs(2)).is_some(), + "die Rampe wurde nicht angenommen" + ); + + std::thread::sleep(Duration::from_millis(200)); + b.sagt("set channel1.fader 0.5"); + + let gemeldet = a.wartet_auf("abgeloest", Duration::from_secs(3)); + laeuft.store(false, std::sync::atomic::Ordering::Relaxed); + audio.join().ok(); + let _ = std::fs::remove_file(&pfad); + + assert!( + gemeldet.is_some(), + "über den Socket kam keine Ablösung an — der Verlierer bleibt blind" + ); + } +} + +/// Der Weg über die Rückschleife — geprüft ohne Unix-Sockets, damit er auch +/// dort gilt, wo es keine gibt. +#[cfg(test)] +mod tcp { + use super::*; + use crate::testing::pult_mit_zwei_decks; + use std::io::BufRead; + use std::net::{IpAddr, Ipv4Addr}; + + fn pult() -> Arc> { + let (pult, runner) = pult_mit_zwei_decks(); + // Der Runner muss am Leben bleiben, sonst bricht die Kommandoschlange + // und jeder Befehl liefe ins Nichts — die Tests würden grün, obwohl + // nichts ankommt. + std::mem::forget(runner); + Arc::new(Mutex::new(pult)) + } + + /// Ein Port, den nur dieser Lauf benutzt: 0 heißt „such dir einen". + fn irgendwo() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0) + } + + struct Draht { + schreiben: TcpStream, + lesen: BufReader, + } + + impl Draht { + fn neu(adresse: &str) -> Draht { + let strom = TcpStream::connect(adresse).expect("verbinden"); + let lesen = BufReader::new(strom.try_clone().expect("klonen")); + Draht { + schreiben: strom, + lesen, + } + } + + fn sag(&mut self, zeile: &str) -> String { + writeln!(self.schreiben, "{zeile}").expect("schreiben"); + self.schreiben.flush().expect("leeren"); + let mut antwort = String::new(); + self.lesen.read_line(&mut antwort).expect("lesen"); + antwort.trim_end().to_string() + } + } + + /// **Die Anlage wird nicht ins Netz gestellt.** + /// + /// Ein Tippfehler in der Adresse — `0.0.0.0` statt `127.0.0.1` — würde + /// jeden im selben WLAN ans Pult lassen. Das muss abgelehnt werden, und + /// zwar *bevor* gebunden wird: Zwischen Öffnen und Schließen läge sonst + /// genau das Fenster, das man nicht haben will. + #[test] + fn eine_adresse_ausserhalb_der_rueckschleife_wird_abgelehnt() { + let offen = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0); + let fehler = Server::starten_tcp(offen, pult()) + .err() + .expect("darf nicht binden"); + assert!( + matches!(fehler, ServerFehler::NichtLokal(_)), + "falscher Fehler: {fehler}" + ); + assert!( + TcpStream::connect(offen).is_err(), + "es wurde trotzdem gebunden" + ); + } + + /// **Ohne Schlüssel geschieht nichts** — auch nichts Lesendes. + #[test] + fn ohne_anmeldung_wird_kein_befehl_ausgefuehrt() { + let server = Server::starten_tcp(irgendwo(), pult()).expect("starten"); + let mut draht = Draht::neu(server.adresse()); + + let antwort = draht.sag("set deck1.play 1"); + assert!(antwort.starts_with("err nicht angemeldet"), "{antwort}"); + + // Auch fragen ist Auskunft. Wer nicht hereindarf, soll nicht erfahren, + // was gerade läuft. + let antwort = draht.sag("get deck1.play"); + assert!(antwort.starts_with("err nicht angemeldet"), "{antwort}"); + } + + #[test] + fn ein_falscher_schluessel_hilft_nicht() { + let server = Server::starten_tcp(irgendwo(), pult()).expect("starten"); + let mut draht = Draht::neu(server.adresse()); + let antwort = draht.sag("auth 00000000000000000000000000000000"); + assert!(antwort.starts_with("err nicht angemeldet"), "{antwort}"); + } + + #[test] + fn mit_dem_richtigen_schluessel_laesst_sich_bedienen() { + let server = Server::starten_tcp(irgendwo(), pult()).expect("starten"); + let schluessel = server.schluessel().expect("Schlüssel").to_string(); + let mut draht = Draht::neu(server.adresse()); + + assert_eq!(draht.sag(&format!("auth {schluessel}")), "ok angemeldet"); + assert_eq!(draht.sag("set deck1.play 1"), "ok deck1.play 1"); + assert_eq!(draht.sag("get deck1.play"), "value deck1.play 1"); + } + + /// Jeder Start bekommt einen eigenen Schlüssel. Ein fester wäre nach dem + /// ersten Mal keiner mehr. + #[test] + fn jeder_start_hat_einen_eigenen_schluessel() { + let a = Server::starten_tcp(irgendwo(), pult()).expect("starten"); + let b = Server::starten_tcp(irgendwo(), pult()).expect("starten"); + assert_ne!(a.schluessel(), b.schluessel()); + assert_eq!(a.schluessel().map(str::len), Some(32)); + } + + #[test] + fn der_vergleich_haengt_nicht_an_der_laenge_allein() { + let s = "abcdef".to_string(); + assert!(anmeldung_pruefen("auth abcdef", Some(&s))); + assert!(!anmeldung_pruefen("auth abcde", Some(&s))); + assert!(!anmeldung_pruefen("auth abcdefg", Some(&s))); + assert!(!anmeldung_pruefen("abcdef", Some(&s))); + // Ohne Schlüssel ist alles angemeldet — der Unix-Socket. + assert!(anmeldung_pruefen("was auch immer", None)); + } +} diff --git a/crates/control/src/signal.rs b/crates/control/src/signal.rs new file mode 100644 index 0000000..22d60b3 --- /dev/null +++ b/crates/control/src/signal.rs @@ -0,0 +1,231 @@ +//! Was von außen hereinkommt — die Reaktion des Raums. +//! +//! Ein DJ liest die Fläche: Wird es voller, geht die Energie hoch, kippt die +//! Stimmung? Ein Agent kann das nicht sehen. Damit er trotzdem darauf reagieren +//! kann, muss es jemand hereingeben — ein Mikrofonpegel, eine Umfrage auf dem +//! Handy, ein Mensch, der im Chat „wird voller" tippt. +//! +//! **Ein einzelner Wert nützt dabei fast nichts.** „Energie 0,7" beantwortet +//! keine Frage; „0,7 und seit zwei Minuten fallend" beantwortet sie. Deshalb +//! merkt sich ein Signal seine jüngste Vergangenheit und rechnet daraus den +//! Trend aus — dieselbe Überlegung wie bei `beats_left`: Was ein Bediener sonst +//! bei jedem Blick selbst ausrechnet, rechnet er irgendwann falsch. +//! +//! Vier feste Plätze statt beliebig vieler Namen. Ein Platz trägt seine +//! Bedeutung als Text, so wie man am Mischpult einen Kanalzug mit Klebeband +//! beschriftet. Das hält den Katalog statisch — die Alternative wären zur +//! Laufzeit geleakte Zeichenketten, und dieselbe Entscheidung ist schon bei den +//! Hot Cues so gefallen. + +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +/// Wie viele Signale es gibt. +pub const SIGNALE: usize = 4; + +/// Über welchen Zeitraum der Trend gerechnet wird. +/// +/// Zwei Minuten sind an einer Tanzfläche eine Aussage: kürzer misst man das +/// Rauschen einzelner Momente, länger verpasst man den Umschwung, auf den man +/// reagieren wollte. +pub const FENSTER: Duration = Duration::from_secs(120); + +/// Wie viele Proben höchstens aufbewahrt werden. +/// +/// Deckel gegen einen Sender, der im Millisekundentakt schickt — die Liste soll +/// nicht wachsen, nur weil jemand zu eifrig meldet. +pub const MAX_PROBEN: usize = 240; + +/// Ein Wert von außen, mit seiner jüngsten Vergangenheit. +#[derive(Debug, Clone, Default)] +pub struct Signal { + /// Was der Wert bedeutet. Leer heißt: noch nie benutzt. + pub name: String, + proben: VecDeque<(Instant, f64)>, +} + +impl Signal { + pub fn neu() -> Signal { + Signal::default() + } + + /// Trägt einen Messwert ein. + /// + /// Die Zeit kommt von außen, damit sich das hier prüfen lässt, ohne zu + /// warten. + pub fn setzen(&mut self, wert: f64, jetzt: Instant) { + self.proben.push_back((jetzt, wert)); + self.aufraeumen(jetzt); + } + + fn aufraeumen(&mut self, jetzt: Instant) { + while self + .proben + .front() + .is_some_and(|(t, _)| jetzt.duration_since(*t) > FENSTER) + { + self.proben.pop_front(); + } + while self.proben.len() > MAX_PROBEN { + self.proben.pop_front(); + } + } + + /// Der zuletzt gemeldete Wert. + /// + /// Bleibt stehen, auch wenn lange nichts kam — es ist die letzte bekannte + /// Lage, und die verschwindet nicht dadurch, dass niemand mehr misst. Wie + /// alt sie ist, sagt [`Signal::alter`]; wer stillschweigend nicht auf alte + /// Werte hereinfallen will, fragt danach. + pub fn wert(&self) -> Option { + self.proben.back().map(|(_, w)| *w) + } + + /// Sekunden seit der letzten Meldung. + pub fn alter(&self, jetzt: Instant) -> Option { + self.proben + .back() + .map(|(t, _)| jetzt.saturating_duration_since(*t).as_secs_f64()) + } + + /// Änderung je Minute, als Steigung einer Ausgleichsgeraden. + /// + /// Nicht einfach „letzter minus erster": Ein einzelner Ausreißer am Rand + /// würde daraus eine Behauptung machen, die die übrigen Proben nicht + /// hergeben. `None`, solange weniger als zwei Proben im Fenster liegen — + /// aus einem Punkt lässt sich keine Richtung ablesen. + pub fn trend(&self, jetzt: Instant) -> Option { + let gueltig: Vec<(f64, f64)> = self + .proben + .iter() + .filter(|(t, _)| jetzt.duration_since(*t) <= FENSTER) + .map(|(t, w)| (jetzt.duration_since(*t).as_secs_f64(), *w)) + .collect(); + + if gueltig.len() < 2 { + return None; + } + + // x läuft rückwärts (Sekunden *her*), also am Ende das Vorzeichen + // drehen: Ein steigendes Signal soll einen positiven Trend haben. + let n = gueltig.len() as f64; + let x_mittel = gueltig.iter().map(|(x, _)| x).sum::() / n; + let y_mittel = gueltig.iter().map(|(_, y)| y).sum::() / n; + + let mut oben = 0.0; + let mut unten = 0.0; + for (x, y) in &gueltig { + oben += (x - x_mittel) * (y - y_mittel); + unten += (x - x_mittel) * (x - x_mittel); + } + if unten <= f64::EPSILON { + // Alle Proben zur selben Zeit — keine Steigung, aber auch kein + // Fehler. + return Some(0.0); + } + + Some(-oben / unten * 60.0) + } + + pub fn ist_leer(&self) -> bool { + self.proben.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn nach(start: Instant, sekunden: u64) -> Instant { + start + Duration::from_secs(sekunden) + } + + #[test] + fn ein_steigendes_signal_hat_einen_positiven_trend() { + let start = Instant::now(); + let mut s = Signal::neu(); + // Von 0,2 auf 0,6 in einer Minute. + for (sek, wert) in [(0, 0.2), (20, 0.33), (40, 0.47), (60, 0.6)] { + s.setzen(wert, nach(start, sek)); + } + + let trend = s.trend(nach(start, 60)).expect("kein Trend"); + assert!( + (trend - 0.4).abs() < 0.02, + "rund 0,4 je Minute erwartet, war {trend:.3}" + ); + assert_eq!(s.wert(), Some(0.6)); + } + + #[test] + fn ein_fallendes_signal_hat_einen_negativen_trend() { + let start = Instant::now(); + let mut s = Signal::neu(); + for (sek, wert) in [(0, 0.9), (30, 0.6), (60, 0.3)] { + s.setzen(wert, nach(start, sek)); + } + assert!(s.trend(nach(start, 60)).unwrap() < -0.5); + } + + /// Ein Ausreißer am Rand darf die Richtung nicht umdrehen. + /// + /// Genau dafür die Ausgleichsgerade und nicht „letzter minus erster": Eine + /// einzelne Fehlmessung würde daraus eine Behauptung machen, die die + /// übrigen Proben nicht hergeben. + #[test] + fn ein_ausreisser_dreht_die_richtung_nicht_um() { + let start = Instant::now(); + let mut s = Signal::neu(); + for (sek, wert) in [ + (0, 0.2), + (10, 0.3), + (20, 0.4), + (30, 0.5), + (40, 0.6), + (50, 0.7), + (60, 0.1), + ] { + s.setzen(wert, nach(start, sek)); + } + assert!( + s.trend(nach(start, 60)).unwrap() > 0.0, + "der letzte Wert allein hat die Richtung bestimmt" + ); + } + + #[test] + fn aus_einer_probe_laesst_sich_keine_richtung_lesen() { + let start = Instant::now(); + let mut s = Signal::neu(); + assert_eq!(s.trend(start), None); + s.setzen(0.5, start); + assert_eq!(s.trend(start), None, "eine Probe ist keine Richtung"); + assert_eq!(s.wert(), Some(0.5)); + } + + /// Was aus dem Fenster gefallen ist, zählt für den Trend nicht mehr — der + /// Wert selbst bleibt aber die letzte bekannte Lage. + #[test] + fn alte_proben_fallen_aus_dem_fenster_der_wert_bleibt() { + let start = Instant::now(); + let mut s = Signal::neu(); + s.setzen(0.2, start); + s.setzen(0.8, nach(start, 10)); + + let spaeter = nach(start, 400); + assert_eq!(s.wert(), Some(0.8), "die letzte bekannte Lage fehlt"); + assert_eq!(s.trend(spaeter), None, "uralte Proben ergaben einen Trend"); + assert!(s.alter(spaeter).unwrap() > 380.0); + } + + #[test] + fn die_probenliste_waechst_nicht_unbegrenzt() { + let start = Instant::now(); + let mut s = Signal::neu(); + // Ein eifriger Sender: tausend Meldungen in derselben Sekunde. + for _ in 0..1_000 { + s.setzen(0.5, start); + } + assert!(s.proben.len() <= MAX_PROBEN, "{}", s.proben.len()); + } +} diff --git a/crates/control/src/testing.rs b/crates/control/src/testing.rs new file mode 100644 index 0000000..2c1e250 --- /dev/null +++ b/crates/control/src/testing.rs @@ -0,0 +1,185 @@ +//! Ein vollständig verkabeltes Pult für Tests. +//! +//! Mit echter Engine und echter Kommandoschlange, nur ohne Audiogerät. Der +//! `EngineRunner` muss dabei am Leben bleiben: Fällt er weg, bricht die +//! Schlange, und jedes gesendete Kommando liefe ins Nichts — die Tests würden +//! grün, obwohl nichts ankommt. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use audio_core::deck::DeckState; +use audio_engine::{Assign, DeckSource, Engine, EngineRunner, SilentSource}; + +use crate::pult::{DeckEintrag, KanalSpiegel, Sammlung, Steuerpult, Treffer}; + +pub const RATE: u32 = 48_000; + +/// Was die Sammlung an Hot Cues zu sehen bekommen hat, je Pfad. +/// +/// Geteilt, weil die Sammlung im Pult unter einem `Box` +/// verschwindet — ohne einen Griff von außen ließe sich nicht prüfen, ob ein +/// Cue wirklich zurückgeschrieben wurde. +pub type CueProtokoll = Arc>>>; + +/// Das zuletzt zurückgeschriebene Beatgrid: Pfad, Tempo, Anker in Sekunden. +pub type GridProtokoll = Arc>>; + +/// Zwei Decks auf Kanal 1 und 2, dazu ein AUX-Kanal auf Thru. +pub fn pult_mit_zwei_decks() -> (Steuerpult, EngineRunner) { + let (pult, runner, _, _) = pult_mit_protokoll(); + (pult, runner) +} + +/// Dasselbe Pult, aber mit einem Blick auf das, was zurückgeschrieben wird. +pub fn pult_mit_protokoll() -> (Steuerpult, EngineRunner, CueProtokoll, GridProtokoll) { + let mut engine = Engine::new(RATE as f32); + + // Echte Abspieler statt `SilentSource`: Nur so bewegt sich die Position, + // und ohne Position gibt es keine Beats — der Zeitplan wäre nicht prüfbar. + // Gerechnet wird trotzdem nur, wenn ein Test den Runner ausdrücklich + // dreht; stillstehende Tests bleiben stillstehend. + let mut kanaele = Vec::new(); + let mut zustaende = Vec::new(); + for (name, assign) in [("DECK A", Assign::A), ("DECK B", Assign::B)] { + let state = Arc::new(DeckState::new()); + let track = Arc::new(audio_core::Track { + samples: vec![0.0; RATE as usize * 60 * 2], + sample_rate: RATE, + stems: Vec::new(), + }); + let voice = audio_core::deck::Voice::new(track, Arc::clone(&state)); + let kanal = engine.add_channel(name, Box::new(DeckSource::new(voice))); + engine.channel(kanal).set_assign(assign); + kanaele.push((name, kanal, assign)); + zustaende.push(state); + } + let aux = engine.add_channel("AUX", Box::new(SilentSource)); + engine.channel(aux).set_assign(Assign::Thru); + + // Der Mitschnitt hängt an der Engine, genau wie im Ernstfall — sonst + // liefe der Test gegen ein Loch, wo in der Anwendung ein Ringpuffer ist. + let (tap, aufnahme) = audio_engine::mitschnitt(RATE); + engine.set_mitschnitt(tap); + + let (handle, runner) = audio_engine::engine_channel(engine, 256); + let mut pult = Steuerpult::neu(handle); + + for ((name, kanal, assign), state) in kanaele.iter().zip(zustaende) { + pult.kanal_hinzufuegen(KanalSpiegel::neu(*name, *assign)); + let mut eintrag = DeckEintrag::neu(state, *kanal, RATE); + // Eine Minute, damit Positionen und Hot Cues Platz haben. + eintrag.frames = RATE as u64 * 60; + eintrag.titel = format!("Testtrack {name}"); + eintrag.artist = "Test".into(); + eintrag.tonart = audio_core::Tonart::parse("Am"); + pult.deck_hinzufuegen(eintrag); + } + pult.kanal_hinzufuegen(KanalSpiegel::neu("AUX", Assign::Thru)); + + // Ein Deck ohne Grid könnte `loop_beats` nicht setzen, und der Test, der + // jedes schreibbare Control durchgeht, würde das nicht bemerken. + for eintrag in pult.decks() { + eintrag + .state + .set_grid(Some(audio_core::Beatgrid::new(128.0, 0, 1.0))); + } + + let protokoll: CueProtokoll = Arc::new(Mutex::new(HashMap::new())); + let grid_protokoll: GridProtokoll = Arc::new(Mutex::new(None)); + pult.sammlung_setzen(Box::new(TestSammlung { + gesicherte_cues: Arc::clone(&protokoll), + gesichertes_grid: Arc::clone(&grid_protokoll), + })); + + pult.aufnahme_setzen(aufnahme); + (pult, runner, protokoll, grid_protokoll) +} + +/// Eine Sammlung, die nichts liest und nichts dekodiert. +/// +/// Sie hält fest, dass ein Ladeauftrag *angenommen* wurde — mehr verspricht +/// die Schnittstelle nicht, und mehr soll ein Test hier auch nicht prüfen. +pub struct TestSammlung { + /// Was zurückgeschrieben wurde — damit ein Test das nachsehen kann. + pub gesicherte_cues: CueProtokoll, + pub gesichertes_grid: GridProtokoll, +} + +impl Sammlung for TestSammlung { + fn suchen(&self, text: &str, grenze: usize) -> Vec { + // Ein Spektrum von ruhig bis heftig, und einer ohne Analyse: Die + // Auswahl nach Energie muss sich an beidem prüfen lassen. + const ENERGIEN: [Option; 3] = [Some(0.2), Some(0.55), None]; + (0..3.min(grenze)) + .map(|i| Treffer { + pfad: format!("/musik/{text}-{i}.wav"), + titel: format!("{text} {i}"), + artist: None, + bpm: Some(128.0 + i as f32), + tonart: audio_core::Tonart::parse("Am"), + energie: ENERGIEN[i], + }) + .collect() + } + + fn suchen_mischbar(&self, bpm: f32, grenze: usize) -> Vec { + self.suchen(&format!("{bpm:.0}"), grenze) + } + + fn suchen_harmonisch(&self, tonart: audio_core::Tonart, grenze: usize) -> Vec { + self.suchen(&tonart.camelot(), grenze) + } + + fn playlists(&self) -> Vec { + vec!["Freitag".into(), "Warmup".into()] + } + + fn playlist(&self, name: &str, grenze: usize) -> Vec { + if name == "Freitag" { + self.suchen(name, grenze) + } else { + Vec::new() + } + } + + fn laden(&self, _deck: usize, pfad: &str) -> Result<(), String> { + if pfad.ends_with(".txt") { + return Err("keine Audiodatei".into()); + } + Ok(()) + } + + fn grid_speichern(&self, pfad: &str, bpm: f32, anker_sekunden: f64) -> Result<(), String> { + if pfad.ends_with(".schreibgeschuetzt") { + return Err("nicht beschreibbar".into()); + } + *self.gesichertes_grid.lock().unwrap() = Some((pfad.to_string(), bpm, anker_sekunden)); + Ok(()) + } + + fn hot_cues_speichern(&self, pfad: &str, cues: &[(usize, f64)]) -> Result<(), String> { + if pfad.ends_with(".schreibgeschuetzt") { + return Err("nicht beschreibbar".into()); + } + self.gesicherte_cues + .lock() + .unwrap() + .insert(pfad.to_string(), cues.to_vec()); + Ok(()) + } +} + +/// Dreht den Mixer so lange, bis so viele Frames vergangen sind. +/// +/// Die Decks laufen sonst nicht: Position und Beats bewegen sich nur, wenn +/// jemand rendert. Im Ernstfall ist das die Soundkarte, hier der Test. +pub fn rendern(runner: &mut EngineRunner, frames: usize) { + const BLOCK: usize = 256; + let mut puffer = vec![0.0f32; BLOCK * 4]; + let mut getan = 0; + while getan < frames { + runner.render(&mut puffer, 4); + getan += BLOCK; + } +} diff --git a/crates/control/src/uebergang.rs b/crates/control/src/uebergang.rs new file mode 100644 index 0000000..20c1c77 --- /dev/null +++ b/crates/control/src/uebergang.rs @@ -0,0 +1,615 @@ +//! Ein Repertoire statt eines Handgriffs. +//! +//! **Der langweiligste DJ im Raum ist der, der immer dasselbe macht** — und +//! genau das war der erste automatisch gefahrene Übergang in diesem Projekt: +//! eine lange Blende, linear, jedes Mal. Sauber, messbar, seelenlos. +//! +//! Ein Mensch hat vier oder fünf Griffe im Kopf und wählt aus. Hier stehen sie +//! benannt: lange Blende, Bass-Swap, harter Schnitt auf die Eins, +//! Filter-Sweep, Schleifen-Ausstieg. +//! +//! # Was hier ausdrücklich *nicht* passiert +//! +//! **Die Anlage wählt nicht aus.** Welcher Griff passt, hängt daran, ob der +//! ausgehende Track ein Outro hat, der eingehende ein langes Intro, wie groß +//! der Energieunterschied ist — und vor allem daran, was vorher schon dreimal +//! gefahren wurde. Das ist eine Entscheidung mit Gründen, und die gehört +//! demjenigen, der sie begründen kann. +//! +//! Seit der Gliederung (S2) stehen die Zahlen dafür im Steuerraum: `section`, +//! `intro_beats`, `beats_to_outro`. Ein Agent liest sie, wählt und sagt warum. +//! Nähme die Anlage ihm das ab, verlöre das Set genau den Teil, um den es +//! diesem Projekt geht. +//! +//! # Jeder Griff endet damit, dass das ausgehende Deck steht +//! +//! Das klang zuerst nach einer Kleinigkeit und war die Stelle, an der aus +//! einem Übergang ein *Set* wird. Bis dahin lief der ausgehende Track nach der +//! Blende weiter — unhörbar hinter dem geschlossenen Crossfader, aber laufend. +//! Der nächste `uebergang` wurde deshalb abgewiesen: „es laufen mehrere Decks", +//! und wer aus- und wer eingeht, kann dann niemand wissen. +//! +//! Am laufenden Programm fiel das beim zweiten Griff auf, nicht beim ersten. +//! Ein einzelner Übergang war nie das Ziel. +//! +//! # Getaktet wird nach dem ausgehenden Deck +//! +//! Ohne Angabe nimmt `in` das erste Deck mit Beatgrid — und das ist beim +//! zweiten Griff eines Sets ausgerechnet das gerade gestoppte. Ein stehendes +//! Deck hält den Plan an, also stand der ganze Übergang still: angenommen, +//! eingetragen, nie gefahren. Das ausgehende Deck läuft dagegen per +//! Definition, solange der Griff dauert. +//! +//! Aufgefallen ist auch das erst beim zweiten Griff am laufenden Programm. +//! Beim ersten läuft zufällig das richtige Deck. +//! +//! # Warum die Griffe in Protokollzeilen gebaut sind +//! +//! Jeder Griff ist eine Handvoll gewöhnlicher Zeilen — `in phrase …`, +//! `ramp … weich` —, die durch denselben Weg laufen wie alles andere. Damit +//! taucht er im Plan auf, in der Mitschrift und bei den Ereignissen, ein +//! zweiter Bediener sieht ihn kommen, und `cancel` nimmt ihn zurück. +//! +//! Vor allem aber: **Es gibt nichts, was ein Agent nicht auch selbst hätte +//! tippen können.** Der Griff spart ihm acht Zeilen, keine Magie. Die Antwort +//! nennt jede davon, damit er sie lesen, ändern und beim nächsten Mal von Hand +//! anders setzen kann. + +use crate::pult::{Fehler, Steuerpult}; + +/// Wie viele Beats zwischen dem Ende einer Bewegung und dem Stoppen des +/// ausgehenden Decks liegen. +/// +/// Ein Beat, und der ist nötig. Endet eine Rampe genau in dem Takt, in dem ihr +/// Taktgeber-Deck stehenbleibt, bekommt sie ihren letzten Schritt nicht mehr: +/// Der Regler steht zwar am Ziel, aber der Auftrag bleibt für immer im Plan +/// stehen — sichtbar für jeden zweiten Bediener, und nicht mehr wegzubekommen, +/// weil das Deck nicht mehr läuft. Am laufenden Programm sah man ihn nach dem +/// zweiten Griff dort liegen. +/// +/// Hörbar ist der eine Beat nicht: Der Crossfader ist längst drüben. +const NACHLAUF: f64 = 1.0; + +/// Ein benannter Übergang. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Griff { + /// Lange Blende über den Crossfader, weich verteilt. + Blende, + /// Beide laufen in der Mitte, und die Bässe tauschen. + Bassswap, + /// Auf der Eins umschalten, ohne Übergang. + Schnitt, + /// Dem Ausgehenden den Boden wegziehen, während der Neue kommt. + Filter, + /// Den Ausgehenden in eine Schleife legen und darüber wechseln. + /// + /// Der einzige Griff, der dem Ausgehenden **Zeit gibt**: Ein Track, der in + /// vier Beats zu Ende wäre, hält so noch eine ganze Phrase durch. Genau + /// dafür setzt ein Mensch am Ende eines Stücks eine Schleife — nicht, um + /// etwas zu wiederholen, sondern um nicht gehetzt wechseln zu müssen. + Schleife, +} + +impl Griff { + pub fn name(&self) -> &'static str { + match self { + Griff::Blende => "blende", + Griff::Bassswap => "bassswap", + Griff::Schnitt => "schnitt", + Griff::Filter => "filter", + Griff::Schleife => "schleife", + } + } + + pub fn parse(text: &str) -> Option { + match text { + "blende" => Some(Griff::Blende), + "bassswap" => Some(Griff::Bassswap), + "schnitt" => Some(Griff::Schnitt), + "filter" => Some(Griff::Filter), + "schleife" => Some(Griff::Schleife), + _ => None, + } + } + + pub const ALLE: [Griff; 5] = [ + Griff::Blende, + Griff::Bassswap, + Griff::Schnitt, + Griff::Filter, + Griff::Schleife, + ]; + + /// Wie lang der Griff üblicherweise ist, in Beats. + /// + /// Keine Vorschrift, nur die Zahl, die man ohne weiteres Nachdenken nimmt: + /// zwei Phrasen für eine Blende, eine für den Bass-Swap, keine für den + /// Schnitt. + /// + /// Bei der Schleife ist die Zahl zugleich die Schleifenlänge, und deshalb + /// eine ganze Phrase: Eine Schleife, die nicht auf der Phrase liegt, + /// verschiebt die Eins und macht aus dem Griff einen Fehler. + pub fn beats(&self) -> f64 { + match self { + Griff::Blende => 32.0, + Griff::Bassswap => 16.0, + Griff::Schnitt => 0.0, + Griff::Filter => 16.0, + Griff::Schleife => 16.0, + } + } + + pub fn beschreibung(&self) -> &'static str { + match self { + Griff::Blende => "Lange Blende über den Crossfader, weich verteilt", + Griff::Bassswap => "Beide laufen in der Mitte, dann tauschen die Bässe", + Griff::Schnitt => "Auf der Eins umschalten, ohne Übergang", + Griff::Filter => "Dem Ausgehenden den Boden wegziehen, während der Neue kommt", + Griff::Schleife => "Den Ausgehenden in eine Schleife legen und darüber wechseln", + } + } +} + +/// Welche Decks gerade aus- und eingehen. +/// +/// Ausgehend ist das Deck, das läuft; eingehend das andere, sofern etwas darauf +/// liegt. Mehr Raterei findet hier nicht statt — wer es anders will, tippt die +/// Zeilen selbst. +pub fn paar(pult: &Steuerpult) -> Result<(usize, usize), Fehler> { + let laeuft: Vec = pult + .decks() + .iter() + .enumerate() + .filter(|(_, d)| d.state.is_playing()) + .map(|(i, _)| i) + .collect(); + + let aus = match laeuft.as_slice() { + [] => { + return Err(Fehler::Gescheitert( + "kein Deck läuft — kein Übergang".into(), + )) + } + [eins] => *eins, + // Läuft schon beides, ist der Übergang längst im Gange, und welcher + // von beiden gemeint ist, kann hier niemand wissen. + _ => { + return Err(Fehler::Gescheitert( + "es laufen mehrere Decks — dann sind die Zeilen von Hand zu setzen".into(), + )); + } + }; + + let ein = pult + .decks() + .iter() + .enumerate() + .find(|(i, d)| *i != aus && d.frames > 0) + .map(|(i, _)| i) + .ok_or_else(|| { + Fehler::Gescheitert("auf dem anderen Deck liegt nichts — erst laden".into()) + })?; + + Ok((aus, ein)) +} + +/// Die Zeilen, aus denen ein Griff besteht. +/// +/// Sie laufen anschließend durch dasselbe Protokoll wie alles andere; hier +/// entsteht nur der Text. +pub fn zeilen(pult: &Steuerpult, griff: Griff, beats: f64) -> Result, Fehler> { + if beats < 0.0 { + return Err(Fehler::Gescheitert( + "eine Länge unter null gibt es nicht".into(), + )); + } + let (aus, ein) = paar(pult)?; + let kaus = pult.decks()[aus].kanal + 1; + let kein = pult.decks()[ein].kanal + 1; + let dein = ein + 1; + let daus = aus + 1; + + // Der Crossfader steht auf −1 für A und +1 für B. Wohin er soll, sagt der + // Kanalzug des eingehenden Decks, nicht die Deck-Nummer: Welche Seite ein + // Zug bedient, ist einstellbar. + let ziel = match pult + .kanaele() + .get(pult.decks()[ein].kanal) + .map(|k| k.assign) + { + Some(audio_engine::Assign::A) => -1.0, + Some(audio_engine::Assign::B) => 1.0, + _ => { + return Err(Fehler::Gescheitert(format!( + "channel{kein} hängt nicht am Crossfader — kein Übergang darüber" + ))); + } + }; + + Ok(match griff { + Griff::Blende => vec![ + format!("in deck{daus} phrase set deck{dein}.play 1"), + format!("in deck{daus} phrase ramp master.crossfader {ziel} {beats} weich deck{daus}"), + format!( + "in deck{daus} phrase+{} set deck{daus}.play 0", + beats + NACHLAUF + ), + ], + Griff::Bassswap => { + // Erst der Bass des Eingehenden weg, dann beide in die Mitte, dort + // tauschen die Bässe über die halbe Länge, und danach ganz hinüber. + let halb = (beats / 2.0).max(1.0); + vec![ + format!("set channel{kein}.eq_low 0"), + format!("in deck{daus} phrase set deck{dein}.play 1"), + format!("in deck{daus} phrase ramp master.crossfader 0 {beats} weich deck{daus}"), + format!("in deck{daus} phrase+{beats} ramp channel{kaus}.eq_low 0 {halb} deck{daus}"), + format!("in deck{daus} phrase+{beats} ramp channel{kein}.eq_low 1 {halb} deck{daus}"), + format!( + "in deck{daus} phrase+{} ramp master.crossfader {ziel} {beats} weich deck{daus}", + beats + halb + ), + format!( + "in deck{daus} phrase+{} set deck{daus}.play 0", + beats + halb + beats + NACHLAUF + ), + ] + } + Griff::Schnitt => vec![ + format!("in deck{daus} phrase set deck{dein}.play 1"), + format!("in deck{daus} phrase set master.crossfader {ziel}"), + format!("in deck{daus} phrase set deck{daus}.play 0"), + ], + Griff::Filter => vec![ + format!("in deck{daus} phrase set deck{dein}.play 1"), + format!("in deck{daus} phrase ramp master.crossfader {ziel} {beats} frueh deck{daus}"), + // Hochpass auf dem Ausgehenden: Der Boden geht weg, während der + // Neue ihn übernimmt. `spaet`, damit es erst spürbar wird, wenn + // der andere schon trägt. + format!("in deck{daus} phrase ramp channel{kaus}.filter 1 {beats} spaet deck{daus}"), + format!( + "in deck{daus} phrase+{} set deck{daus}.play 0", + beats + NACHLAUF + ), + // Der Hochpass geht mit dem Deck wieder auf. Ein Kanalzug, der + // beim nächsten Track noch gefiltert dasteht, ist eine + // Überraschung, die niemand bestellt hat — und man sucht sie + // lange, weil man den Regler nicht angefasst hat. + format!( + "in deck{daus} phrase+{} set channel{kaus}.filter 0", + beats + NACHLAUF + ), + ], + Griff::Schleife => { + // Die Schleife wird auf der Phrasengrenze gesetzt und ist genau so + // lang wie die Blende darüber: Der Ausgehende läuft sie einmal + // durch, und wenn sie herum ist, ist der Wechsel fertig. Eine + // Schleife, die länger steht als der Übergang, wiederholt hörbar — + // und das ist der Fehler, den dieser Griff gerade vermeiden soll. + // + // **Alles nach dem Setzen hängt am eingehenden Deck.** Der Beat des + // ausgehenden wiederholt sich ja gerade — daran getaktet fing die + // Blende bei jedem Schleifendurchlauf von vorn an, und die + // Auflösung der Schleife kam nie, weil ihr Beat nie über das Ende + // der Schleife hinauskam. Am laufenden Programm sah man den + // Crossfader sieben Mal hin und her fahren. + // + // Das setzt voraus, dass der Eingehende auf einer Phrasengrenze + // steht — `do deckN.jump_entry` sorgt dafür, und wer von Hand + // irgendwohin springt, sollte es hier ohnehin nicht tun. + vec![ + format!("in deck{daus} phrase set deck{daus}.loop_beats {beats}"), + format!("in deck{daus} phrase set deck{dein}.play 1"), + format!( + "in deck{dein} phrase ramp master.crossfader {ziel} {beats} weich deck{dein}" + ), + // Gelöst, nicht vergessen: Ein Deck, das im Hintergrund + // weiterschleift, ist beim nächsten Griff eine Überraschung. + format!("in deck{dein} phrase+{beats} set deck{daus}.loop_active 0"), + format!( + "in deck{dein} phrase+{} set deck{daus}.play 0", + beats + NACHLAUF + ), + ] + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::pult_mit_zwei_decks; + use crate::wert::Wert; + use crate::Schluessel; + + fn k(text: &str) -> Schluessel { + Schluessel::parse(text).unwrap() + } + + fn laufend() -> (Steuerpult, audio_engine::EngineRunner) { + let (mut pult, runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + (pult, runner) + } + + /// Ausgehend ist, was läuft; eingehend das andere. + #[test] + fn das_paar_ergibt_sich_daraus_wer_laeuft() { + let (pult, _runner) = laufend(); + assert_eq!(paar(&pult).unwrap(), (0, 1)); + } + + /// Läuft nichts, gibt es keinen Übergang — und das wird gesagt, statt + /// irgendein Deck zu wählen. + #[test] + fn ohne_laufendes_deck_gibt_es_keinen_uebergang() { + let (pult, _runner) = pult_mit_zwei_decks(); + let fehler = paar(&pult).unwrap_err(); + assert!(format!("{fehler}").contains("kein Deck läuft"), "{fehler}"); + } + + /// Laufen schon beide, ist der Übergang im Gange. Wer dann gemeint ist, + /// kann hier niemand wissen — also raten wir nicht. + #[test] + fn bei_zwei_laufenden_decks_wird_nicht_geraten() { + let (mut pult, _runner) = laufend(); + pult.schreibe(&k("deck2.play"), Wert::Schalter(true)) + .unwrap(); + let fehler = paar(&pult).unwrap_err(); + assert!(format!("{fehler}").contains("mehrere"), "{fehler}"); + } + + /// **Jeder Griff fängt auf einer Phrasengrenze an.** Das ist der Punkt, an + /// dem ein Übergang aufhört, irgendwo zu sitzen. + #[test] + fn jeder_griff_beginnt_auf_der_phrase() { + let (pult, _runner) = laufend(); + for g in Griff::ALLE { + let zeilen = + zeilen(&pult, g, g.beats()).unwrap_or_else(|e| panic!("{}: {e}", g.name())); + assert!( + zeilen + .iter() + .any(|z| z.starts_with("in deck") && z.contains(" phrase")), + "{}: keine Zeile auf der Phrase: {zeilen:?}", + g.name() + ); + } + } + + /// **Getaktet wird nach dem ausgehenden Deck, nie „nach dem ersten mit + /// Grid".** + /// + /// Ohne Angabe nimmt `in` das erste Deck mit Beatgrid — beim zweiten Griff + /// eines Sets also das gerade gestoppte. Ein stehendes Deck hält den Plan + /// an, und der ganze Übergang stand still: angenommen, eingetragen, nie + /// gefahren. Am laufenden Programm fiel das beim zweiten Griff auf, beim + /// ersten läuft zufällig das richtige Deck. + #[test] + fn keine_zeile_haengt_am_ersten_deck_mit_grid() { + let (pult, _runner) = laufend(); + for g in Griff::ALLE { + for zeile in zeilen(&pult, g, g.beats()).expect("Zeilen") { + if !zeile.starts_with("in ") { + continue; + } + assert!( + zeile.starts_with("in deck"), + "{} überlässt den Takt dem Zufall: {zeile}", + g.name() + ); + } + } + } + + /// Und jeder startet das eingehende Deck — sonst blendet man auf Stille. + /// Genau das ist beim ersten selbstgefahrenen Set passiert. + #[test] + fn jeder_griff_startet_das_eingehende_deck() { + let (pult, _runner) = laufend(); + for g in Griff::ALLE { + let zeilen = + zeilen(&pult, g, g.beats()).unwrap_or_else(|e| panic!("{}: {e}", g.name())); + assert!( + zeilen.iter().any(|z| z.contains("set deck2.play 1")), + "{}: das eingehende Deck bleibt stehen: {zeilen:?}", + g.name() + ); + } + } + + /// Der Crossfader geht auf die Seite des eingehenden Kanalzugs, nicht auf + /// die der Deck-Nummer: Welche Seite ein Zug bedient, ist einstellbar. + #[test] + fn der_crossfader_geht_zur_seite_des_eingehenden_zuges() { + let (pult, _runner) = laufend(); + let zeilen = zeilen(&pult, Griff::Blende, 32.0).unwrap(); + assert!( + zeilen.iter().any(|z| z.contains("master.crossfader 1")), + "Deck 2 liegt auf B, also nach +1: {zeilen:?}" + ); + } + + /// Der Bass-Swap ist der einzige Griff, bei dem beide gleichzeitig laut + /// laufen — daran erkennt man ihn. + #[test] + fn der_bassswap_geht_ueber_die_mitte_und_tauscht_die_baesse() { + let (pult, _runner) = laufend(); + let zeilen = zeilen(&pult, Griff::Bassswap, 16.0).unwrap(); + let text = zeilen.join("\n"); + assert!(text.contains("master.crossfader 0 "), "keine Mitte: {text}"); + assert!(text.contains("channel1.eq_low 0"), "der alte Bass bleibt"); + assert!( + text.contains("channel2.eq_low 1"), + "der neue Bass kommt nicht" + ); + // Und der Neue kommt ohne Bass herein, sonst dröhnt es in der Mitte. + assert!( + zeilen[0].starts_with("set channel2.eq_low 0"), + "der Eingehende kommt mit Bass: {:?}", + zeilen[0] + ); + } + + /// Ein Schnitt ist keine Blende: kein `ramp`, nur ein Umlegen. + /// **Jeder Griff endet damit, dass das ausgehende Deck steht.** + /// + /// Das ist die Stelle, an der aus einem Übergang ein *Set* wird. Bis dahin + /// lief der ausgehende Track weiter — unhörbar hinter dem geschlossenen + /// Crossfader, aber laufend —, und der nächste `uebergang` wurde deshalb + /// abgewiesen: „es laufen mehrere Decks". Am laufenden Programm fiel das + /// beim zweiten Griff auf, nicht beim ersten. + #[test] + fn jeder_griff_stoppt_das_ausgehende_deck() { + let (pult, _runner) = laufend(); + for griff in Griff::ALLE { + let zeilen = zeilen(&pult, griff, 16.0).expect("Zeilen"); + assert!( + zeilen.iter().any(|z| z.ends_with("set deck1.play 0")), + "{} lässt das ausgehende Deck laufen: {zeilen:?}", + griff.name() + ); + // Und zwar nicht sofort, sondern erst wenn der Wechsel durch ist — + // ein Schnitt ausgenommen, der hat keine Strecke. + let stopp = zeilen + .iter() + .find(|z| z.ends_with("set deck1.play 0")) + .unwrap(); + if griff != Griff::Schnitt { + assert!( + stopp.contains("phrase+"), + "{} stoppt zu früh: {stopp}", + griff.name() + ); + } + } + } + + /// **Keine Bewegung endet im selben Takt, in dem ihr Deck stehenbleibt.** + /// + /// Sonst bekommt sie ihren letzten Schritt nicht mehr: Der Regler steht am + /// Ziel, der Auftrag aber für immer im Plan — sichtbar für jeden zweiten + /// Bediener und nicht mehr wegzubekommen, weil das Deck nicht mehr läuft. + /// Am laufenden Programm lag er nach dem zweiten Griff dort. + #[test] + fn keine_bewegung_endet_im_takt_des_stopps() { + let (pult, _runner) = laufend(); + for griff in Griff::ALLE { + let beats = 16.0; + let zeilen = zeilen(&pult, griff, beats).expect("Zeilen"); + + let Some(stopp) = zeilen.iter().find(|z| z.ends_with(".play 0")) else { + panic!("{} stoppt nicht: {zeilen:?}", griff.name()); + }; + let bei = versatz(stopp); + + for zeile in &zeilen { + let Some(rest) = zeile.split(" ramp ").nth(1) else { + continue; + }; + // `ramp …` + let laenge: f64 = rest.split_whitespace().nth(2).unwrap().parse().unwrap(); + let ende = versatz(zeile) + laenge; + assert!( + ende + 1e-9 < bei, + "{}: Rampe endet bei {ende}, gestoppt wird bei {bei} — {zeile}", + griff.name() + ); + } + } + } + + /// Liest das `phrase+n` einer Zeile; ohne Versatz null. + fn versatz(zeile: &str) -> f64 { + match zeile.split("phrase+").nth(1) { + Some(rest) => rest + .split_whitespace() + .next() + .and_then(|z| z.parse().ok()) + .unwrap_or(0.0), + None => 0.0, + } + } + + /// **Der einzige Griff, der dem Ausgehenden Zeit gibt.** + /// + /// Die Schleife liegt genau so lang wie die Blende darüber: Der Ausgehende + /// läuft sie einmal durch, und wenn sie herum ist, ist der Wechsel fertig. + /// Eine Schleife, die länger steht als der Übergang, wiederholt hörbar — + /// und das ist der Fehler, den dieser Griff gerade vermeiden soll. + #[test] + fn die_schleife_haelt_den_ausgehenden_genau_solange_wie_der_wechsel_dauert() { + let (pult, _runner) = laufend(); + let zeilen = zeilen(&pult, Griff::Schleife, 16.0).expect("Zeilen"); + + assert!( + zeilen + .iter() + .any(|z| z == "in deck1 phrase set deck1.loop_beats 16"), + "die Schleife wird nicht gesetzt: {zeilen:?}" + ); + assert!( + zeilen + .iter() + .any(|z| z == "in deck2 phrase+16 set deck1.loop_active 0"), + "die Schleife wird nicht gelöst: {zeilen:?}" + ); + // Gesetzt wird sie auf dem ausgehenden Deck — auf dem eingehenden wäre + // sie das Gegenteil von Zeit gewinnen. + assert!( + !zeilen.iter().any(|z| z.contains("deck2.loop")), + "die Schleife liegt auf dem falschen Deck: {zeilen:?}" + ); + // **Alles nach dem Setzen hängt am eingehenden Deck.** Der Beat des + // schleifenden wiederholt sich; daran getaktet fängt die Blende bei + // jedem Durchlauf von vorn an, und die Auflösung kommt nie. + for zeile in &zeilen { + if zeile.contains("loop_beats") || zeile.contains("play 1") { + continue; + } + assert!( + zeile.starts_with("in deck2 "), + "hängt am schleifenden Deck: {zeile}" + ); + } + } + + /// Eine gesetzte und nie gelöste Schleife ist beim nächsten Griff eine + /// Überraschung — und zwar eine, die man erst hört, wenn es zu spät ist. + #[test] + fn jede_gesetzte_schleife_wird_auch_wieder_geloest() { + let (pult, _runner) = laufend(); + for griff in Griff::ALLE { + let zeilen = zeilen(&pult, griff, 16.0).expect("Zeilen"); + let gesetzt = zeilen.iter().any(|z| z.contains(".loop_beats")); + let geloest = zeilen.iter().any(|z| z.contains(".loop_active 0")); + assert_eq!( + gesetzt, + geloest, + "{}: gesetzt {gesetzt}, gelöst {geloest} — {zeilen:?}", + griff.name() + ); + } + } + + #[test] + fn der_schnitt_rampt_nicht() { + let (pult, _runner) = laufend(); + let zeilen = zeilen(&pult, Griff::Schnitt, 0.0).unwrap(); + assert!( + !zeilen.iter().any(|z| z.contains("ramp")), + "ein Schnitt mit Rampe ist kein Schnitt: {zeilen:?}" + ); + assert!(zeilen.iter().any(|z| z.contains("set master.crossfader"))); + } + + #[test] + fn jeder_griff_ueberlebt_seinen_namen() { + for g in Griff::ALLE { + assert_eq!(Griff::parse(g.name()), Some(g)); + assert!(!g.beschreibung().is_empty()); + } + assert_eq!(Griff::parse("wumms"), None); + } +} diff --git a/crates/control/src/vielfalt.rs b/crates/control/src/vielfalt.rs new file mode 100644 index 0000000..b90e9d3 --- /dev/null +++ b/crates/control/src/vielfalt.rs @@ -0,0 +1,251 @@ +//! Zurückhaltung: ob immer dasselbe gefahren wird. +//! +//! Das Repertoire aus fünf Griffen macht Abwechslung **möglich**. Es erzwingt +//! sie nicht — und ein System, das viermal hintereinander dieselbe Blende +//! wählt, klingt weiterhin nach Automat, auch wenn jede einzelne sauber ist. +//! Genau das war der erste selbstgefahrene Übergang, und der Vorwurf daran +//! („ein bisschen herzlos") ist nie mit einer Zahl beantwortet worden. +//! +//! Hier steht sie: wie viele der letzten Übergänge gleich waren. +//! +//! # Was als Übergang zählt +//! +//! **Ein Übergang ist vorbei, wenn der Crossfader die Seite wechselt.** Das ist +//! die einzige Stelle, die sich ohne Raterei bestimmen lässt, und sie zählt +//! genau einmal: Ein Bass-Swap fährt zweimal am Crossfader (erst in die Mitte, +//! dann hinüber), eine Blende in vielen kleinen Schritten, ein Schnitt in einem +//! Sprung — angekommen wird bei allen dreien einmal. +//! +//! Nicht der Wert entscheidet, sondern der Wechsel: Wer `set master.crossfader +//! 1` dreimal schickt, hat einmal übergeblendet, nicht dreimal. +//! +//! **Und es müssen zwei Decks laufen.** Wer vor dem Set den Fader auf die Seite +//! des einzigen laufenden Decks stellt, richtet ein — da ist nichts, wovon oder +//! wohin überzublenden wäre. Das stand hier zunächst falsch und fiel erst am +//! laufenden Programm auf: Nach vier Zeilen Einrichten meldete die Anlage +//! bereits einen Übergang. Alle fünf Griffe starten das eingehende Deck, bevor +//! der Fader sich bewegt; zwei laufende Decks sind also kein Sonderfall, +//! sondern das Kennzeichen. +//! +//! Damit ist auch gezählt, was **von Hand** zusammengesetzt wurde und nicht aus +//! dem Repertoire kam. Das ist wichtig: Der erste automatisch gefahrene +//! Übergang bestand aus sieben `when`-Zeilen und keinem einzigen `uebergang`. +//! +//! **Zwei Grenzen sind echt und stehen hier:** Wer nur mit den Kanalfadern +//! mischt und den Crossfader stehen lässt, taucht nicht auf. Und wenn der +//! ausgehende Track ausläuft, bevor die Blende drüben ankommt, fehlt sie — +//! dann lief zuletzt nur noch ein Deck. Beides kommt vor; dann sagt diese Zahl +//! nichts, statt etwas Falsches zu sagen. + +use std::collections::VecDeque; + +/// Wie viele Übergänge aufgehoben werden. +/// +/// Acht sind eine gute halbe Stunde Set — lang genug, um ein Muster zu sehen, +/// kurz genug, dass ein Bediener die Liste noch überblickt. +pub const PLATZ: usize = 8; + +/// Ab welcher Crossfader-Stellung er „drüben" ist. +/// +/// Nicht exakt 1: Wer über MCP `0.98` schickt, meint dasselbe, und eine +/// Zählung, die an der zweiten Nachkommastelle scheitert, wäre eine Falle. +pub const ANGEKOMMEN: f64 = 0.9; + +/// Auf welcher Seite der Crossfader steht: -1 links, 1 rechts, 0 dazwischen. +/// +/// Der Wechsel dieser Zahl ist der Übergang — nicht der Wert selbst. Ein Sprung +/// von ganz links nach ganz rechts ist einer, obwohl der Betrag sich nie +/// geändert hat; dreimal derselbe Befehl ist keiner. +pub fn seite(v: f64) -> i8 { + if v >= ANGEKOMMEN { + 1 + } else if v <= -ANGEKOMMEN { + -1 + } else { + 0 + } +} + +/// Was zuletzt gefahren wurde. +#[derive(Debug, Default, Clone)] +pub struct Vielfalt { + letzte: VecDeque, + /// Der Name eines angeforderten Griffs, bis der Crossfader ankommt. + /// + /// Ohne den stünde von einem Bass-Swap nur die Form seiner letzten Rampe in + /// der Erinnerung — dieselbe wie bei einer Blende. Zwei verschiedene Griffe + /// sähen dann aus wie eine Wiederholung. + vorgemerkt: Option, +} + +impl Vielfalt { + pub fn neu() -> Vielfalt { + Vielfalt::default() + } + + /// Trägt einen Übergang ein. + /// + /// `art` ist seine Kennung: der Name des Griffs, wenn er aus dem Repertoire + /// kam, sonst Form und Länge der Bewegung. + pub fn merken(&mut self, art: &str) { + let art = art.trim(); + if art.is_empty() { + return; + } + self.letzte.push_back(art.to_string()); + while self.letzte.len() > PLATZ { + self.letzte.pop_front(); + } + } + + /// Merkt vor, welcher Griff gerade angefordert wurde. + /// + /// Eingetragen wird er erst, wenn der Crossfader ankommt — ein Griff, den + /// jemand plant und dann abbricht, hat nicht stattgefunden. + pub fn vormerken(&mut self, griff: &str) { + self.vorgemerkt = Some(griff.trim().to_string()); + } + + /// Der Crossfader ist auf einer Seite angekommen. + /// + /// `wie` beschreibt die Bewegung, die ihn dorthin gebracht hat — Form und + /// Länge einer Rampe, oder `schnitt` für einen Sprung. Ein vorgemerkter + /// Griff hat Vorrang, weil sein Name mehr sagt. + pub fn angekommen(&mut self, wie: &str) { + match self.vorgemerkt.take() { + Some(griff) if !griff.is_empty() => self.merken(&griff), + _ => self.merken(wie), + } + } + + /// Wie viele der letzten Übergänge hintereinander gleich waren. + /// + /// 0 heißt: noch keiner. 1 heißt: der letzte war anders als der davor — + /// also alles in Ordnung. Ab 3 wiederholt sich jemand hörbar. + pub fn wiederholung(&self) -> usize { + let Some(letzter) = self.letzte.back() else { + return 0; + }; + self.letzte + .iter() + .rev() + .take_while(|a| *a == letzter) + .count() + } + + /// Wie viele verschiedene Arten in der Erinnerung stehen. + pub fn arten(&self) -> usize { + let mut gesehen: Vec<&String> = Vec::new(); + for a in &self.letzte { + if !gesehen.contains(&a) { + gesehen.push(a); + } + } + gesehen.len() + } + + pub fn ist_leer(&self) -> bool { + self.letzte.is_empty() + } + + /// Die letzten Übergänge, ältester zuerst. + pub fn text(&self) -> String { + self.letzte.iter().cloned().collect::>().join(", ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mit(arten: &[&str]) -> Vielfalt { + let mut v = Vielfalt::neu(); + for a in arten { + v.merken(a); + } + v + } + + /// Die Zahl, um die es geht. + #[test] + fn wiederholung_zaehlt_die_gleichen_am_ende() { + assert_eq!(mit(&[]).wiederholung(), 0); + assert_eq!(mit(&["blende"]).wiederholung(), 1); + assert_eq!(mit(&["blende", "blende", "blende"]).wiederholung(), 3); + // Etwas anderes dazwischen bricht die Serie. + assert_eq!(mit(&["blende", "blende", "schnitt"]).wiederholung(), 1); + // Und was davor war, zählt nicht mehr mit. + assert_eq!( + mit(&["blende", "blende", "schnitt", "schnitt"]).wiederholung(), + 2 + ); + } + + #[test] + fn die_arten_zaehlen_das_verschiedene() { + assert_eq!(mit(&[]).arten(), 0); + assert_eq!(mit(&["blende", "blende", "blende"]).arten(), 1); + assert_eq!(mit(&["blende", "schnitt", "blende"]).arten(), 2); + } + + #[test] + fn die_erinnerung_waechst_nicht_unbegrenzt() { + let mut v = Vielfalt::neu(); + for i in 0..100 { + v.merken(&format!("art{i}")); + } + assert_eq!(v.letzte.len(), PLATZ); + assert!(v.text().starts_with("art92"), "{}", v.text()); + } + + #[test] + fn leeres_wird_nicht_gemerkt() { + let mut v = Vielfalt::neu(); + v.merken(" "); + assert!(v.ist_leer()); + assert_eq!(v.wiederholung(), 0); + } + + /// Der Wechsel zählt, nicht der Wert — sonst wäre jede Blende acht + /// Übergänge, weil eine Rampe achtmal schreibt. + #[test] + fn die_seite_unterscheidet_ankunft_von_bewegung() { + assert_eq!(seite(1.0), 1); + assert_eq!(seite(0.95), 1); + assert_eq!(seite(-1.0), -1); + assert_eq!(seite(0.0), 0); + assert_eq!(seite(0.89), 0); + // Von ganz links nach ganz rechts ist ein Wechsel, obwohl der Betrag + // gleich bleibt. + assert_ne!(seite(-1.0), seite(1.0)); + } + + #[test] + fn ein_vorgemerkter_griff_hat_vorrang() { + let mut v = Vielfalt::neu(); + v.vormerken("bassswap"); + v.angekommen("weich/32"); + assert_eq!(v.text(), "bassswap"); + // Und er gilt nur einmal. + v.angekommen("weich/32"); + assert_eq!(v.text(), "bassswap, weich/32"); + } + + /// Ein Griff, der geplant und dann nicht gefahren wird, hat nicht + /// stattgefunden — er darf die Zurückhaltung nicht beschönigen. + #[test] + fn ein_vorgemerkter_griff_ohne_ankunft_zaehlt_nicht() { + let mut v = Vielfalt::neu(); + v.vormerken("filter"); + assert!(v.ist_leer()); + assert_eq!(v.wiederholung(), 0); + } + + #[test] + fn der_text_zeigt_das_muster() { + assert_eq!( + mit(&["blende", "blende", "schnitt"]).text(), + "blende, blende, schnitt" + ); + } +} diff --git a/crates/control/src/warteschlange.rs b/crates/control/src/warteschlange.rs new file mode 100644 index 0000000..771d01b --- /dev/null +++ b/crates/control/src/warteschlange.rs @@ -0,0 +1,193 @@ +//! Was als Nächstes kommt — das gemeinsame Blatt für die Auswahl. +//! +//! **Warum das nicht jeder Bediener für sich führen kann.** Sobald mehr als +//! einer auswählt, ist „was kommt als Nächstes" eine Frage an die Anlage und +//! nicht an einen Kopf. Zwei Agenten, die ihre Liste je für sich halten, legen +//! irgendwann beide auf dasselbe Deck; einer verliert, und niemand merkt es, +//! bis der falsche Track läuft. +//! +//! Deshalb liegt die Liste hier, im Pult, hinter demselben Mutex wie alles +//! andere. Wer abnimmt, nimmt den Eintrag heraus — ein zweiter, der im selben +//! Moment abnimmt, bekommt den nächsten und nicht denselben. +//! +//! **Jeder Eintrag trägt eine Notiz.** Das ist der Unterschied zu einer +//! Playlist: Wer einen Track vormerkt, weiß, warum — harmonisch passend, mehr +//! Druck, Ruhepunkt nach dem Peak. Ohne die Notiz muss der Nächste den Grund +//! aus BPM und Tonart erraten, und bei einem Team von Agenten heißt das: Er +//! erfindet ihn. + +/// Ein vorgemerkter Track. +#[derive(Debug, Clone, PartialEq)] +pub struct Eintrag { + /// Nummer, unter der sich der Eintrag ansprechen lässt. Bleibt vergeben, + /// auch wenn davor etwas herausgenommen wird — anders als die Position. + pub id: u64, + pub pfad: String, + /// Warum er hier steht. Leer erlaubt, aber schade. + pub notiz: String, +} + +#[derive(Default)] +pub struct Warteschlange { + eintraege: Vec, + naechste_id: u64, +} + +impl Warteschlange { + pub fn neu() -> Warteschlange { + Warteschlange::default() + } + + pub fn eintraege(&self) -> &[Eintrag] { + &self.eintraege + } + + pub fn ist_leer(&self) -> bool { + self.eintraege.is_empty() + } + + /// Hängt hinten an. + /// + /// `Err(nr)` heißt: Der Pfad steht schon unter dieser Nummer. Das ist kein + /// Formfehler, sondern der häufigste Zusammenstoß, wenn zwei unabhängig + /// voneinander auswählen — beide finden denselben passenden Track. Ihn + /// stillschweigend zweimal aufzunehmen hieße, ihn auch zweimal zu spielen. + pub fn anhaengen(&mut self, pfad: String, notiz: String) -> Result { + if let Some(schon) = self.eintraege.iter().find(|e| e.pfad == pfad) { + return Err(schon.id); + } + self.naechste_id += 1; + self.eintraege.push(Eintrag { + id: self.naechste_id, + pfad, + notiz, + }); + Ok(self.naechste_id) + } + + /// Nimmt den vordersten heraus. + pub fn abnehmen(&mut self) -> Option { + if self.eintraege.is_empty() { + return None; + } + Some(self.eintraege.remove(0)) + } + + /// Legt einen abgenommenen Eintrag wieder nach vorn. + /// + /// Für den Fall, dass das Auflegen scheitert. Ein Eintrag, der aus der + /// Liste verschwindet, ohne gespielt zu werden, fällt niemandem auf — bis + /// er fehlt. + pub fn zuruecklegen(&mut self, eintrag: Eintrag) { + self.naechste_id = self.naechste_id.max(eintrag.id); + self.eintraege.insert(0, eintrag); + } + + pub fn streichen(&mut self, id: u64) -> Option { + let stelle = self.eintraege.iter().position(|e| e.id == id)?; + Some(self.eintraege.remove(stelle)) + } + + /// Schreibt die Notiz neu — auch, um eine Einschätzung zu widerrufen. + pub fn notieren(&mut self, id: u64, notiz: String) -> bool { + match self.eintraege.iter_mut().find(|e| e.id == id) { + Some(e) => { + e.notiz = notiz; + true + } + None => false, + } + } + + /// Zieht einen Eintrag nach vorn — „der als Nächstes". + pub fn vorziehen(&mut self, id: u64) -> bool { + match self.eintraege.iter().position(|e| e.id == id) { + Some(stelle) => { + let e = self.eintraege.remove(stelle); + self.eintraege.insert(0, e); + true + } + None => false, + } + } + + /// Leert die Liste und sagt, wie viele es waren. + pub fn leeren(&mut self) -> usize { + let weg = self.eintraege.len(); + self.eintraege.clear(); + weg + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wl() -> Warteschlange { + let mut w = Warteschlange::neu(); + w.anhaengen("/a.mp3".into(), "warm".into()).unwrap(); + w.anhaengen("/b.mp3".into(), String::new()).unwrap(); + w.anhaengen("/c.mp3".into(), "Peak".into()).unwrap(); + w + } + + #[test] + fn abgenommen_wird_von_vorn() { + let mut w = wl(); + assert_eq!(w.abnehmen().unwrap().pfad, "/a.mp3"); + assert_eq!(w.abnehmen().unwrap().pfad, "/b.mp3"); + assert_eq!(w.eintraege().len(), 1); + } + + /// Der häufigste Zusammenstoß zweier Auswählender. + /// + /// Beide suchen, was zu 128 BPM in 8A passt, beide finden denselben Track. + /// Ihn zweimal aufzunehmen hieße, ihn zweimal zu spielen. + #[test] + fn derselbe_track_kommt_nicht_zweimal_hinein() { + let mut w = wl(); + assert_eq!(w.anhaengen("/b.mp3".into(), "passt".into()), Err(2)); + assert_eq!(w.eintraege().len(), 3); + + // Nach dem Abnehmen ist der Platz wieder frei — ein Track darf später + // erneut vorgemerkt werden. + while w.abnehmen().is_some() {} + assert!(w.anhaengen("/b.mp3".into(), String::new()).is_ok()); + } + + #[test] + fn nummern_bleiben_stehen_wenn_davor_etwas_verschwindet() { + // Sonst spräche ein Agent, der sich Nummer 3 gemerkt hat, plötzlich + // über einen anderen Track. + let mut w = wl(); + w.abnehmen(); + let dritter = w.eintraege().iter().find(|e| e.id == 3).expect("Nummer 3"); + assert_eq!(dritter.pfad, "/c.mp3"); + } + + #[test] + fn vorziehen_macht_einen_zum_naechsten() { + let mut w = wl(); + assert!(w.vorziehen(3)); + assert_eq!(w.abnehmen().unwrap().pfad, "/c.mp3"); + // Die Reihenfolge dahinter bleibt, wie sie war. + assert_eq!(w.abnehmen().unwrap().pfad, "/a.mp3"); + } + + #[test] + fn eine_notiz_laesst_sich_nachtragen_und_widerrufen() { + let mut w = wl(); + assert!(w.notieren(2, "doch zu ruhig".into())); + assert_eq!(w.eintraege()[1].notiz, "doch zu ruhig"); + assert!(!w.notieren(99, "gibt es nicht".into())); + } + + #[test] + fn streichen_und_leeren_melden_was_sie_getan_haben() { + let mut w = wl(); + assert_eq!(w.streichen(2).unwrap().pfad, "/b.mp3"); + assert!(w.streichen(2).is_none()); + assert_eq!(w.leeren(), 2); + assert!(w.ist_leer()); + } +} diff --git a/crates/control/src/wert.rs b/crates/control/src/wert.rs new file mode 100644 index 0000000..23641ae --- /dev/null +++ b/crates/control/src/wert.rs @@ -0,0 +1,146 @@ +//! Werte, die über das Steuerpult laufen. +//! +//! Bewusst **typisiert** statt alles als Fließkommazahl. Mixxx macht jedes +//! Control zu einem `double` — `play` ist dort 0.0 oder 1.0, die +//! Crossfader-Zuweisung eine durchnummerierte Zahl. Das ist einfach zu bauen +//! und schwer zu benutzen: Wer von außen steuert, muss wissen, dass 2.0 bei +//! `orientation` „rechts" heißt, und ein Tippfehler wird zu einem gültigen +//! Wert statt zu einem Fehler. +//! +//! Hier ist ein Schalter ein Schalter, eine Auswahl trägt ihre erlaubten Namen +//! und eine Zahl ihren Bereich. Falsches lässt sich damit zurückweisen, statt +//! es stumm zu übernehmen. + +use std::fmt; + +#[derive(Debug, Clone, PartialEq)] +pub enum Wert { + Schalter(bool), + Zahl(f64), + Auswahl(String), + Text(String), + /// Ein Wert, den es gerade nicht gibt — ein ungesetzter Hot Cue, das Tempo + /// eines Decks ohne Beatgrid. + Leer, +} + +impl Wert { + pub fn als_zahl(&self) -> Option { + match self { + Wert::Zahl(v) => Some(*v), + Wert::Schalter(b) => Some(if *b { 1.0 } else { 0.0 }), + _ => None, + } + } + + pub fn als_schalter(&self) -> Option { + match self { + Wert::Schalter(b) => Some(*b), + Wert::Zahl(v) => Some(*v >= 0.5), + _ => None, + } + } + + pub fn als_text(&self) -> Option<&str> { + match self { + Wert::Text(s) | Wert::Auswahl(s) => Some(s), + _ => None, + } + } +} + +impl fmt::Display for Wert { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Wert::Schalter(true) => f.write_str("1"), + Wert::Schalter(false) => f.write_str("0"), + // Kurz halten, aber nichts verschlucken: Positionen brauchen + // Millisekunden-Auflösung, Verstärkungen nicht. + Wert::Zahl(v) if v.fract() == 0.0 && v.abs() < 1e15 => write!(f, "{v:.0}"), + Wert::Zahl(v) => write!(f, "{v:.6}"), + Wert::Auswahl(s) | Wert::Text(s) => f.write_str(s), + Wert::Leer => f.write_str("-"), + } + } +} + +/// Was für ein Wert an einem Control hängt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Art { + Schalter, + Zahl, + Auswahl, + Text, + /// Ein Auslöser, kein Wert: `sync`, `load`, einen Hot Cue anspringen. + /// + /// Mixxx modelliert so etwas als Control, in das man 1.0 schreibt + /// (`[Channel1],cue_gotoandplay`). Das funktioniert, ist aber genau die + /// Schwäche, die wir vermeiden wollten: Ein Auslöser hat keinen Zustand, + /// den man lesen könnte, und `get` darauf müsste etwas erfinden. Hier ist + /// er ein eigener Typ mit einem eigenen Verb im Protokoll. + Aktion, +} + +impl Art { + pub fn name(&self) -> &'static str { + match self { + Art::Schalter => "schalter", + Art::Zahl => "zahl", + Art::Auswahl => "auswahl", + Art::Text => "text", + Art::Aktion => "aktion", + } + } +} + +/// Wofür die Zahl steht. Ohne das ist „0.8" nicht interpretierbar. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Einheit { + Keine, + /// 1.0 = unverändert. Tempo, Verstärkung, EQ. + Faktor, + Sekunden, + Beats, + Bpm, + /// −1 bis +1, Mitte 0. Crossfader, Filter. + Bipolar, +} + +impl Einheit { + pub fn name(&self) -> &'static str { + match self { + Einheit::Keine => "-", + Einheit::Faktor => "faktor", + Einheit::Sekunden => "s", + Einheit::Beats => "beats", + Einheit::Bpm => "bpm", + Einheit::Bipolar => "bipolar", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schalter_werden_als_null_und_eins_geschrieben() { + assert_eq!(Wert::Schalter(true).to_string(), "1"); + assert_eq!(Wert::Schalter(false).to_string(), "0"); + } + + #[test] + fn zahlen_verlieren_beim_schreiben_keine_aufloesung() { + // Eine Position auf die zweite Nachkommastelle zu runden wäre bei + // 44 100 Hz ein Fehler von hunderten Samples. + assert_eq!(Wert::Zahl(12.345678).to_string(), "12.345678"); + assert_eq!(Wert::Zahl(4.0).to_string(), "4"); + } + + #[test] + fn ein_leerer_wert_ist_kein_null() { + // Ein ungesetzter Hot Cue liegt nicht auf Sekunde 0. + assert_eq!(Wert::Leer.to_string(), "-"); + assert_eq!(Wert::Leer.als_zahl(), None); + } +} diff --git a/crates/control/src/zeitplan.rs b/crates/control/src/zeitplan.rs new file mode 100644 index 0000000..cfbe07d --- /dev/null +++ b/crates/control/src/zeitplan.rs @@ -0,0 +1,1411 @@ +//! Der Zeitplan: Was später geschehen soll, und über wie viele Beats. +//! +//! **Warum das nicht der Aufrufer selbst machen kann.** Ein Übergang ist keine +//! Folge von Reglerstellungen, sondern eine Bewegung über Takte: „Bass raus +//! über acht Beats, dann Crossfader rüber über sechzehn". Wer das von außen +//! nachbaut, müsste in einer engen Schleife `beat_phase` pollen und zwischen +//! den Schritten schlafen — über einen Socket, dessen Timing dem Scheduler des +//! Betriebssystems ausgeliefert ist. Das Ergebnis eiert hörbar, und der Agent +//! ist die ganze Zeit blockiert. +//! +//! Hier drin läuft es auf dem **Beatgrid** statt auf der Uhr. Ein Plan, der in +//! Sekunden rechnet, geht schief, sobald jemand am Tempo dreht; einer, der in +//! Beats rechnet, bleibt musikalisch richtig. Steht das Deck, steht auch der +//! Plan — was gestoppt ist, hat keine Takte. +//! +//! Neben „in so vielen Beats" gibt es „sobald es so weit ist": Ein [`Was::Wenn`] +//! hängt nicht an Takten, sondern an einem Wert — „wenn Deck A noch 32 Beats +//! hat, leg den nächsten auf". Ohne das müsste ein Bediener `beats_left` +//! abonnieren und bekäme zwanzig Zahlen je Sekunde, aus denen er die eine +//! Schwelle selbst heraussucht. Über eine Chat-Schnittstelle ist das nicht nur +//! unbequem, sondern unbezahlbar. +//! +//! **Für ein Team von Agenten ist der Plan zugleich das gemeinsame Blatt.** +//! Wer `plan` liest, sieht, was die anderen vorhaben, statt es aus +//! Reglerbewegungen zu erraten. Und eine Rampe gibt auf, sobald jemand anders +//! denselben Regler anfasst — siehe [`Rampe`]. + +use std::sync::{Arc, Mutex}; + +use crate::pult::{Fehler, Steuerpult}; +use crate::schluessel::{Gruppe, Schluessel}; +use crate::wert::Wert; + +/// Wie weit ein Reglerwert von dem abweichen darf, was die Rampe zuletzt +/// geschrieben hat, bevor sie sich für abgelöst hält. +/// +/// Reglerwerte laufen als `f32` durch die Kommandoschlange und kommen als +/// `f64` zurück; ein exakter Vergleich fände deshalb ständig Unterschiede, die +/// keine sind. Alles darüber ist ein fremder Eingriff. +const ABGELOEST: f64 = 1e-4; + +/// Ein Auftrag im Plan. +#[derive(Debug, Clone)] +pub struct Auftrag { + pub id: u64, + /// Auf wessen Takten der Auftrag liegt. + /// + /// Bei einem [`Was::Wenn`] ohne Bedeutung — das hängt an einem Wert und + /// nicht an Takten. + pub takt_deck: usize, + /// Beat des Taktgeber-Decks, ab dem gehandelt wird. + pub ab_beat: f64, + /// Der zuletzt gesehene Beat des Taktgebers. + /// + /// Nur, um einen **Rücksprung** zu bemerken: Läuft das Deck in einer + /// Schleife oder springt jemand zurück, zählt sein Beat wieder von vorn, + /// und dann ist alles falsch, was daran hängt — eine Rampe fängt von vorn + /// an und wiederholt sich endlos, ein Vorgemerktes kommt nie an. Beides + /// hat kein Test gezeigt, sondern ein Blick auf den Crossfader, der sieben + /// Mal hin und her fuhr. + pub zuletzt_beat: f64, + pub was: Was, +} + +/// Wie weit der Beat zurückgehen darf, bevor es ein Sprung ist. +/// +/// Ein halber Beat: Die Position wird aus der Frame-Zahl gerechnet und +/// schwankt um Bruchteile, aber sie geht nicht rückwärts. Alles darüber ist +/// eine Schleife oder ein Sprung. +pub const RUECKSPRUNG: f64 = 0.5; + +#[derive(Debug, Clone)] +pub enum Was { + /// Ein Reglerwert, der über mehrere Beats wandert. + Rampe(Rampe), + /// Eine fertige Protokollzeile, die später ausgeführt wird. + Spaeter { beim_beat: f64, zeile: String }, + /// Dieselbe Zeile, aber ausgelöst von einem Wert statt von der Zeit. + Wenn { + control: Schluessel, + vergleich: Vergleich, + schwelle: f64, + zeile: String, + }, +} + +/// In welche Richtung eine Schwelle überschritten wird. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Vergleich { + Unter, + Ueber, +} + +impl Vergleich { + pub fn parse(text: &str) -> Option { + match text { + "<" => Some(Vergleich::Unter), + ">" => Some(Vergleich::Ueber), + _ => None, + } + } + + pub fn zeichen(&self) -> &'static str { + match self { + Vergleich::Unter => "<", + Vergleich::Ueber => ">", + } + } + + fn trifft_zu(&self, wert: f64, schwelle: f64) -> bool { + match self { + Vergleich::Unter => wert < schwelle, + Vergleich::Ueber => wert > schwelle, + } + } +} + +/// Wie sich eine Rampe über ihre Strecke verteilt. +/// +/// **Dieselbe Strecke, andere Wirkung.** Ein Fader, der gleichmäßig wandert, +/// klingt nach Automat; einer, der anfängt zu ziehen, durchzieht und wieder +/// abflacht, klingt nach einer Hand. Der Weg ist in beiden Fällen derselbe — +/// nur die Verteilung über die Beats ist eine andere, und die hört man. +/// +/// Die Namen sagen, **wann** die Bewegung passiert, nicht welche Kurve +/// dahintersteckt: Danach fragt man beim Auflegen. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Form { + /// Gleichmäßig. Ehrlich, unauffällig, für Bass-Swaps und alles, was nicht + /// als Geste gemeint ist. + #[default] + Linear, + /// Langsam los, durchziehen, weich ankommen — die S-Kurve. + /// + /// Für lange Blenden die Voreinstellung von Hand: Der Anfang bleibt + /// unauffällig, die Mitte ist entschieden, das Ende schleicht sich nicht + /// an, sondern legt sich hin. + Weich, + /// Lange fast nichts, dann schnell. + /// + /// Hält den ausgehenden Track so lange wie möglich präsent. Wer ein Outro + /// auskosten will und trotzdem pünktlich fertig sein muss, nimmt das. + Spaet, + /// Sofort viel, dann auslaufen. + /// + /// Bringt den eingehenden Track schnell in den Raum und lässt den Rest + /// nachlaufen — für einen Wechsel, der als Ereignis gemeint ist. + Frueh, +} + +impl Form { + pub fn name(&self) -> &'static str { + match self { + Form::Linear => "linear", + Form::Weich => "weich", + Form::Spaet => "spaet", + Form::Frueh => "frueh", + } + } + + pub fn parse(text: &str) -> Option
{ + match text { + "linear" => Some(Form::Linear), + "weich" => Some(Form::Weich), + "spaet" => Some(Form::Spaet), + "frueh" => Some(Form::Frueh), + _ => None, + } + } + + pub const ALLE: [Form; 4] = [Form::Linear, Form::Weich, Form::Spaet, Form::Frueh]; + + /// Bildet den Anteil der Strecke auf den Anteil des Weges ab. + /// + /// Beide zwischen 0 und 1, und **jede Form muss 0 auf 0 und 1 auf 1 + /// abbilden**: Sonst spränge der Regler beim Anfang oder käme nicht an. + pub fn anwenden(&self, t: f64) -> f64 { + let t = t.clamp(0.0, 1.0); + match self { + Form::Linear => t, + // Smoothstep. Die Ableitung ist an beiden Enden null — deshalb + // kein hörbarer Ruck beim Losfahren und keiner beim Ankommen. + Form::Weich => t * t * (3.0 - 2.0 * t), + Form::Spaet => t * t, + Form::Frueh => 1.0 - (1.0 - t) * (1.0 - t), + } + } +} + +/// Eine Bewegung eines Reglers über Beats. +/// +/// **Sie gibt auf, sobald jemand anders denselben Regler anfasst.** Ohne das +/// wäre die Rampe stärker als der Mensch daneben: Er zieht den Fader zu, und +/// eine halbe Sekunde später steht er wieder offen. Geprüft wird nicht über +/// eine Kennung, sondern über den Wert selbst — steht dort etwas anderes, als +/// die Rampe zuletzt geschrieben hat, war jemand anders am Werk. Das gilt für +/// einen Menschen an der Oberfläche genauso wie für einen zweiten Agenten. +#[derive(Debug, Clone)] +pub struct Rampe { + pub control: Schluessel, + pub von: f64, + pub nach: f64, + /// Länge in Beats des Taktgeber-Decks. + pub beats: f64, + /// Was zuletzt geschrieben wurde — der Prüfstein für fremde Eingriffe. + pub zuletzt: f64, + /// Wie sich die Bewegung über die Beats verteilt. + pub form: Form, +} + +/// Was mit einem Auftrag beim Takt geschah — nur für die Meldung nach außen. +#[derive(Debug, PartialEq)] +pub enum Ausgang { + Laeuft, + Fertig, + /// Jemand anders hat den Regler angefasst. + Abgeloest, + /// Der Taktgeber hat kein Grid mehr, oder das Deck ist weg. + Haltlos, +} + +#[derive(Default)] +pub struct Zeitplan { + auftraege: Vec, + naechste_id: u64, +} + +impl Zeitplan { + pub fn neu() -> Zeitplan { + Zeitplan::default() + } + + pub fn auftraege(&self) -> &[Auftrag] { + &self.auftraege + } + + pub fn ist_leer(&self) -> bool { + self.auftraege.is_empty() + } + + fn aufnehmen(&mut self, takt_deck: usize, ab_beat: f64, was: Was) -> u64 { + self.naechste_id += 1; + self.auftraege.push(Auftrag { + id: self.naechste_id, + takt_deck, + ab_beat, + zuletzt_beat: ab_beat, + was, + }); + self.naechste_id + } + + /// Übernimmt den Zählerstand eines anderen Plans. + /// + /// Der Taktgeber nimmt den Plan aus dem Pult heraus, arbeitet ihn ab und + /// legt ihn zurück. Was währenddessen neu vorgemerkt wird — ein `in 16 + /// ramp …`, das gerade fällig geworden ist —, landet im leeren Plan, der + /// solange im Pult liegt, und finge dort wieder bei 1 an. Damit trüge ein + /// frischer Auftrag dieselbe Nummer wie ein noch laufender, und `cancel 1` + /// träfe beide. + pub fn zaehler_von(&mut self, anderer: &Zeitplan) { + self.naechste_id = self.naechste_id.max(anderer.naechste_id); + } + + /// Nimmt einen fertigen Auftrag auf — für den Taktgeber, der den Plan + /// kurzzeitig aus dem Pult nimmt und zurücklegt. + pub fn uebernehmen(&mut self, auftrag: Auftrag) { + self.naechste_id = self.naechste_id.max(auftrag.id); + self.auftraege.push(auftrag); + } + + /// Nimmt einen Auftrag zurück. `None` löscht alle. + /// + /// Ein Agent muss zurücknehmen können, was er vorgemerkt hat — sonst wäre + /// jeder Plan eine Einbahnstraße, und bei mehreren Bedienern erst recht. + pub fn streichen(&mut self, id: Option) -> usize { + let vorher = self.auftraege.len(); + match id { + Some(id) => self.auftraege.retain(|a| a.id != id), + None => self.auftraege.clear(), + } + vorher - self.auftraege.len() + } +} + +/// Wo ein Deck gerade auf seinem Beatgrid steht. +/// +/// `None`, wenn es kein Grid hat — dann gibt es keine Takte, auf die sich ein +/// Plan beziehen könnte. +pub fn beat_jetzt(pult: &Steuerpult, deck: usize) -> Option { + let d = pult.decks().get(deck)?; + let grid = d.state.grid()?; + Some(grid.beat_at(d.state.position_frames() as f64, d.sample_rate)) +} + +/// Welches Deck den Takt für ein Control vorgibt. +/// +/// Ein Kanalzug erbt ihn vom Deck, das auf ihm liegt; ein Deck ist sein +/// eigener Taktgeber. Für die Summe gibt es keine natürliche Antwort — dort +/// zählt das erste Deck mit Grid, und wer etwas anderes will, sagt es dazu. +pub fn taktgeber(pult: &Steuerpult, control: &Schluessel) -> Option { + match control.gruppe { + Gruppe::Deck(i) => (i < pult.decks().len()).then_some(i), + Gruppe::Kanal(k) => pult.decks().iter().position(|d| d.kanal == k), + Gruppe::Master => (0..pult.decks().len()).find(|i| beat_jetzt(pult, *i).is_some()), + } +} + +/// Führt aus, was fällig ist. +/// +/// Gibt für jeden erledigten oder abgebrochenen Auftrag eine Zeile zurück — +/// dieselbe Form wie sonst im Protokoll, damit ein Abonnent sie mitliest. +pub fn takt( + pult: &mut Steuerpult, + plan: &mut Zeitplan, + ausfuehren: &mut dyn FnMut(&mut Steuerpult, &str) -> String, +) -> Vec { + let mut meldungen = Vec::new(); + // Was ein fälliger Befehl selbst vormerkt, landet im Plan des Pults — + // der ist gerade leer, weil der Taktgeber diesen hier herausgenommen hat. + // Ohne den Zählerstand finge er wieder bei 1 an. + pult.plan.zaehler_von(plan); + // Herausnehmen, abarbeiten, zurücklegen: Sonst läge der Plan geborgt da, + // während die Aufträge selbst wieder ins Pult schreiben. + let mut offen = std::mem::take(&mut plan.auftraege); + let mut bleibt = Vec::with_capacity(offen.len()); + + for mut auftrag in offen.drain(..) { + // Ein Wenn zuerst und getrennt: Es hängt an einem Wert, nicht an + // Takten, und dürfte deshalb nicht daran scheitern, dass irgendein + // Deck gerade kein Grid hat. + if let Was::Wenn { + control, + vergleich, + schwelle, + zeile, + } = &auftrag.was + { + let (control, vergleich, schwelle, zeile) = + (control.clone(), *vergleich, *schwelle, zeile.clone()); + + match pult.lies(&control) { + Ok(Wert::Zahl(steht)) if vergleich.trifft_zu(steht, schwelle) => { + let antwort = ausfuehren(pult, &zeile); + meldungen.push(format!("plan {} ausgefuehrt {antwort}", auftrag.id)); + } + // Noch nicht so weit — oder noch nicht bekannt. `Leer` heißt + // nicht „nie": Ein Track, der gerade lädt, hat noch kein Grid + // und damit noch keine Restbeats. + Ok(_) => bleibt.push(auftrag), + Err(e) => meldungen.push(format!("plan {} abgebrochen — {e}", auftrag.id)), + } + continue; + } + + let Some(jetzt) = beat_jetzt(pult, auftrag.takt_deck) else { + meldungen.push(format!( + "plan {} abgebrochen — deck{} hat kein Grid mehr", + auftrag.id, + auftrag.takt_deck + 1 + )); + continue; + }; + + // Ist der Track des Taktgebers durchgelaufen, kann der Auftrag nie + // mehr fällig werden: Sein Beat steht und rührt sich nicht wieder. + // + // Das ist etwas anderes als ein angehaltenes Deck — dort wartet der + // Plan absichtlich, und das ist richtig so. Hier wartet er auf etwas, + // das nicht kommt. Am laufenden Programm sah man die Folge: Der + // ausgehende Track lief mitten im Bass-Swap aus, und danach stand der + // Crossfader für immer in der Mitte, mit fünf toten Aufträgen im Plan. + // Ein Bediener sieht dort einen Übergang, der läuft, und hört einen, + // der steht. + if pult + .decks() + .get(auftrag.takt_deck) + .is_some_and(|d| d.state.is_finished()) + { + meldungen.push(format!( + "plan {} abgebrochen — deck{} ist durchgelaufen", + auftrag.id, + auftrag.takt_deck + 1 + )); + continue; + } + + // Ist der Taktgeber zurückgesprungen, ist die Grundlage des Auftrags + // weg. Ihn trotzdem weiterlaufen zu lassen hieße, eine Rampe von vorn + // beginnen zu lassen — bei einer Schleife immer wieder. Lieber + // abbrechen und es sagen: Wer eine Schleife setzt, soll erfahren, dass + // sein Plan daran hängt. + if jetzt + RUECKSPRUNG < auftrag.zuletzt_beat { + meldungen.push(format!( + "plan {} abgebrochen — deck{} ist zurückgesprungen (Schleife oder Sprung)", + auftrag.id, + auftrag.takt_deck + 1 + )); + continue; + } + auftrag.zuletzt_beat = jetzt; + + match &mut auftrag.was { + Was::Spaeter { beim_beat, zeile } => { + if jetzt + 1e-9 < *beim_beat { + bleibt.push(auftrag); + continue; + } + let antwort = ausfuehren(pult, &zeile.clone()); + meldungen.push(format!("plan {} ausgefuehrt {antwort}", auftrag.id)); + } + // Oben schon behandelt; hier kommt nichts mehr an. + Was::Wenn { .. } => unreachable!("Wenn wird vor dem Taktgeber behandelt"), + Was::Rampe(rampe) => match rampe_schritt(pult, rampe, auftrag.ab_beat, jetzt) { + Ausgang::Laeuft => bleibt.push(auftrag), + Ausgang::Fertig => meldungen.push(format!( + "plan {} fertig {} {:.4}", + auftrag.id, rampe.control, rampe.nach + )), + Ausgang::Abgeloest => meldungen.push(format!( + "plan {} abgeloest {} — jemand anders hat den Regler", + auftrag.id, rampe.control + )), + Ausgang::Haltlos => { + meldungen.push(format!("plan {} abgebrochen {}", auftrag.id, rampe.control)) + } + }, + } + } + + plan.auftraege = bleibt; + // In die Mitschrift gehört vor allem das hier: `plan 3 fertig` sagt auf + // den Frame genau, wann eine Blende zu Ende war. Aus dem Klang allein wäre + // das Ende einer langen Blende genauso schwer zu finden wie ihr Anfang — + // dort ist der eingehende Track längst der einzige. + for m in &meldungen { + pult.halten(crate::mitschrift::Richtung::Meldung, m); + // Und in den Ring, damit sie noch im selben Takt jemanden erreichen. + // Die Mitschrift ist für hinterher; wer gerade auflegt, muss *jetzt* + // erfahren, dass seine Blende abgelöst wurde. + pult.ereignisse.melden(m); + } + meldungen +} + +fn rampe_schritt(pult: &mut Steuerpult, rampe: &mut Rampe, ab: f64, jetzt: f64) -> Ausgang { + // Noch nicht losgelaufen? Dann auch noch nichts anfassen. + if jetzt + 1e-9 < ab { + return Ausgang::Laeuft; + } + + // Hat jemand anders den Regler bewegt, seit wir zuletzt geschrieben haben? + let Ok(Wert::Zahl(steht)) = pult.lies(&rampe.control) else { + return Ausgang::Haltlos; + }; + if (steht - rampe.zuletzt).abs() > ABGELOEST { + return Ausgang::Abgeloest; + } + + let anteil = if rampe.beats > 0.0 { + ((jetzt - ab) / rampe.beats).clamp(0.0, 1.0) + } else { + 1.0 + }; + let wert = rampe.von + (rampe.nach - rampe.von) * rampe.form.anwenden(anteil); + + // Nur um diesen einen Schreibvorgang herum: Das Pult kann sonst den letzten + // Schritt einer Blende nicht von einem Schnitt unterscheiden. + pult.rampe_am_werk = Some(format!("{}/{:.0}", rampe.form.name(), rampe.beats)); + let geschrieben = pult.schreibe(&rampe.control, Wert::Zahl(wert)); + pult.rampe_am_werk = None; + if geschrieben.is_err() { + return Ausgang::Haltlos; + } + // Zurücklesen statt den gewünschten Wert zu merken: Das Pult begrenzt, und + // ein begrenzter Wert wäre sonst beim nächsten Takt ein „fremder Eingriff". + rampe.zuletzt = match pult.lies(&rampe.control) { + Ok(Wert::Zahl(v)) => v, + _ => wert, + }; + + if anteil >= 1.0 { + Ausgang::Fertig + } else { + Ausgang::Laeuft + } +} + +/// Merkt eine Rampe vor. +pub fn rampe_planen( + pult: &Steuerpult, + plan: &mut Zeitplan, + control: Schluessel, + nach: f64, + beats: f64, + takt_deck: Option, + form: Form, +) -> Result { + if beats < 0.0 { + return Err(Fehler::Argument { + control: control.to_string(), + erwartet: "eine Länge in Beats, nicht negativ".into(), + }); + } + + let Ok(Wert::Zahl(von)) = pult.lies(&control) else { + return Err(Fehler::FalscherTyp { + control: control.to_string(), + erwartet: crate::wert::Art::Zahl, + }); + }; + + let deck = takt_deck + .or_else(|| taktgeber(pult, &control)) + .ok_or_else(|| Fehler::Gescheitert("kein Deck mit Beatgrid als Taktgeber".into()))?; + let jetzt = beat_jetzt(pult, deck) + .ok_or_else(|| Fehler::Gescheitert(format!("deck{} hat kein Beatgrid", deck + 1)))?; + + Ok(plan.aufnehmen( + deck, + jetzt, + Was::Rampe(Rampe { + control, + von, + nach, + beats, + zuletzt: von, + form, + }), + )) +} + +/// Merkt eine Protokollzeile für später vor. +pub fn spaeter_planen( + pult: &Steuerpult, + plan: &mut Zeitplan, + beats: f64, + zeile: String, + takt_deck: Option, +) -> Result { + if beats < 0.0 { + return Err(Fehler::Argument { + control: "in".into(), + erwartet: "eine Wartezeit in Beats, nicht negativ".into(), + }); + } + + let deck = takt_deck + .or_else(|| (0..pult.decks().len()).find(|i| beat_jetzt(pult, *i).is_some())) + .ok_or_else(|| Fehler::Gescheitert("kein Deck mit Beatgrid als Taktgeber".into()))?; + let jetzt = beat_jetzt(pult, deck) + .ok_or_else(|| Fehler::Gescheitert(format!("deck{} hat kein Beatgrid", deck + 1)))?; + + Ok(plan.aufnehmen( + deck, + jetzt, + Was::Spaeter { + beim_beat: jetzt + beats, + zeile, + }, + )) +} + +/// Merkt eine Protokollzeile für den Moment vor, in dem ein Wert eine Schwelle +/// überschreitet. +/// +/// Geprüft wird beim Vormerken, ob das Control überhaupt eine Zahl ist — ein +/// Schalter oder ein Titel ließe sich nie mit einer Schwelle vergleichen, und +/// ein Auftrag, der stumm für immer wartet, ist schlimmer als eine Absage. +/// +/// **Trifft die Bedingung schon jetzt zu, läuft der Befehl beim nächsten Takt.** +/// Das ist gewollt: `when` heißt „sobald es so weit ist", nicht „beim nächsten +/// Überschreiten". Wer auf eine Flanke warten will, prüft vorher selbst. +pub fn wenn_planen( + pult: &Steuerpult, + plan: &mut Zeitplan, + control: Schluessel, + vergleich: Vergleich, + schwelle: f64, + zeile: String, +) -> Result { + let Some(b) = pult.beschreibung(&control) else { + return Err(Fehler::UnbekanntesControl(control.to_string())); + }; + if b.art != crate::wert::Art::Zahl { + return Err(Fehler::FalscherTyp { + control: control.to_string(), + erwartet: crate::wert::Art::Zahl, + }); + } + + Ok(plan.aufnehmen( + 0, + 0.0, + Was::Wenn { + control, + vergleich, + schwelle, + zeile, + }, + )) +} + +/// Wie oft der Plan nachsieht, ob etwas fällig ist. +/// +/// 5 ms sind bei 128 BPM rund ein Prozent eines Beats — für eine Blende +/// unhörbar, für einen harten Schnitt auf die Eins gerade noch vertretbar. +/// Kürzer zu takten hieße, öfter den Mutex des Pults zu nehmen, an dem auch +/// die Oberfläche hängt. Sample-genau ist das ausdrücklich **nicht**: Dafür +/// müssten die Befehle im Audio-Callback liegen, und dorthin gehört keine +/// Reglerlogik. +pub const TAKT: std::time::Duration = std::time::Duration::from_millis(5); + +/// Läuft, solange dieser Griff lebt. +pub struct Taktgeberthread { + weiter: Arc, +} + +impl Drop for Taktgeberthread { + fn drop(&mut self) { + self.weiter + .store(false, std::sync::atomic::Ordering::Relaxed); + } +} + +/// Startet den Takt, der den Plan abarbeitet. +/// +/// Gehört bewusst nicht in den Server: Der Plan muss auch dann laufen, wenn +/// niemand über den Socket verbunden ist — die Oberfläche kann genauso eine +/// Rampe auslösen. +pub fn takt_starten(pult: Arc>) -> Taktgeberthread { + let weiter = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let laufen = Arc::clone(&weiter); + + std::thread::Builder::new() + .name("zeitplan".into()) + .spawn(move || { + while laufen.load(std::sync::atomic::Ordering::Relaxed) { + if let Ok(mut p) = pult.lock() { + if !p.plan.ist_leer() { + let mut plan = std::mem::take(&mut p.plan); + takt(&mut p, &mut plan, &mut |pult, zeile| { + let mut sitzung = crate::Sitzung::neu(); + crate::behandle(pult, &mut sitzung, zeile) + }); + // Was währenddessen dazukam, geht nicht verloren. + let neu = std::mem::take(&mut p.plan); + p.plan = plan; + for a in neu.auftraege() { + p.plan.uebernehmen(a.clone()); + } + } + } + std::thread::sleep(TAKT); + } + }) + .expect("Zeitplan-Thread ließ sich nicht starten"); + + Taktgeberthread { weiter } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{pult_mit_zwei_decks, rendern, RATE}; + use crate::wert::Wert; + + fn k(text: &str) -> Schluessel { + Schluessel::parse(text).unwrap() + } + + /// Führt den Plan aus, während das Deck spielt. + fn laufen( + pult: &mut Steuerpult, + plan: &mut Zeitplan, + runner: &mut audio_engine::EngineRunner, + frames: usize, + ) -> Vec { + let mut meldungen = Vec::new(); + // In Häppchen, damit die Rampe unterwegs mehrfach greift — genau wie + // der Taktgeber-Thread im Betrieb. + for _ in 0..20 { + rendern(runner, frames / 20); + meldungen.extend(takt(pult, plan, &mut |_, _| String::new())); + } + meldungen + } + + /// Eine Blende über acht Beats — der eigentliche Zweck. + #[test] + fn eine_rampe_wandert_ueber_die_beats() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let mut plan = Zeitplan::neu(); + pult.schreibe(&k("channel1.fader"), Wert::Zahl(1.0)) + .unwrap(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + + rampe_planen( + &pult, + &mut plan, + k("channel1.fader"), + 0.0, + 8.0, + None, + Form::Linear, + ) + .expect("planen"); + + // Bei 128 BPM sind acht Beats 3,75 s. Erst die Hälfte. + laufen( + &mut pult, + &mut plan, + &mut runner, + (RATE as f64 * 1.875) as usize, + ); + let Wert::Zahl(mitte) = pult.lies(&k("channel1.fader")).unwrap() else { + panic!() + }; + assert!( + (0.35..0.65).contains(&mitte), + "nach der Hälfte steht der Fader bei {mitte:.3}, nicht in der Mitte" + ); + assert!(!plan.ist_leer(), "die Rampe ist zu früh fertig"); + + // Und zu Ende. + let meldungen = laufen( + &mut pult, + &mut plan, + &mut runner, + (RATE as f64 * 2.2) as usize, + ); + assert_eq!(pult.lies(&k("channel1.fader")).unwrap(), Wert::Zahl(0.0)); + assert!(plan.ist_leer(), "die Rampe hängt noch im Plan"); + assert!( + meldungen.iter().any(|m| m.contains("fertig")), + "{meldungen:?}" + ); + } + + /// Zwei offene Kanäle, zwei laufende Decks — so sieht jeder Griff aus, + /// bevor der Crossfader sich bewegt. + fn aufgelegt(pult: &mut Steuerpult) { + for z in ["channel1.fader", "channel2.fader"] { + pult.schreibe(&k(z), Wert::Zahl(1.0)).unwrap(); + } + for d in ["deck1.play", "deck2.play"] { + pult.schreibe(&k(d), Wert::Schalter(true)).unwrap(); + } + } + + /// Den Fader vor dem Set auf eine Seite zu stellen ist kein Übergang. + /// + /// Gefunden am laufenden Programm, nicht im Test: Nach `set channel1.fader + /// 1; set master.crossfader -1; set deck1.play 1` stand dort bereits + /// `transitions schnitt`. Niemand hatte übergeblendet — es lief ja erst + /// ein Deck. Eine Zurückhaltung, die schon beim Einrichten mitzählt, + /// warnt beim ersten echten Griff vor einer Wiederholung, die es nicht gab. + #[test] + fn den_fader_vor_dem_set_zu_parken_ist_kein_uebergang() { + let (mut pult, _runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("channel1.fader"), Wert::Zahl(1.0)) + .unwrap(); + + // Erst der Fader, dann das Deck — die Reihenfolge aus dem Fund. + pult.schreibe(&k("master.crossfader"), Wert::Zahl(-1.0)) + .unwrap(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + assert_eq!(pult.lies(&k("master.transitions")).unwrap(), Wert::Leer); + + // Und andersherum, denn so richtet man eher ein: Deck läuft, dann wird + // der Fader dorthin gestellt. Ein einzelnes laufendes Deck reicht + // nicht — sonst zählte genau dieser Griff. + pult.schreibe(&k("master.crossfader"), Wert::Zahl(0.0)) + .unwrap(); + pult.schreibe(&k("master.crossfader"), Wert::Zahl(-1.0)) + .unwrap(); + + assert_eq!(pult.lies(&k("master.repeats")).unwrap(), Wert::Zahl(0.0)); + assert_eq!(pult.lies(&k("master.transitions")).unwrap(), Wert::Leer); + } + + /// Eine Blende ist **ein** Übergang, keine achtzig. + /// + /// Eine Rampe schreibt bei jedem Takt in denselben Regler. Zählte man den + /// Wert statt den Seitenwechsel, stünde nach einer einzigen Blende eine + /// Wiederholung von 80 da — und die Zahl, die vor Eintönigkeit warnen soll, + /// wäre selbst der Grund, sie zu ignorieren. + #[test] + fn eine_blende_zaehlt_einmal_nicht_achtzigmal() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let mut plan = Zeitplan::neu(); + aufgelegt(&mut pult); + + rampe_planen( + &pult, + &mut plan, + k("master.crossfader"), + 1.0, + 8.0, + None, + Form::Weich, + ) + .expect("planen"); + laufen( + &mut pult, + &mut plan, + &mut runner, + (RATE as f64 * 4.2) as usize, + ); + + assert_eq!(pult.lies(&k("master.crossfader")).unwrap(), Wert::Zahl(1.0)); + assert_eq!(pult.lies(&k("master.repeats")).unwrap(), Wert::Zahl(1.0)); + assert_eq!( + pult.lies(&k("master.transitions")).unwrap(), + Wert::Text("weich/8".into()) + ); + } + + /// Ein Schnitt und eine Blende sind nicht dasselbe — und dreimal derselbe + /// Befehl ist ein Übergang, nicht drei. + #[test] + fn verschiedene_griffe_sind_keine_wiederholung() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let mut plan = Zeitplan::neu(); + aufgelegt(&mut pult); + + // Ein Sprung hinüber, dreimal geschickt. + for _ in 0..3 { + pult.schreibe(&k("master.crossfader"), Wert::Zahl(1.0)) + .unwrap(); + } + assert_eq!(pult.lies(&k("master.repeats")).unwrap(), Wert::Zahl(1.0)); + + // Und wieder zurück, diesmal als Blende. + rampe_planen( + &pult, + &mut plan, + k("master.crossfader"), + -1.0, + 8.0, + None, + Form::Linear, + ) + .expect("planen"); + laufen( + &mut pult, + &mut plan, + &mut runner, + (RATE as f64 * 4.2) as usize, + ); + + assert_eq!( + pult.lies(&k("master.transitions")).unwrap(), + Wert::Text("schnitt, linear/8".into()) + ); + assert_eq!(pult.lies(&k("master.repeats")).unwrap(), Wert::Zahl(1.0)); + } + + /// **Ein Deck, das zurückspringt, bricht den Plan ab — statt ihn zu + /// wiederholen.** + /// + /// Gefunden am laufenden Programm beim ersten Schleifen-Übergang: Der + /// Crossfader fuhr sieben Mal hin und her. Eine Rampe rechnet ihren + /// Fortschritt aus dem Beat des Taktgebers; läuft der in einer Schleife, + /// fängt sie bei jedem Durchlauf von vorn an. Ein Vorgemerktes hinter dem + /// Schleifenende kommt umgekehrt nie an. + /// + /// Beides still zu tun wäre das Schlimmste: Der Bediener sieht einen Plan, + /// der läuft, und hört etwas anderes. + #[test] + fn ein_zurueckgesprungenes_deck_bricht_den_plan_ab_statt_ihn_zu_wiederholen() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let mut plan = Zeitplan::neu(); + pult.schreibe(&k("channel1.fader"), Wert::Zahl(1.0)) + .unwrap(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + + rampe_planen( + &pult, + &mut plan, + k("master.crossfader"), + 1.0, + 32.0, + None, + Form::Linear, + ) + .expect("planen"); + + // Ein Stück laufen lassen, dann zurückspringen — genau das tut eine + // Schleife an ihrem Ende. + laufen( + &mut pult, + &mut plan, + &mut runner, + (RATE as f64 * 2.0) as usize, + ); + assert!(!plan.ist_leer(), "die Rampe ist zu früh fertig"); + pult.decks()[0].state.seek_frames(0); + + let meldungen = laufen(&mut pult, &mut plan, &mut runner, RATE as usize / 4); + assert!(plan.ist_leer(), "der Auftrag läuft nach dem Sprung weiter"); + assert!( + meldungen.iter().any(|m| m.contains("zurückgesprungen")), + "kein Wort vom Rücksprung: {meldungen:?}" + ); + } + + /// **Läuft der Track des Taktgebers aus, bricht sein Plan ab — statt für + /// immer zu warten.** + /// + /// Am laufenden Programm gefunden: Der ausgehende Track lief mitten im + /// Bass-Swap aus, und danach stand der Crossfader für immer in der Mitte, + /// mit fünf toten Aufträgen im Plan. Ein Bediener sieht dort einen + /// Übergang, der läuft, und hört einen, der steht. + /// + /// Das ist etwas anderes als ein angehaltenes Deck: Dort wartet der Plan + /// absichtlich weiter, und der Test daneben hält das fest. + #[test] + fn ein_durchgelaufener_track_bricht_seinen_plan_ab() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let mut plan = Zeitplan::neu(); + pult.schreibe(&k("channel1.fader"), Wert::Zahl(1.0)) + .unwrap(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + + // Etwas, das erst weit hinten fällig wäre — der Track ist vorher zu + // Ende. Eine Rampe taugte hier nicht: Der Sprung ans Trackende würde + // sie unterwegs fertig laufen lassen, und dann misst der Test etwas + // anderes. + spaeter_planen( + &pult, + &mut plan, + 10_000.0, + "set master.crossfader 1".into(), + None, + ) + .expect("planen"); + laufen(&mut pult, &mut plan, &mut runner, RATE as usize / 4); + assert!(!plan.ist_leer()); + + // Der Track läuft wirklich aus — nicht angehalten, sondern zu Ende. + // Kurz vor den Schluss gesprungen und weitergerendert; den Rest macht + // der Abspieler selbst, so wie im Betrieb. + pult.decks()[0] + .state + .seek_frames(RATE as u64 * 60 - RATE as u64 / 10); + let meldungen = laufen(&mut pult, &mut plan, &mut runner, RATE as usize / 2); + assert!( + pult.decks()[0].state.is_finished(), + "der Track ist nicht durchgelaufen — der Test misst etwas anderes" + ); + + assert!( + plan.ist_leer(), + "der Auftrag wartet auf etwas, das nicht kommt" + ); + assert!( + meldungen.iter().any(|m| m.contains("durchgelaufen")), + "kein Wort davon, warum: {meldungen:?}" + ); + } + + /// Ein bloß angehaltenes Deck hält den Plan an — das ist Absicht und darf + /// nicht mit einem durchgelaufenen Track verwechselt werden. Wer pausiert, + /// will dort weitermachen, wo er aufgehört hat. + #[test] + fn ein_angehaltenes_deck_haelt_den_plan_nur_an() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let mut plan = Zeitplan::neu(); + pult.schreibe(&k("channel1.fader"), Wert::Zahl(1.0)) + .unwrap(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + rampe_planen( + &pult, + &mut plan, + k("master.crossfader"), + 1.0, + 64.0, + None, + Form::Linear, + ) + .expect("planen"); + laufen(&mut pult, &mut plan, &mut runner, RATE as usize / 4); + + pult.schreibe(&k("deck1.play"), Wert::Schalter(false)) + .unwrap(); + laufen(&mut pult, &mut plan, &mut runner, RATE as usize / 2); + assert!(!plan.ist_leer(), "die Pause hat den Auftrag verworfen"); + } + + /// `in deck2 phrase …` — der Taktgeber darf ein anderer sein als das Deck, + /// auf das gewirkt wird. Ohne das ließe sich ein schleifendes Deck nicht + /// wieder auflösen: Sein eigener Beat kommt nie über das Schleifenende. + #[test] + fn in_nimmt_ein_eigenes_taktgeber_deck() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + pult.schreibe(&k("channel2.fader"), Wert::Zahl(1.0)) + .unwrap(); + pult.schreibe(&k("deck2.play"), Wert::Schalter(true)) + .unwrap(); + rendern(&mut runner, 256); + + let mut sitzung = crate::Sitzung::neu(); + let antwort = crate::behandle( + &mut pult, + &mut sitzung, + "in deck2 4 set deck1.loop_active 0", + ); + assert!(antwort.starts_with("ok plan "), "{antwort}"); + assert_eq!( + pult.plan.auftraege()[0].takt_deck, + 1, + "der Auftrag hängt am falschen Deck" + ); + } + + /// Der Mensch gewinnt. + /// + /// Ohne das wäre eine laufende Rampe stärker als der Griff daneben: Man + /// zieht den Fader zu, und einen Wimpernschlag später steht er wieder + /// offen. Für ein Team von Agenten gilt dasselbe untereinander. + #[test] + fn ein_fremder_griff_loest_die_rampe_ab() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let mut plan = Zeitplan::neu(); + pult.schreibe(&k("channel1.fader"), Wert::Zahl(1.0)) + .unwrap(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + + rampe_planen( + &pult, + &mut plan, + k("channel1.fader"), + 0.0, + 32.0, + None, + Form::Linear, + ) + .expect("planen"); + laufen(&mut pult, &mut plan, &mut runner, RATE as usize); + assert!(!plan.ist_leer(), "die Rampe sollte noch laufen"); + + // Jemand anders greift ein. + pult.schreibe(&k("channel1.fader"), Wert::Zahl(0.2)) + .unwrap(); + let meldungen = laufen(&mut pult, &mut plan, &mut runner, RATE as usize / 2); + + assert!(plan.ist_leer(), "die Rampe läuft über den Eingriff hinweg"); + assert!( + meldungen.iter().any(|m| m.contains("abgeloest")), + "{meldungen:?}" + ); + // Und der fremde Wert steht noch da, wo er hingesetzt wurde. + let Wert::Zahl(steht) = pult.lies(&k("channel1.fader")).unwrap() else { + panic!() + }; + assert!((steht - 0.2).abs() < 1e-3, "Fader steht bei {steht:.3}"); + } + + /// Ein stehendes Deck hat keine Takte — der Plan wartet, statt zu laufen. + #[test] + fn ein_gestopptes_deck_haelt_den_plan_an() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let mut plan = Zeitplan::neu(); + pult.schreibe(&k("channel1.fader"), Wert::Zahl(1.0)) + .unwrap(); + // deck1 bleibt stehen. + + rampe_planen( + &pult, + &mut plan, + k("channel1.fader"), + 0.0, + 4.0, + None, + Form::Linear, + ) + .expect("planen"); + laufen(&mut pult, &mut plan, &mut runner, RATE as usize * 3); + + assert!(!plan.ist_leer(), "die Rampe lief ohne laufendes Deck"); + assert_eq!(pult.lies(&k("channel1.fader")).unwrap(), Wert::Zahl(1.0)); + } + + /// Ein Befehl, der auf seinen Beat wartet. + #[test] + fn ein_vorgemerkter_befehl_kommt_zu_seiner_zeit() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let mut plan = Zeitplan::neu(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + + spaeter_planen(&pult, &mut plan, 4.0, "set channel2.fader 0.9".into(), None) + .expect("planen"); + + let mut gelaufen = Vec::new(); + for _ in 0..40 { + rendern(&mut runner, RATE as usize / 20); + takt(&mut pult, &mut plan, &mut |_, zeile| { + gelaufen.push(zeile.to_string()); + "ok".into() + }); + } + + assert_eq!(gelaufen, vec!["set channel2.fader 0.9"]); + assert!(plan.ist_leer()); + } + + /// Der Taktgeber kommt vom Kanal, ohne dass man ihn nennen muss. + #[test] + fn ein_kanal_erbt_den_takt_von_seinem_deck() { + let (pult, _runner) = pult_mit_zwei_decks(); + + assert_eq!(taktgeber(&pult, &k("channel2.fader")), Some(1)); + assert_eq!(taktgeber(&pult, &k("deck2.tempo")), Some(1)); + // Der AUX-Kanal hat kein Deck. + assert_eq!(taktgeber(&pult, &k("channel3.fader")), None); + // Die Summe nimmt das erste Deck mit Grid. + assert_eq!(taktgeber(&pult, &k("master.crossfader")), Some(0)); + } + + #[test] + fn was_sich_nicht_rampen_laesst_wird_abgewiesen() { + let (pult, _runner) = pult_mit_zwei_decks(); + let mut plan = Zeitplan::neu(); + + // Ein Schalter ist keine Bewegung. + assert!(rampe_planen( + &pult, + &mut plan, + k("deck1.play"), + 1.0, + 8.0, + None, + Form::Linear + ) + .is_err()); + // Negative Längen ergeben keinen Sinn. + assert!(rampe_planen( + &pult, + &mut plan, + k("channel1.fader"), + 0.0, + -4.0, + None, + Form::Linear + ) + .is_err()); + assert!(plan.ist_leer()); + } + + /// Ein fällig gewordener Befehl darf keine Nummer wiederverwenden. + /// + /// Der Taktgeber nimmt den Plan aus dem Pult heraus, arbeitet ihn ab und + /// legt ihn zurück. Was währenddessen neu vorgemerkt wird — ein + /// `in 0 ramp …`, das gerade fällig wurde —, landet im leeren Plan, der + /// solange im Pult liegt. Ohne übernommenen Zählerstand bekäme es dort + /// wieder die Nummer 1, und `cancel 1` träfe dann zwei Aufträge. + #[test] + fn ein_nachgelegter_auftrag_bekommt_keine_schon_vergebene_nummer() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let mut plan = Zeitplan::neu(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + + // Ein langer Läufer, der die 1 belegt und belegt bleibt. + rampe_planen( + &pult, + &mut plan, + k("channel1.fader"), + 0.0, + 512.0, + None, + Form::Linear, + ) + .unwrap(); + // Und ein Befehl, der sofort fällig ist und selbst etwas vormerkt. + spaeter_planen( + &pult, + &mut plan, + 0.0, + "ramp channel2.fader 0 64".into(), + None, + ) + .unwrap(); + + rendern(&mut runner, RATE as usize / 10); + takt(&mut pult, &mut plan, &mut |pult, zeile| { + let mut sitzung = crate::Sitzung::neu(); + crate::behandle(pult, &mut sitzung, zeile) + }); + + // Der Taktgeber legt zurück, was währenddessen dazukam — wie im Betrieb. + let neu = std::mem::take(&mut pult.plan); + for a in neu.auftraege() { + plan.uebernehmen(a.clone()); + } + + let nummern: Vec = plan.auftraege().iter().map(|a| a.id).collect(); + let mut sortiert = nummern.clone(); + sortiert.sort_unstable(); + sortiert.dedup(); + assert_eq!( + sortiert.len(), + nummern.len(), + "zwei Aufträge tragen dieselbe Nummer: {nummern:?}" + ); + } + + #[test] + fn vorgemerktes_laesst_sich_zuruecknehmen() { + let (pult, _runner) = pult_mit_zwei_decks(); + let mut plan = Zeitplan::neu(); + + let a = rampe_planen( + &pult, + &mut plan, + k("channel1.fader"), + 0.0, + 8.0, + None, + Form::Linear, + ) + .unwrap(); + rampe_planen( + &pult, + &mut plan, + k("channel2.fader"), + 0.0, + 8.0, + None, + Form::Linear, + ) + .unwrap(); + + assert_eq!(plan.streichen(Some(a)), 1); + assert_eq!(plan.auftraege().len(), 1); + assert_eq!(plan.streichen(None), 1); + assert!(plan.ist_leer()); + } + + /// Was der Plan meldet, gehört in die Mitschrift — und zwar mit dem Frame. + /// + /// Das Ende einer langen Blende ist im Klang genauso schwer zu finden wie + /// ihr Anfang: Dort läuft der eingehende Track längst allein. Der Plan + /// weiß es auf den Takt. + #[test] + fn was_der_plan_meldet_steht_in_der_mitschrift() { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let wav = std::env::temp_dir().join(format!("musik-plan-{}.wav", std::process::id())); + let neben = wav.with_extension(crate::mitschrift::ENDUNG); + let _ = std::fs::remove_file(&wav); + let _ = std::fs::remove_file(&neben); + + pult.ausloesen(&k("master.record"), Some(&wav.to_string_lossy())) + .expect("Mitschnitt"); + pult.schreibe(&k("channel1.fader"), Wert::Zahl(1.0)) + .unwrap(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + + let mut plan = Zeitplan::neu(); + // Acht Beats bei 128 BPM sind 3,75 s. + rampe_planen( + &pult, + &mut plan, + k("channel1.fader"), + 0.0, + 8.0, + None, + Form::Linear, + ) + .expect("planen"); + laufen(&mut pult, &mut plan, &mut runner, RATE as usize * 4); + + let p = crate::mitschrift::lesen(&neben).expect("lesbar"); + let fertig = p + .ereignisse + .iter() + .find(|e| e.text.contains("fertig")) + .expect("das Ende der Blende steht nicht in der Mitschrift"); + let sek = fertig.sekunden(p.kopf.rate); + assert!( + (3.5..4.1).contains(&sek), + "die Blende endete laut Mitschrift bei {sek:.2} s, erwartet wurden rund 3,75" + ); + assert!( + fertig.stand(0).is_some_and(|s| s.beat > 7.5), + "der Beat beim Ende fehlt oder passt nicht: {:?}", + fertig.staende + ); + + pult.ausloesen(&k("master.record_stop"), None) + .expect("Stop"); + let _ = std::fs::remove_file(&wav); + let _ = std::fs::remove_file(&neben); + } +} + +/// Die Formen: dieselbe Strecke, andere Verteilung. +#[cfg(test)] +mod form_tests { + use super::*; + use crate::testing::{pult_mit_zwei_decks, rendern, RATE}; + use crate::wert::Wert; + + fn k(text: &str) -> Schluessel { + Schluessel::parse(text).unwrap() + } + + /// **Jede Form muss 0 auf 0 und 1 auf 1 abbilden.** Sonst spränge der + /// Regler beim Losfahren oder käme am Ende nicht an — und das wäre kein + /// Geschmack, sondern ein Fehler. + #[test] + fn jede_form_faengt_bei_null_an_und_kommt_bei_eins_an() { + for f in Form::ALLE { + assert_eq!(f.anwenden(0.0), 0.0, "{}", f.name()); + assert_eq!(f.anwenden(1.0), 1.0, "{}", f.name()); + // Und außerhalb wird begrenzt, statt über das Ziel hinauszulaufen. + assert_eq!(f.anwenden(-1.0), 0.0, "{}", f.name()); + assert_eq!(f.anwenden(2.0), 1.0, "{}", f.name()); + } + } + + /// Keine Form darf rückwärtslaufen: Ein Fader, der zwischendurch umkehrt, + /// ist kaputt und nicht ausdrucksstark. + #[test] + fn keine_form_laeuft_rueckwaerts() { + for f in Form::ALLE { + let mut vorher = 0.0; + for i in 0..=100 { + let jetzt = f.anwenden(i as f64 / 100.0); + assert!( + jetzt >= vorher - 1e-9, + "{} läuft bei {i} % zurück: {vorher} → {jetzt}", + f.name() + ); + vorher = jetzt; + } + } + } + + /// Der Unterschied, um den es geht: Auf halber Strecke stehen die Formen + /// woanders. `spaet` hat noch kaum etwas getan, `frueh` schon fast alles. + #[test] + fn auf_halber_strecke_stehen_sie_woanders() { + assert_eq!(Form::Linear.anwenden(0.5), 0.5); + assert_eq!( + Form::Weich.anwenden(0.5), + 0.5, + "die S-Kurve ist symmetrisch" + ); + assert_eq!(Form::Spaet.anwenden(0.5), 0.25); + assert_eq!(Form::Frueh.anwenden(0.5), 0.75); + + // Nach einem Viertel: der Unterschied ist am größten, wo er zählt. + assert!(Form::Spaet.anwenden(0.25) < 0.1, "spaet zieht zu früh los"); + assert!(Form::Frueh.anwenden(0.25) > 0.4, "frueh zögert"); + } + + /// Und das Ganze am laufenden Regler, nicht nur in der Rechnung. + #[test] + fn eine_weiche_rampe_haengt_in_der_mitte_der_linearen_nach_oder_vor() { + for (form, erwartet) in [ + (Form::Spaet, 0.25f64), + (Form::Linear, 0.5), + (Form::Frueh, 0.75), + ] { + let (mut pult, mut runner) = pult_mit_zwei_decks(); + let mut plan = Zeitplan::neu(); + pult.schreibe(&k("channel1.fader"), Wert::Zahl(1.0)) + .unwrap(); + pult.schreibe(&k("deck1.play"), Wert::Schalter(true)) + .unwrap(); + rampe_planen(&pult, &mut plan, k("channel1.fader"), 0.0, 8.0, None, form) + .expect("planen"); + + // Acht Beats bei 128 BPM sind 3,75 s — die Hälfte davon. + for _ in 0..20 { + rendern(&mut runner, (RATE as f64 * 1.875 / 20.0) as usize); + takt(&mut pult, &mut plan, &mut |_, _| String::new()); + } + + let Wert::Zahl(steht) = pult.lies(&k("channel1.fader")).unwrap() else { + panic!() + }; + // Der Fader läuft von 1 nach 0, der Anteil also andersherum. + let gefahren = 1.0 - steht; + assert!( + (gefahren - erwartet).abs() < 0.12, + "{}: nach der Hälfte sind {gefahren:.2} gefahren, erwartet {erwartet}", + form.name() + ); + } + } + + #[test] + fn jede_form_ueberlebt_ihren_namen() { + for f in Form::ALLE { + assert_eq!(Form::parse(f.name()), Some(f)); + } + assert_eq!(Form::parse("sanft"), None); + assert_eq!(Form::default(), Form::Linear); + } +} diff --git a/crates/library/Cargo.toml b/crates/library/Cargo.toml new file mode 100644 index 0000000..ea3f6b4 --- /dev/null +++ b/crates/library/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "library" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +audio-core = { version = "0.1.0", path = "../audio-core" } +quick-xml = "0.41.0" +rusqlite = { version = "0.40.2", features = ["bundled"] } +thiserror = "2.0.20" diff --git a/crates/library/src/db.rs b/crates/library/src/db.rs new file mode 100644 index 0000000..15abdda --- /dev/null +++ b/crates/library/src/db.rs @@ -0,0 +1,735 @@ +//! SQLite-Ablage der Sammlung. +//! +//! Eine Datei, kein Server. Für eine DJ-Library ist das genau richtig: Sie liegt +//! neben der Musik, lässt sich kopieren und sichern wie jede andere Datei, und +//! sie ist beim Auflegen sofort da statt nach dem Start eines Dienstes. + +use std::path::Path; + +use rusqlite::{params, Connection, OptionalExtension, Row}; + +use crate::error::{LibraryError, Result}; +use crate::model::{CueKind, CueRecord, Query, Source, TrackRecord}; + +/// Version des Schemas. Bei Änderungen hochzählen und eine Migration ergänzen. +const SCHEMA_VERSION: i32 = 1; + +pub struct Library { + conn: Connection, +} + +impl Library { + pub fn open(path: impl AsRef) -> Result { + let library = Library { + conn: Connection::open(path)?, + }; + library.prepare()?; + Ok(library) + } + + pub fn open_in_memory() -> Result { + let library = Library { + conn: Connection::open_in_memory()?, + }; + library.prepare()?; + Ok(library) + } + + fn prepare(&self) -> Result<()> { + self.conn.pragma_update(None, "foreign_keys", "ON")?; + self.conn.pragma_update(None, "journal_mode", "WAL")?; + + let version: i32 = self + .conn + .pragma_query_value(None, "user_version", |row| row.get(0))?; + + if version < 1 { + self.migrate_to_v1()?; + } + + self.conn + .pragma_update(None, "user_version", SCHEMA_VERSION)?; + Ok(()) + } + + fn migrate_to_v1(&self) -> Result<()> { + self.conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS tracks ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + fingerprint TEXT, + title TEXT, + artist TEXT, + album TEXT, + genre TEXT, + duration_secs REAL, + bpm REAL, + beat_anchor_ms REAL, + musical_key TEXT, + license TEXT, + attribution TEXT, + source TEXT NOT NULL DEFAULT 'file', + added_at INTEGER NOT NULL + ); + + -- Kein UNIQUE: Derselbe Klang darf zweimal im Dateisystem liegen, + -- und das ist kein Fehler, den ein Constraint erzwingen sollte. + CREATE INDEX IF NOT EXISTS idx_tracks_fingerprint ON tracks(fingerprint); + CREATE INDEX IF NOT EXISTS idx_tracks_bpm ON tracks(bpm); + + CREATE TABLE IF NOT EXISTS cues ( + id INTEGER PRIMARY KEY, + track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + hotcue INTEGER, + position_ms REAL NOT NULL, + name TEXT, + kind TEXT NOT NULL DEFAULT 'cue' + ); + CREATE INDEX IF NOT EXISTS idx_cues_track ON cues(track_id); + + CREATE TABLE IF NOT EXISTS playlists ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS playlist_items ( + playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + PRIMARY KEY (playlist_id, position) + ); + "#, + )?; + Ok(()) + } + + /// Legt einen Track an oder aktualisiert ihn. Schlüssel ist der Pfad. + /// + /// Felder, die im übergebenen Datensatz `None` sind, überschreiben + /// vorhandene Werte **nicht** — sonst löschte ein erneuter Ordner-Scan die + /// mühsam erarbeitete Analyse wieder weg. + pub fn upsert_track(&self, track: &TrackRecord) -> Result { + let now = unix_now(); + + self.conn.execute( + r#" + INSERT INTO tracks + (path, fingerprint, title, artist, album, genre, duration_secs, + bpm, beat_anchor_ms, musical_key, license, attribution, source, added_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) + ON CONFLICT(path) DO UPDATE SET + fingerprint = COALESCE(excluded.fingerprint, tracks.fingerprint), + title = COALESCE(excluded.title, tracks.title), + artist = COALESCE(excluded.artist, tracks.artist), + album = COALESCE(excluded.album, tracks.album), + genre = COALESCE(excluded.genre, tracks.genre), + duration_secs = COALESCE(excluded.duration_secs, tracks.duration_secs), + bpm = COALESCE(excluded.bpm, tracks.bpm), + beat_anchor_ms = COALESCE(excluded.beat_anchor_ms, tracks.beat_anchor_ms), + musical_key = COALESCE(excluded.musical_key, tracks.musical_key), + license = COALESCE(excluded.license, tracks.license), + attribution = COALESCE(excluded.attribution, tracks.attribution), + source = excluded.source + "#, + params![ + track.path, + track.fingerprint, + track.title, + track.artist, + track.album, + track.genre, + track.duration_secs, + track.bpm, + track.beat_anchor_ms, + track.musical_key, + track.license, + track.attribution, + track.source.as_str(), + now, + ], + )?; + + self.track_id_by_path(&track.path)? + .ok_or_else(|| LibraryError::NotFound(track.path.clone())) + } + + pub fn track_id_by_path(&self, path: &str) -> Result> { + Ok(self + .conn + .query_row( + "SELECT id FROM tracks WHERE path = ?1", + params![path], + |r| r.get(0), + ) + .optional()?) + } + + pub fn track(&self, id: i64) -> Result> { + Ok(self + .conn + .query_row( + &format!("{SELECT_TRACK} WHERE id = ?1"), + params![id], + track_from_row, + ) + .optional()?) + } + + pub fn track_by_path(&self, path: &str) -> Result> { + Ok(self + .conn + .query_row( + &format!("{SELECT_TRACK} WHERE path = ?1"), + params![path], + track_from_row, + ) + .optional()?) + } + + /// Alle Tracks mit diesem Inhalts-Hash — mehr als einer heißt: Dublette. + pub fn tracks_by_fingerprint(&self, fingerprint: &str) -> Result> { + let mut stmt = self + .conn + .prepare(&format!("{SELECT_TRACK} WHERE fingerprint = ?1"))?; + let rows = stmt.query_map(params![fingerprint], track_from_row)?; + Ok(rows.collect::>>()?) + } + + pub fn track_count(&self) -> Result { + Ok(self + .conn + .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get::<_, i64>(0))? + as u64) + } + + pub fn search(&self, query: &Query) -> Result> { + let mut sql = String::from(SELECT_TRACK); + let mut wheres: Vec = Vec::new(); + let mut args: Vec> = Vec::new(); + + if let Some(text) = query.text.as_ref().filter(|t| !t.trim().is_empty()) { + // Drei Spalten, derselbe Wert — SQLite nummeriert Platzhalter, also + // dreimal binden statt einmal wiederverwenden. + let n = args.len(); + wheres.push(format!( + "(title LIKE ?{} OR artist LIKE ?{} OR album LIKE ?{})", + n + 1, + n + 2, + n + 3 + )); + + let muster = format!("%{text}%"); + args.push(Box::new(muster.clone())); + args.push(Box::new(muster.clone())); + args.push(Box::new(muster)); + } + + if let Some(min) = query.bpm_min { + wheres.push(format!("bpm >= ?{}", args.len() + 1)); + args.push(Box::new(min)); + } + if let Some(max) = query.bpm_max { + wheres.push(format!("bpm <= ?{}", args.len() + 1)); + args.push(Box::new(max)); + } + if let Some(genre) = query.genre.as_ref() { + wheres.push(format!("genre = ?{}", args.len() + 1)); + args.push(Box::new(genre.clone())); + } + if let Some(keys) = query.keys.as_ref().filter(|k| !k.is_empty()) { + // Groß/klein und Leerraum vereinheitlicht: In der Sammlung steht, + // was die Quelle geschrieben hat, und `am` meint dasselbe wie `Am`. + let platzhalter: Vec = (0..keys.len()) + .map(|i| format!("?{}", args.len() + i + 1)) + .collect(); + wheres.push(format!( + "UPPER(TRIM(musical_key)) IN ({})", + platzhalter.join(", ") + )); + for key in keys { + args.push(Box::new(key.trim().to_uppercase())); + } + } + + if !wheres.is_empty() { + sql.push_str(" WHERE "); + sql.push_str(&wheres.join(" AND ")); + } + sql.push_str(" ORDER BY artist, title"); + if let Some(limit) = query.limit { + sql.push_str(&format!(" LIMIT {limit}")); + } + + let mut stmt = self.conn.prepare(&sql)?; + let refs: Vec<&dyn rusqlite::ToSql> = args.iter().map(|a| a.as_ref()).collect(); + let rows = stmt.query_map(refs.as_slice(), track_from_row)?; + Ok(rows.collect::>>()?) + } + + /// Tracks, deren Rechteangaben fehlen, obwohl sie welche bräuchten. + pub fn tracks_missing_attribution(&self) -> Result> { + let mut stmt = self.conn.prepare(&format!( + "{SELECT_TRACK} WHERE source = 'sample' AND \ + (license IS NULL OR TRIM(license) = '' OR \ + attribution IS NULL OR TRIM(attribution) = '')" + ))?; + let rows = stmt.query_map([], track_from_row)?; + Ok(rows.collect::>>()?) + } + + /// Ersetzt alle Marker eines Tracks. + /// + /// **Alle** heißt alle — auch Grid-Anker und Fade-Marker. Wer nur die Hot + /// Cues eines Decks zurückschreiben will, nimmt [`Library::replace_hot_cues`], + /// sonst ist der importierte Grid-Marker weg. + pub fn replace_cues(&self, track_id: i64, cues: &[CueRecord]) -> Result<()> { + let tx = self.conn.unchecked_transaction()?; + tx.execute("DELETE FROM cues WHERE track_id = ?1", params![track_id])?; + + for cue in cues { + tx.execute( + "INSERT INTO cues (track_id, hotcue, position_ms, name, kind) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + track_id, + cue.hotcue.map(|h| h as i64), + cue.position_ms, + cue.name, + cue.kind.as_str(), + ], + )?; + } + + tx.commit()?; + Ok(()) + } + + /// Ersetzt nur die Hot Cues eines Tracks und lässt alles andere stehen. + /// + /// Das ist der Weg, den ein Deck nimmt, wenn jemand einen Cue setzt oder + /// löscht. Ein Deck kennt acht Tasten und sonst nichts — es weiß nichts vom + /// Grid-Anker, den der Traktor-Import in dieselbe Tabelle legt, und von + /// Fade-Markern erst recht nicht. Würde es `replace_cues` benutzen, wäre + /// beides beim ersten gesetzten Cue verloren. + pub fn replace_hot_cues(&self, track_id: i64, hot: &[CueRecord]) -> Result<()> { + let tx = self.conn.unchecked_transaction()?; + tx.execute( + "DELETE FROM cues WHERE track_id = ?1 AND hotcue IS NOT NULL", + params![track_id], + )?; + + for cue in hot { + tx.execute( + "INSERT INTO cues (track_id, hotcue, position_ms, name, kind) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + track_id, + cue.hotcue.map(|h| h as i64), + cue.position_ms, + cue.name, + cue.kind.as_str(), + ], + )?; + } + + tx.commit()?; + Ok(()) + } + + pub fn cues(&self, track_id: i64) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, track_id, hotcue, position_ms, name, kind FROM cues \ + WHERE track_id = ?1 ORDER BY position_ms", + )?; + let rows = stmt.query_map(params![track_id], |row| { + Ok(CueRecord { + id: row.get(0)?, + track_id: row.get(1)?, + hotcue: row.get::<_, Option>(2)?.map(|v| v as u8), + position_ms: row.get(3)?, + name: row.get(4)?, + kind: CueKind::parse(&row.get::<_, String>(5)?), + }) + })?; + Ok(rows.collect::>>()?) + } + + pub fn create_playlist(&self, name: &str) -> Result { + self.conn.execute( + "INSERT OR IGNORE INTO playlists (name, created_at) VALUES (?1, ?2)", + params![name, unix_now()], + )?; + self.conn + .query_row( + "SELECT id FROM playlists WHERE name = ?1", + params![name], + |r| r.get(0), + ) + .map_err(Into::into) + } + + pub fn playlists(&self) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT id, name FROM playlists ORDER BY name")?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; + Ok(rows.collect::>>()?) + } + + /// Hängt einen Track ans Ende einer Playlist. + pub fn add_to_playlist(&self, playlist_id: i64, track_id: i64) -> Result<()> { + let next: i64 = self.conn.query_row( + "SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_items WHERE playlist_id = ?1", + params![playlist_id], + |r| r.get(0), + )?; + + self.conn.execute( + "INSERT INTO playlist_items (playlist_id, track_id, position) VALUES (?1, ?2, ?3)", + params![playlist_id, track_id, next], + )?; + Ok(()) + } + + pub fn playlist_tracks(&self, playlist_id: i64) -> Result> { + let mut stmt = self.conn.prepare(&format!( + "{SELECT_TRACK_PREFIXED} JOIN playlist_items pi ON pi.track_id = t.id \ + WHERE pi.playlist_id = ?1 ORDER BY pi.position" + ))?; + let rows = stmt.query_map(params![playlist_id], track_from_row)?; + Ok(rows.collect::>>()?) + } +} + +const SELECT_TRACK: &str = "SELECT id, path, fingerprint, title, artist, album, genre, \ + duration_secs, bpm, beat_anchor_ms, musical_key, \ + license, attribution, source FROM tracks"; + +const SELECT_TRACK_PREFIXED: &str = "SELECT t.id, t.path, t.fingerprint, t.title, t.artist, \ + t.album, t.genre, t.duration_secs, t.bpm, \ + t.beat_anchor_ms, t.musical_key, t.license, \ + t.attribution, t.source FROM tracks t"; + +fn track_from_row(row: &Row) -> rusqlite::Result { + Ok(TrackRecord { + id: row.get(0)?, + path: row.get(1)?, + fingerprint: row.get(2)?, + title: row.get(3)?, + artist: row.get(4)?, + album: row.get(5)?, + genre: row.get(6)?, + duration_secs: row.get(7)?, + bpm: row.get(8)?, + beat_anchor_ms: row.get(9)?, + musical_key: row.get(10)?, + license: row.get(11)?, + attribution: row.get(12)?, + source: Source::parse(&row.get::<_, String>(13)?), + }) +} + +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use audio_core::{Beatgrid, Tonart}; + + fn track(path: &str, artist: &str, title: &str, bpm: Option) -> TrackRecord { + let mut t = TrackRecord::from_path(path); + t.artist = Some(artist.into()); + t.title = Some(title.into()); + t.bpm = bpm; + t + } + + #[test] + fn anlegen_und_wiederfinden() { + let lib = Library::open_in_memory().unwrap(); + let id = lib + .upsert_track(&track("/a.mp3", "Rebekah", "Fear Paralysis", Some(135.0))) + .unwrap(); + + let geladen = lib.track(id).unwrap().expect("Track weg"); + assert_eq!(geladen.artist.as_deref(), Some("Rebekah")); + assert_eq!(geladen.path, "/a.mp3"); + assert_eq!(lib.track_count().unwrap(), 1); + } + + #[test] + fn erneuter_scan_loescht_die_analyse_nicht() { + // Der wichtigste Punkt am Upsert: Ein Ordner-Scan kennt nur Pfad und + // Tags. Würde er die Analyse überschreiben, wäre sie nach jedem Scan + // weg — und Analysieren ist die teure Arbeit. + let lib = Library::open_in_memory().unwrap(); + + let mut voll = track("/a.mp3", "Rebekah", "Fear Paralysis", Some(135.0)); + voll.set_beatgrid(Some(Beatgrid::new(135.0, 4_410, 1.0)), 44_100); + voll.fingerprint = Some("abc123".into()); + lib.upsert_track(&voll).unwrap(); + + // Zweiter Durchlauf ohne Analysedaten. + let mager = TrackRecord::from_path("/a.mp3"); + lib.upsert_track(&mager).unwrap(); + + let geladen = lib.track_by_path("/a.mp3").unwrap().unwrap(); + assert_eq!(geladen.bpm, Some(135.0), "BPM überschrieben"); + assert_eq!(geladen.fingerprint.as_deref(), Some("abc123")); + assert_eq!(geladen.artist.as_deref(), Some("Rebekah")); + assert_eq!(lib.track_count().unwrap(), 1, "Dublette angelegt"); + } + + #[test] + fn suche_nach_text_trifft_titel_und_kuenstler() { + let lib = Library::open_in_memory().unwrap(); + lib.upsert_track(&track("/a.mp3", "Rebekah", "Fear Paralysis", Some(135.0))) + .unwrap(); + lib.upsert_track(&track("/b.mp3", "Len Faki", "My Black Sheep", Some(128.0))) + .unwrap(); + + assert_eq!(lib.search(&Query::text("rebekah")).unwrap().len(), 1); + assert_eq!(lib.search(&Query::text("Sheep")).unwrap().len(), 1); + assert_eq!(lib.search(&Query::text("zzz")).unwrap().len(), 0); + assert_eq!(lib.search(&Query::default()).unwrap().len(), 2); + } + + #[test] + fn suche_nach_mixbarem_tempo() { + let lib = Library::open_in_memory().unwrap(); + // 135 BPM läge bei ±6 % noch im Fenster — für diesen Test muss der + // Ausreißer deutlich außerhalb liegen. + lib.upsert_track(&track("/a.mp3", "A", "zu schnell", Some(150.0))) + .unwrap(); + lib.upsert_track(&track("/b.mp3", "B", "passend", Some(129.0))) + .unwrap(); + lib.upsert_track(&track("/c.mp3", "C", "ohne Tempo", None)) + .unwrap(); + + let treffer = lib.search(&Query::mixable_with(128.0, 0.06)).unwrap(); + let titel: Vec<_> = treffer.iter().filter_map(|t| t.title.as_deref()).collect(); + + assert_eq!(titel, vec!["passend"], "unerwartete Treffer: {titel:?}"); + } + + #[test] + fn suche_nach_harmonisch_passenden_tonarten() { + let lib = Library::open_in_memory().unwrap(); + + let mit_key = |pfad: &str, titel: &str, key: &str| { + let mut t = track(pfad, "A", titel, Some(128.0)); + t.musical_key = Some(key.into()); + lib.upsert_track(&t).unwrap(); + }; + + // a-Moll ist 8A. Verwandt sind 8B (C-Dur), 7A (d-Moll) und 9A (e-Moll). + mit_key("/a.mp3", "gleiche Tonart", "Am"); + mit_key("/b.mp3", "Parallele", "C"); + mit_key("/c.mp3", "Nachbar", "Em"); + // Dieselbe Tonart in Camelot geschrieben — die Sammlung enthält, was + // die Quelle geschrieben hat, und Traktor schreibt gern so. + mit_key("/d.mp3", "Camelot geschrieben", "7a"); + mit_key("/e.mp3", "weit weg", "D#m"); + mit_key("/f.mp3", "unlesbar", "H-Dur irgendwie"); + lib.upsert_track(&track("/g.mp3", "A", "ohne Tonart", Some(128.0))) + .unwrap(); + + let a_moll = Tonart::parse("Am").unwrap(); + let treffer = lib.search(&Query::harmonic_with(a_moll)).unwrap(); + let mut titel: Vec<_> = treffer.iter().filter_map(|t| t.title.as_deref()).collect(); + titel.sort_unstable(); + + assert_eq!( + titel, + vec![ + "Camelot geschrieben", + "Nachbar", + "Parallele", + "gleiche Tonart" + ], + "unerwartete Treffer: {titel:?}" + ); + } + + #[test] + fn dubletten_werden_ueber_den_hash_gefunden() { + let lib = Library::open_in_memory().unwrap(); + + let mut a = track("/ordner1/x.mp3", "A", "X", Some(128.0)); + a.fingerprint = Some("gleich".into()); + let mut b = track("/ordner2/x.mp3", "A", "X", Some(128.0)); + b.fingerprint = Some("gleich".into()); + + lib.upsert_track(&a).unwrap(); + lib.upsert_track(&b).unwrap(); + + let dubletten = lib.tracks_by_fingerprint("gleich").unwrap(); + assert_eq!(dubletten.len(), 2, "derselbe Klang darf zweimal liegen"); + } + + #[test] + fn fehlende_rechteangaben_lassen_sich_auflisten() { + let lib = Library::open_in_memory().unwrap(); + + let mut ohne = TrackRecord::from_path("/kick.wav"); + ohne.source = Source::Sample; + lib.upsert_track(&ohne).unwrap(); + + let mut mit = TrackRecord::from_path("/hat.wav"); + mit.source = Source::Sample; + mit.license = Some("CC BY 4.0".into()); + mit.attribution = Some("jemand".into()); + lib.upsert_track(&mit).unwrap(); + + lib.upsert_track(&TrackRecord::from_path("/eigen.mp3")) + .unwrap(); + + let luecken = lib.tracks_missing_attribution().unwrap(); + assert_eq!(luecken.len(), 1); + assert_eq!(luecken[0].path, "/kick.wav"); + } + + #[test] + fn cues_werden_ersetzt_nicht_angehaeuft() { + let lib = Library::open_in_memory().unwrap(); + let id = lib + .upsert_track(&track("/a.mp3", "A", "X", Some(128.0))) + .unwrap(); + + let cue = |hot: u8, ms: f64| CueRecord { + id: None, + track_id: id, + hotcue: Some(hot), + position_ms: ms, + name: None, + kind: CueKind::Cue, + }; + + lib.replace_cues(id, &[cue(0, 1_000.0), cue(1, 2_000.0)]) + .unwrap(); + assert_eq!(lib.cues(id).unwrap().len(), 2); + + lib.replace_cues(id, &[cue(0, 500.0)]).unwrap(); + let cues = lib.cues(id).unwrap(); + assert_eq!(cues.len(), 1, "alte Cues blieben stehen"); + assert_eq!(cues[0].position_ms, 500.0); + assert_eq!(cues[0].frame(48_000), 24_000); + } + + /// Ein Deck darf beim Cue-Setzen nicht den Grid-Anker mitnehmen. + /// + /// Beide liegen in derselben Tabelle, und ein Deck kennt nur seine acht + /// Tasten. Mit `replace_cues` wäre der importierte Anker beim ersten + /// gesetzten Cue weg — und damit die Arbeit, die der Traktor-Import + /// gemacht hat. + #[test] + fn hot_cues_zurueckschreiben_laesst_den_grid_marker_stehen() { + let lib = Library::open_in_memory().unwrap(); + let id = lib + .upsert_track(&track("/a.mp3", "A", "X", Some(128.0))) + .unwrap(); + + let marker = CueRecord { + id: None, + track_id: id, + hotcue: None, + position_ms: 42.0, + name: Some("Grid".into()), + kind: CueKind::Grid, + }; + let hot = |nummer: u8, ms: f64| CueRecord { + id: None, + track_id: id, + hotcue: Some(nummer), + position_ms: ms, + name: None, + kind: CueKind::Cue, + }; + + lib.replace_cues(id, &[marker.clone(), hot(0, 1_000.0)]) + .unwrap(); + + // Das Deck schreibt seine acht Tasten zurück — mehr kennt es nicht. + lib.replace_hot_cues(id, &[hot(0, 500.0), hot(3, 9_000.0)]) + .unwrap(); + + let cues = lib.cues(id).unwrap(); + let grid: Vec<_> = cues.iter().filter(|c| c.kind == CueKind::Grid).collect(); + assert_eq!(grid.len(), 1, "der Grid-Marker ist weg: {cues:?}"); + assert_eq!(grid[0].position_ms, 42.0); + + let mut nummern: Vec<_> = cues.iter().filter_map(|c| c.hotcue).collect(); + nummern.sort_unstable(); + assert_eq!(nummern, vec![0, 3]); + } + + #[test] + fn playlists_behalten_ihre_reihenfolge() { + let lib = Library::open_in_memory().unwrap(); + let a = lib + .upsert_track(&track("/a.mp3", "A", "erst", None)) + .unwrap(); + let b = lib + .upsert_track(&track("/b.mp3", "B", "dann", None)) + .unwrap(); + let c = lib + .upsert_track(&track("/c.mp3", "C", "zuletzt", None)) + .unwrap(); + + let pl = lib.create_playlist("Warmup").unwrap(); + lib.add_to_playlist(pl, c).unwrap(); + lib.add_to_playlist(pl, a).unwrap(); + lib.add_to_playlist(pl, b).unwrap(); + + let titel: Vec<_> = lib + .playlist_tracks(pl) + .unwrap() + .into_iter() + .filter_map(|t| t.title) + .collect(); + + assert_eq!(titel, vec!["zuletzt", "erst", "dann"]); + assert_eq!(lib.playlists().unwrap().len(), 1); + } + + #[test] + fn playlist_mit_gleichem_namen_wird_nicht_verdoppelt() { + let lib = Library::open_in_memory().unwrap(); + let a = lib.create_playlist("Set").unwrap(); + let b = lib.create_playlist("Set").unwrap(); + + assert_eq!(a, b); + assert_eq!(lib.playlists().unwrap().len(), 1); + } + + #[test] + fn geloeschte_tracks_nehmen_ihre_cues_mit() { + let lib = Library::open_in_memory().unwrap(); + let id = lib.upsert_track(&track("/a.mp3", "A", "X", None)).unwrap(); + lib.replace_cues( + id, + &[CueRecord { + id: None, + track_id: id, + hotcue: Some(0), + position_ms: 100.0, + name: None, + kind: CueKind::Cue, + }], + ) + .unwrap(); + + lib.conn + .execute("DELETE FROM tracks WHERE id = ?1", params![id]) + .unwrap(); + + assert!(lib.cues(id).unwrap().is_empty(), "verwaiste Cues geblieben"); + } +} diff --git a/crates/library/src/error.rs b/crates/library/src/error.rs new file mode 100644 index 0000000..589a8b8 --- /dev/null +++ b/crates/library/src/error.rs @@ -0,0 +1,21 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum LibraryError { + #[error("Datenbankfehler: {0}")] + Db(#[from] rusqlite::Error), + + #[error("Datei konnte nicht gelesen werden: {0}")] + Io(#[from] std::io::Error), + + #[error("XML konnte nicht gelesen werden: {0}")] + Xml(#[from] quick_xml::Error), + + #[error("nicht gefunden: {0}")] + NotFound(String), + + #[error("unerwartetes Format: {0}")] + Format(String), +} + +pub type Result = std::result::Result; diff --git a/crates/library/src/lib.rs b/crates/library/src/lib.rs new file mode 100644 index 0000000..3ce5388 --- /dev/null +++ b/crates/library/src/lib.rs @@ -0,0 +1,26 @@ +//! Die Sammlung: Tracks, Marker, Playlists — und der Weg herein aus Traktor. +//! +//! Getrennt von Engine und Analyse, weil hier keine Echtzeitregeln gelten. +//! Datenbankzugriffe dürfen blockieren; im Audio-Callback hat davon nichts +//! etwas zu suchen. +//! +//! Zwei Entwurfsentscheidungen prägen alles Weitere: +//! +//! **Positionen stehen in Millisekunden**, nicht in Frames. Die Library ist die +//! Tauschschicht zwischen Analyse, Traktor-Import und Deck. Ein Frame-Wert ohne +//! zugehörige Samplerate ist mehrdeutig, und ein resampelter Track hätte andere +//! Zahlen für denselben Zeitpunkt. +//! +//! **Lizenz und Urheber stehen von der ersten Migration an in der Tabelle.** +//! CC BY verlangt Namensnennung auch nicht-kommerziell, und die Herkunft von +//! tausend Samples lässt sich nachträglich nicht rekonstruieren. + +pub mod db; +pub mod error; +pub mod model; +pub mod traktor; + +pub use db::Library; +pub use error::{LibraryError, Result}; +pub use model::{CueKind, CueRecord, Query, Source, TrackRecord}; +pub use traktor::{import_nml, ImportReport}; diff --git a/crates/library/src/model.rs b/crates/library/src/model.rs new file mode 100644 index 0000000..a364da2 --- /dev/null +++ b/crates/library/src/model.rs @@ -0,0 +1,340 @@ +//! Datenmodell der Library. + +use audio_core::{Beatgrid, Tonart}; + +/// Woher ein Track stammt. Bestimmt mit, welche Rechtepflichten daran hängen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Source { + /// Eigene Datei auf der Platte. + File, + /// Aus einer Sample-Datenbank geladen — Lizenz und Urheber sind Pflichtfelder. + Sample, + /// Von einem Generierungsdienst erzeugt. + Generated, +} + +impl Source { + pub fn as_str(&self) -> &'static str { + match self { + Source::File => "file", + Source::Sample => "sample", + Source::Generated => "generated", + } + } + + pub fn parse(raw: &str) -> Source { + match raw { + "sample" => Source::Sample, + "generated" => Source::Generated, + _ => Source::File, + } + } +} + +/// Ein Track in der Sammlung. +#[derive(Debug, Clone, PartialEq)] +pub struct TrackRecord { + pub id: Option, + /// Inhalts-Hash aus der Analyse. `None`, solange nicht analysiert. + pub fingerprint: Option, + pub path: String, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub duration_secs: Option, + pub bpm: Option, + /// Lage des ersten Beats in Millisekunden. + /// + /// Millisekunden statt Frames, weil die Library die Tauschschicht ist: + /// Traktor speichert so, ein resampelter Track hätte andere Frame-Zahlen, + /// und ein Wert ohne zugehörige Samplerate wäre mehrdeutig. + pub beat_anchor_ms: Option, + /// Tonart als Text, wie sie in der Quelle stand. + pub musical_key: Option, + + /// Lizenz und Urheber. + /// + /// **Pflichtfelder für alles aus Sample-Datenbanken.** CC BY verlangt + /// Namensnennung auch bei nicht-kommerzieller Nutzung, und nachträglich + /// lässt sich die Herkunft nicht mehr rekonstruieren, wenn erst tausend + /// Dateien ohne sie im Ordner liegen. Deshalb stehen sie hier von der + /// ersten Migration an und nicht in einer späteren. + pub license: Option, + pub attribution: Option, + + pub source: Source, +} + +impl TrackRecord { + /// Neuer Eintrag aus einem Dateipfad, ohne Metadaten. + pub fn from_path(path: impl Into) -> Self { + TrackRecord { + id: None, + fingerprint: None, + path: path.into(), + title: None, + artist: None, + album: None, + genre: None, + duration_secs: None, + bpm: None, + beat_anchor_ms: None, + musical_key: None, + license: None, + attribution: None, + source: Source::File, + } + } + + pub fn beatgrid(&self, sample_rate: u32) -> Option { + let bpm = self.bpm?; + let anchor = ms_to_frames(self.beat_anchor_ms.unwrap_or(0.0), sample_rate); + let grid = Beatgrid::new(bpm, anchor, 1.0); + grid.is_usable().then_some(grid) + } + + pub fn set_beatgrid(&mut self, grid: Option, sample_rate: u32) { + match grid { + Some(g) => { + self.bpm = Some(g.bpm); + self.beat_anchor_ms = Some(frames_to_ms(g.anchor_frames, sample_rate)); + } + None => { + self.bpm = None; + self.beat_anchor_ms = None; + } + } + } + + /// Ob die Rechtelage vollständig ist. + /// + /// Für eigene Dateien belanglos, für Samples entscheidend — und deshalb + /// abfragbar, statt nur dokumentiert zu sein. + pub fn attribution_complete(&self) -> bool { + match self.source { + Source::Sample => { + self.license.as_ref().is_some_and(|s| !s.trim().is_empty()) + && self + .attribution + .as_ref() + .is_some_and(|s| !s.trim().is_empty()) + } + _ => true, + } + } +} + +/// Ein Hot Cue oder Grid-Marker. +#[derive(Debug, Clone, PartialEq)] +pub struct CueRecord { + pub id: Option, + pub track_id: i64, + /// Hot-Cue-Nummer, `None` bei Markern ohne Taste (etwa dem Grid-Anker). + pub hotcue: Option, + /// Position in Millisekunden — siehe [`TrackRecord::beat_anchor_ms`]. + pub position_ms: f64, + pub name: Option, + pub kind: CueKind, +} + +impl CueRecord { + pub fn frame(&self, sample_rate: u32) -> u64 { + ms_to_frames(self.position_ms, sample_rate) + } +} + +/// Millisekunden in Sample-Frames. Negatives wird auf null gezogen. +pub fn ms_to_frames(ms: f64, sample_rate: u32) -> u64 { + (ms.max(0.0) / 1_000.0 * sample_rate as f64).round() as u64 +} + +pub fn frames_to_ms(frames: u64, sample_rate: u32) -> f64 { + frames as f64 * 1_000.0 / sample_rate as f64 +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CueKind { + Cue, + Grid, + Loop, + FadeIn, + FadeOut, + Load, +} + +impl CueKind { + pub fn as_str(&self) -> &'static str { + match self { + CueKind::Cue => "cue", + CueKind::Grid => "grid", + CueKind::Loop => "loop", + CueKind::FadeIn => "fade_in", + CueKind::FadeOut => "fade_out", + CueKind::Load => "load", + } + } + + pub fn parse(raw: &str) -> CueKind { + match raw { + "grid" => CueKind::Grid, + "loop" => CueKind::Loop, + "fade_in" => CueKind::FadeIn, + "fade_out" => CueKind::FadeOut, + "load" => CueKind::Load, + _ => CueKind::Cue, + } + } +} + +/// Suchanfrage an die Library. +#[derive(Debug, Clone, Default)] +pub struct Query { + /// Freitext über Titel, Künstler und Album. + pub text: Option, + pub bpm_min: Option, + pub bpm_max: Option, + pub genre: Option, + /// Zulässige Tonarten, in allen Schreibweisen, die gemeint sein können. + /// + /// Eine Liste statt eines einzelnen Werts, weil harmonisch nie genau eine + /// Tonart passt — siehe [`Query::harmonic_with`]. + pub keys: Option>, + pub limit: Option, +} + +impl Query { + pub fn text(query: impl Into) -> Self { + Query { + text: Some(query.into()), + ..Default::default() + } + } + + /// Tracks, die tempomäßig zu `bpm` passen — Standard sind ±6 %, der + /// Bereich, den ein Pitchfader ohne hörbare Verfärbung hergibt. + pub fn mixable_with(bpm: f32, toleranz: f32) -> Self { + Query { + bpm_min: Some(bpm * (1.0 - toleranz)), + bpm_max: Some(bpm * (1.0 + toleranz)), + ..Default::default() + } + } + + /// Tracks, deren Tonart harmonisch zu `tonart` passt. + /// + /// Gesucht wird über beide Schreibweisen — `Am` und `8A` —, weil in der + /// Sammlung steht, was die Quelle geschrieben hat: Die eigene Analyse legt + /// Namen ab, Traktor schreibt mal das eine, mal das andere. Eine dritte + /// Schreibweise (`A min` etwa) wird nicht gefunden; das ist eine bewusste + /// Grenze und keine stille. + pub fn harmonic_with(tonart: Tonart) -> Self { + let keys = tonart + .verwandte() + .iter() + .flat_map(|t| [t.name(), t.camelot()]) + .collect(); + + Query { + keys: Some(keys), + ..Default::default() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const RATE: u32 = 44_100; + + #[test] + fn beatgrid_geht_hin_und_zurueck() { + let mut track = TrackRecord::from_path("/a.mp3"); + assert!(track.beatgrid(RATE).is_none()); + + track.set_beatgrid(Some(Beatgrid::new(128.0, 4_410, 1.0)), RATE); + assert_eq!(track.beat_anchor_ms, Some(100.0)); + + let grid = track.beatgrid(RATE).expect("kein Grid"); + assert!((grid.bpm - 128.0).abs() < 1e-6); + assert_eq!(grid.anchor_frames, 4_410); + + track.set_beatgrid(None, RATE); + assert!(track.beatgrid(RATE).is_none()); + } + + #[test] + fn millisekunden_ueberstehen_einen_ratenwechsel() { + // Derselbe Zeitpunkt, zwei Samplerates — genau dafür ist ms da. + let mut track = TrackRecord::from_path("/a.mp3"); + track.set_beatgrid(Some(Beatgrid::new(120.0, 4_410, 1.0)), 44_100); + + let bei_48k = track.beatgrid(48_000).unwrap(); + assert_eq!(bei_48k.anchor_frames, 4_800); + } + + #[test] + fn negative_positionen_werden_abgefangen() { + assert_eq!(ms_to_frames(-5.0, 48_000), 0); + assert_eq!(ms_to_frames(0.0, 48_000), 0); + assert_eq!(ms_to_frames(1_000.0, 48_000), 48_000); + } + + #[test] + fn samples_brauchen_lizenz_und_urheber() { + let mut track = TrackRecord::from_path("/kick.wav"); + track.source = Source::Sample; + assert!( + !track.attribution_complete(), + "Sample ohne Rechte durchgelassen" + ); + + track.license = Some("CC BY 4.0".into()); + assert!(!track.attribution_complete(), "Urheber fehlt noch"); + + track.attribution = Some("jemand auf Freesound".into()); + assert!(track.attribution_complete()); + } + + #[test] + fn leerzeichen_zaehlen_nicht_als_angabe() { + let mut track = TrackRecord::from_path("/kick.wav"); + track.source = Source::Sample; + track.license = Some(" ".into()); + track.attribution = Some("".into()); + + assert!(!track.attribution_complete()); + } + + #[test] + fn eigene_dateien_brauchen_keine_angaben() { + let track = TrackRecord::from_path("/eigener-track.mp3"); + assert_eq!(track.source, Source::File); + assert!(track.attribution_complete()); + } + + #[test] + fn mixbarer_bereich_umfasst_das_tempo() { + let q = Query::mixable_with(128.0, 0.06); + assert!((q.bpm_min.unwrap() - 120.32).abs() < 0.01); + assert!((q.bpm_max.unwrap() - 135.68).abs() < 0.01); + } + + #[test] + fn quellen_und_marker_ueberleben_die_textform() { + for s in [Source::File, Source::Sample, Source::Generated] { + assert_eq!(Source::parse(s.as_str()), s); + } + for k in [ + CueKind::Cue, + CueKind::Grid, + CueKind::Loop, + CueKind::FadeIn, + CueKind::FadeOut, + CueKind::Load, + ] { + assert_eq!(CueKind::parse(k.as_str()), k); + } + } +} diff --git a/crates/library/src/traktor.rs b/crates/library/src/traktor.rs new file mode 100644 index 0000000..6310482 --- /dev/null +++ b/crates/library/src/traktor.rs @@ -0,0 +1,421 @@ +//! Import einer Traktor-Sammlung (`collection.nml`). +//! +//! Wer von Traktor kommt, bringt Jahre an Cue-Points und Beatgrids mit. Die neu +//! zu setzen wäre die unangenehmste Art, ein neues Werkzeug zu beginnen — +//! deshalb steht der Import früh und nicht am Ende. +//! +//! Die `.nml` ist XML und enthält **keine Audiodaten**, nur Pfade und +//! Metadaten. Positionen stehen darin in Millisekunden, was zur Ablage in +//! dieser Library passt. +//! +//! ## Was hier ungeprüft ist +//! +//! Das Format ist nicht offiziell dokumentiert. Feldbedeutungen stammen aus +//! öffentlichen Beschreibungen und sind hier **nur gegen selbst geschriebene +//! Beispiele getestet**, nicht gegen eine echte Traktor-Sammlung. Betroffen +//! sind vor allem: +//! +//! - die Zuordnung der `CUE_V2`-Typen (siehe [`cue_kind`]), +//! - die Nummerierung in `MUSICAL_KEY` (siehe [`traktor_key_name`]), +//! - der Umgang mit `VOLUME` unter Windows (siehe [`build_path`]). +//! +//! Der erste Lauf gegen eine echte Sammlung gehört deshalb stichprobenartig +//! nachgesehen, bevor man sich darauf verlässt. + +use quick_xml::events::{BytesStart, Event}; +use quick_xml::{Reader, XmlVersion}; + +use crate::db::Library; +use crate::error::Result; +use crate::model::{CueKind, CueRecord, Source, TrackRecord}; + +/// Was der Import getan hat. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ImportReport { + pub entries_seen: usize, + pub tracks_imported: usize, + pub cues_imported: usize, + /// Einträge ohne verwertbaren Pfad — die gibt es in echten Sammlungen. + pub skipped_without_location: usize, +} + +/// Ein gelesener Eintrag, noch ohne Datenbank-ID. +#[derive(Debug, Clone, PartialEq)] +pub struct ImportedEntry { + pub track: TrackRecord, + pub cues: Vec, +} + +/// Liest eine `collection.nml` und schreibt sie in die Library. +pub fn import_nml(xml: &str, library: &Library) -> Result { + let entries = parse_nml(xml)?; + let mut report = ImportReport { + entries_seen: entries.len(), + ..Default::default() + }; + + for entry in entries { + if entry.track.path.is_empty() { + report.skipped_without_location += 1; + continue; + } + + let id = library.upsert_track(&entry.track)?; + report.tracks_imported += 1; + + if !entry.cues.is_empty() { + let cues: Vec = entry + .cues + .into_iter() + .map(|mut c| { + c.track_id = id; + c + }) + .collect(); + report.cues_imported += cues.len(); + library.replace_cues(id, &cues)?; + } + } + + Ok(report) +} + +/// Reines Parsen ohne Datenbank — der testbare Teil. +pub fn parse_nml(xml: &str) -> Result> { + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(true); + + let mut entries = Vec::new(); + let mut current: Option = None; + let mut dir = String::new(); + let mut file = String::new(); + let mut volume = String::new(); + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf)? { + Event::Start(e) | Event::Empty(e) => { + let name = e.local_name(); + match name.as_ref() { + b"ENTRY" => { + let mut track = TrackRecord::from_path(""); + track.title = attr(&e, b"TITLE"); + track.artist = attr(&e, b"ARTIST"); + current = Some(ImportedEntry { + track, + cues: Vec::new(), + }); + dir.clear(); + file.clear(); + volume.clear(); + } + b"LOCATION" => { + dir = attr(&e, b"DIR").unwrap_or_default(); + file = attr(&e, b"FILE").unwrap_or_default(); + volume = attr(&e, b"VOLUME").unwrap_or_default(); + } + b"ALBUM" => { + if let Some(entry) = current.as_mut() { + entry.track.album = attr(&e, b"TITLE"); + } + } + b"INFO" => { + if let Some(entry) = current.as_mut() { + entry.track.genre = attr(&e, b"GENRE"); + entry.track.musical_key = attr(&e, b"KEY"); + entry.track.duration_secs = attr(&e, b"PLAYTIME_FLOAT") + .and_then(|v| v.parse().ok()) + .or_else(|| { + attr(&e, b"PLAYTIME").and_then(|v| v.parse::().ok()) + }); + } + } + b"TEMPO" => { + if let Some(entry) = current.as_mut() { + entry.track.bpm = attr(&e, b"BPM").and_then(|v| v.parse().ok()); + } + } + b"MUSICAL_KEY" => { + if let Some(entry) = current.as_mut() { + if entry.track.musical_key.is_none() { + entry.track.musical_key = attr(&e, b"VALUE") + .and_then(|v| v.parse::().ok()) + .and_then(traktor_key_name) + .map(str::to_string); + } + } + } + b"CUE_V2" => { + if let Some(entry) = current.as_mut() { + if let Some(cue) = read_cue(&e) { + // Der Grid-Marker ist kein Cue, sondern der + // Beat-Anker — er gehört an den Track. + if cue.kind == CueKind::Grid { + entry.track.beat_anchor_ms = Some(cue.position_ms); + } + entry.cues.push(cue); + } + } + } + _ => {} + } + } + Event::End(e) if e.local_name().as_ref() == b"ENTRY" => { + if let Some(mut entry) = current.take() { + entry.track.path = build_path(&volume, &dir, &file); + entry.track.source = Source::File; + entries.push(entry); + } + } + Event::Eof => break, + _ => {} + } + buf.clear(); + } + + Ok(entries) +} + +fn attr(element: &BytesStart, name: &[u8]) -> Option { + element + .attributes() + .flatten() + .find(|a| a.key.local_name().as_ref() == name) + .and_then(|a| a.normalized_value(XmlVersion::Explicit1_0).ok()) + .map(|v| v.into_owned()) + .filter(|v| !v.is_empty()) +} + +fn read_cue(element: &BytesStart) -> Option { + let start: f64 = attr(element, b"START")?.parse().ok()?; + let typ: i32 = attr(element, b"TYPE") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let hotcue: i32 = attr(element, b"HOTCUE") + .and_then(|v| v.parse().ok()) + .unwrap_or(-1); + + Some(CueRecord { + id: None, + track_id: 0, + hotcue: (0..=127).contains(&hotcue).then_some(hotcue as u8), + position_ms: start, + name: attr(element, b"NAME"), + kind: cue_kind(typ), + }) +} + +/// Typnummern der `CUE_V2`-Marker. +/// +/// Aus öffentlichen Beschreibungen des Formats; gegen eine echte Sammlung +/// ungeprüft. Unbekannte Werte landen als gewöhnlicher Cue, was schlimmstenfalls +/// einen Marker zu viel auf einer Taste bedeutet — nie einen Datenverlust. +fn cue_kind(typ: i32) -> CueKind { + match typ { + 1 => CueKind::FadeIn, + 2 => CueKind::FadeOut, + 3 => CueKind::Load, + 4 => CueKind::Grid, + 5 => CueKind::Loop, + _ => CueKind::Cue, + } +} + +/// Tonartnummern aus `MUSICAL_KEY`. +/// +/// 0–11 Dur aufsteigend ab C, 12–23 Moll ebenso. Aus öffentlichen +/// Beschreibungen; gegen eine echte Sammlung ungeprüft. Steht in `INFO@KEY` ein +/// Text, wird der bevorzugt — der stammt direkt aus Traktor. +fn traktor_key_name(value: u8) -> Option<&'static str> { + const DUR: [&str; 12] = [ + "C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B", + ]; + const MOLL: [&str; 12] = [ + "Cm", "Dbm", "Dm", "Ebm", "Em", "Fm", "Gbm", "Gm", "Abm", "Am", "Bbm", "Bm", + ]; + + match value { + 0..=11 => Some(DUR[value as usize]), + 12..=23 => Some(MOLL[(value - 12) as usize]), + _ => None, + } +} + +/// Baut den Dateipfad aus `VOLUME`, `DIR` und `FILE`. +/// +/// Traktor trennt Verzeichnisse mit `/:` statt mit `/`. `VOLUME` ist unter +/// Windows der Laufwerksbuchstabe (`C:`), unter macOS ein Datenträgername — +/// deshalb wird nur übernommen, was auf einen Doppelpunkt endet. Ein +/// macOS-Datenträgername ergäbe sonst einen Pfad, den es nicht gibt. +fn build_path(volume: &str, dir: &str, file: &str) -> String { + if file.is_empty() { + return String::new(); + } + + let mut pfad = dir.replace("/:", "/"); + if !pfad.ends_with('/') && !pfad.is_empty() { + pfad.push('/'); + } + pfad.push_str(file); + + if volume.ends_with(':') { + format!("{volume}{pfad}") + } else { + pfad + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BEISPIEL: &str = r#" + + + + + + + + + + + + + + +"#; + + #[test] + fn liest_metadaten_und_tempo() { + let entries = parse_nml(BEISPIEL).unwrap(); + assert_eq!(entries.len(), 2); + + let t = &entries[0].track; + assert_eq!(t.title.as_deref(), Some("Fear Paralysis")); + assert_eq!(t.artist.as_deref(), Some("Rebekah")); + assert_eq!(t.album.as_deref(), Some("Fear Paralysis EP")); + assert_eq!(t.genre.as_deref(), Some("Techno")); + assert_eq!(t.musical_key.as_deref(), Some("Am")); + assert_eq!(t.bpm, Some(135.0)); + assert_eq!(t.duration_secs, Some(384.0)); + } + + #[test] + fn baut_den_pfad_aus_traktors_trennzeichen() { + let entries = parse_nml(BEISPIEL).unwrap(); + assert_eq!(entries[0].track.path, "/Users/felix/Music/fear.mp3"); + } + + #[test] + fn windows_laufwerk_kommt_davor_ein_mac_datentraeger_nicht() { + assert_eq!( + build_path("C:", "/:Musik/:Techno/:", "a.mp3"), + "C:/Musik/Techno/a.mp3" + ); + assert_eq!( + build_path("Macintosh HD", "/:Musik/:", "a.mp3"), + "/Musik/a.mp3" + ); + assert_eq!(build_path("C:", "/:Musik/:", ""), ""); + } + + #[test] + fn der_grid_marker_wird_zum_beat_anker() { + let entries = parse_nml(BEISPIEL).unwrap(); + assert_eq!(entries[0].track.beat_anchor_ms, Some(122.5)); + + let grid = entries[0] + .track + .beatgrid(44_100) + .expect("kein Beatgrid aufgebaut"); + assert!((grid.bpm - 135.0).abs() < 1e-4); + assert_eq!(grid.anchor_frames, 5_402); + } + + #[test] + fn hot_cues_behalten_nummer_und_position() { + let entries = parse_nml(BEISPIEL).unwrap(); + let cues = &entries[0].cues; + assert_eq!(cues.len(), 2); + + let grid = cues.iter().find(|c| c.kind == CueKind::Grid).unwrap(); + assert_eq!(grid.hotcue, None, "der Grid-Marker liegt auf keiner Taste"); + + let drop = cues.iter().find(|c| c.kind == CueKind::Cue).unwrap(); + assert_eq!(drop.hotcue, Some(1)); + assert_eq!(drop.position_ms, 64_000.25); + assert_eq!(drop.name.as_deref(), Some("Drop")); + assert_eq!(drop.frame(44_100), 2_822_411); + } + + #[test] + fn eintraege_ohne_pfad_werden_gezaehlt_nicht_verschluckt() { + let lib = Library::open_in_memory().unwrap(); + let report = import_nml(BEISPIEL, &lib).unwrap(); + + assert_eq!(report.entries_seen, 2); + assert_eq!(report.tracks_imported, 1); + assert_eq!(report.skipped_without_location, 1); + assert_eq!(report.cues_imported, 2); + assert_eq!(lib.track_count().unwrap(), 1); + } + + #[test] + fn import_landet_vollstaendig_in_der_datenbank() { + let lib = Library::open_in_memory().unwrap(); + import_nml(BEISPIEL, &lib).unwrap(); + + let t = lib + .track_by_path("/Users/felix/Music/fear.mp3") + .unwrap() + .expect("Track nicht gespeichert"); + assert_eq!(t.bpm, Some(135.0)); + assert_eq!(t.beat_anchor_ms, Some(122.5)); + + let cues = lib.cues(t.id.unwrap()).unwrap(); + assert_eq!(cues.len(), 2); + assert_eq!(cues[0].position_ms, 122.5, "nicht nach Position sortiert"); + } + + #[test] + fn zweimal_importieren_verdoppelt_nichts() { + let lib = Library::open_in_memory().unwrap(); + import_nml(BEISPIEL, &lib).unwrap(); + import_nml(BEISPIEL, &lib).unwrap(); + + assert_eq!(lib.track_count().unwrap(), 1); + + let t = lib + .track_by_path("/Users/felix/Music/fear.mp3") + .unwrap() + .unwrap(); + assert_eq!(lib.cues(t.id.unwrap()).unwrap().len(), 2, "Cues verdoppelt"); + } + + #[test] + fn numerische_tonart_wird_nur_ohne_textangabe_genutzt() { + let xml = r#" + + + "#; + + let entries = parse_nml(xml).unwrap(); + assert_eq!(entries[0].track.musical_key.as_deref(), Some("Am")); + + assert_eq!(traktor_key_name(0), Some("C")); + assert_eq!(traktor_key_name(12), Some("Cm")); + assert_eq!(traktor_key_name(99), None); + } + + #[test] + fn kaputtes_xml_wird_gemeldet_statt_zu_paniken() { + assert!(parse_nml(""#).unwrap(); + assert!(entries.is_empty()); + } +} diff --git a/crates/musik-app/Cargo.toml b/crates/musik-app/Cargo.toml new file mode 100644 index 0000000..114abcf --- /dev/null +++ b/crates/musik-app/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "musik-app" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +analysis = { version = "0.1.0", path = "../analysis" } +anyhow = "1.0.104" +audio-core = { version = "0.1.0", path = "../audio-core" } +audio-engine = { version = "0.1.0", path = "../audio-engine" } +control = { version = "0.1.0", path = "../control" } +eframe = "0.35.0" +egui = "0.35.0" +image = { version = "0.25.10", default-features = false, features = ["png"] } +library = { version = "0.1.0", path = "../library" } diff --git a/crates/musik-app/src/app.rs b/crates/musik-app/src/app.rs new file mode 100644 index 0000000..c49f48b --- /dev/null +++ b/crates/musik-app/src/app.rs @@ -0,0 +1,1502 @@ +//! Die Oberfläche. +//! +//! Aufbau von oben nach unten: zwei Decks nebeneinander, darunter der Mixer, +//! darunter die Sammlung. Das ist die Anordnung, die man von einem DJ-Setup +//! kennt — Plattenspieler oben, Mischpult in der Mitte, Plattenkiste unten. +//! Rechts daneben die **Regie**: was vorgemerkt ist und was als Nächstes kommt. +//! +//! Die Oberfläche fasst den Mixer nie direkt an — und sie hält auch keine +//! eigenen Werte mehr. Alles Bedienbare liegt im Steuerpult (`control`), das +//! die Kommandos in die lock-freie Schlange schickt. Damit ist die Oberfläche +//! einer von mehreren Bedienern: Ein Skript am Socket bewegt denselben Fader, +//! und beide sehen sofort dasselbe. +//! +//! Aus dieser Gleichberechtigung folgt die Regie-Spalte. Eine laufende Rampe +//! gibt auf, sobald jemand den Regler anfasst — der Mensch gewinnt also jeden +//! Griff. Ohne eine Anzeige gewönne er ihn blind: Er sähe einen Fader wandern +//! und wüsste nicht, ob ihn jemand zieht oder ob er selbst hängengeblieben ist. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use analysis::peaks::PeakLevel; +use audio_core::deck::{DeckState, HOT_CUES}; +use audio_engine::{Assign, Output}; +use control::{Einheit, Gruppe, Schluessel, Steuerpult, Treffer, Wert}; +use egui::{Color32, RichText, Ui}; + +use crate::theme; +use crate::waveform; + +/// Breite eines Kanalzuges. Am Gerät sind das gut fünf Zentimeter — schmal +/// genug, dass vier Züge nebeneinander passen, breit genug zum Greifen. +const KANALBREITE: f32 = 132.0; +/// Länge des Linefaders. Ein kurzer Fader lässt sich nicht sauber einblenden. +const FADERHOEHE: f32 = 84.0; +/// Höhe des Mixerfeldes. Muss den ganzen Kanalzug fassen — ist es zu wenig, +/// schneidet das Panel oben die Beschriftung und unten die Fader ab. +const MIXER_HOEHE: f32 = 318.0; +/// Anfangshöhe der Plattenkiste. Sie ist ziehbar, weil man beim Suchen mehr +/// Liste will und beim Mixen mehr Wellenform. +const SAMMLUNG_HOEHE: f32 = 190.0; +/// Anfangsbreite der Regie-Spalte. Breit genug für einen Dateinamen und eine +/// kurze Notiz, schmal genug, dass die Wellenformen nicht leiden. +const REGIE_BREITE: f32 = 250.0; + +/// Eine Zeile im Plan, fertig zum Zeichnen. +/// +/// Als Schnappschuss und nicht als Verweis ins Pult: Gezeichnet wird ohne +/// gehaltenen Mutex, sonst hinge die Oberfläche an einem Schloss, an dem auch +/// der Taktgeber alle fünf Millisekunden zieht. +struct PlanZeile { + id: u64, + text: String, + /// Wie weit eine Rampe gelaufen ist, 0..1. `None` bei vorgemerkten + /// Befehlen — die laufen nicht, sie warten. + anteil: Option, +} + +struct ListenZeile { + id: u64, + name: String, + notiz: String, +} + +/// Ein Signal aus dem Raum, fertig zum Zeichnen. +struct SignalZeile { + name: String, + wert: Option, + trend: Option, + alter: Option, +} + +/// Der Dateiname ohne Ordner — in einer schmalen Spalte ist der Pfad Ballast. +fn dateiname(pfad: &str) -> String { + std::path::Path::new(pfad) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| pfad.to_string()) +} + +/// Was die Oberfläche über ein Deck weiß, das im Steuerpult nicht steht. +/// +/// Titel, Tempo, Position und Reglerstellungen liegen im Pult — hier bleibt +/// nur, was allein zum Zeichnen gebraucht wird. +pub struct DeckUi { + pub name: String, + pub state: Arc, + pub peaks: Vec, + pub frames: u64, + pub sample_rate: u32, + /// Sichtbarer Ausschnitt der Zoom-Ansicht, in Sekunden. + pub zoom_secs: f32, + pub loop_beats: f64, +} + +impl DeckUi { + pub fn position_secs(&self) -> f64 { + self.state.position_frames() as f64 / self.sample_rate as f64 + } + + pub fn dauer_secs(&self) -> f64 { + self.frames as f64 / self.sample_rate as f64 + } + + pub fn cues(&self) -> Vec<(usize, u64)> { + (0..HOT_CUES) + .filter_map(|i| self.state.cue(i).map(|f| (i, f))) + .collect() + } +} + +pub struct Screenshot { + pub pfad: PathBuf, + /// Erst ab diesem Zeitpunkt wird ausgelöst. + /// + /// Eine feste Zahl von Bildern wäre willkürlich und je nach Rechner + /// unterschiedlich lang. Über die Zeit lässt sich außerdem etwas + /// dazwischenschieben — etwa die Anlage über den Socket in einen + /// bestimmten Zustand fahren und *den* aufnehmen. + pub warte_bis: std::time::Instant, + pub angefordert: bool, +} + +pub struct MusikApp { + /// Fertige Ladeaufträge vom Arbeiter-Thread. + pub ergebnisse: std::sync::mpsc::Receiver, + /// Der gemeinsame Steuerraum. Die Oberfläche ist einer von mehreren + /// Bedienern — ein Skript am Socket sieht dieselben Werte. + pub pult: Arc>, + /// Hält den Audio-Stream am Leben. Fällt er weg, verstummt die Ausgabe — + /// deshalb liegt er hier, auch wenn sonst niemand ihn anfasst. + #[allow(dead_code)] + pub output: Option, + pub audio_hinweis: String, + pub decks: Vec, + pub suche: String, + pub treffer: Vec, + /// Namen der Playlists, einmal beim Start geholt. + pub playlisten: Vec, + pub playliste: String, + pub status: String, + pub screenshot: Option, +} + +impl eframe::App for MusikApp { + fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { + // Abgelöste Quellen abholen, sonst nimmt der Audio-Thread irgendwann + // keine neuen Tracks mehr an. + if let Ok(mut pult) = self.pult.lock() { + pult.handle_mut().collect_retired(); + } + self.ladeergebnisse_einsetzen(); + + // Kopie, weil `ui` gleich selbst ausgeliehen wird und der Kontext + // danach noch für den Screenshot gebraucht wird. + let ctx = ui.ctx().clone(); + + // Die Decks laufen weiter, also muss auch weiter gezeichnet werden. + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + + egui::Panel::top("kopf").show(ui, |ui| self.kopfzeile(ui)); + + // Die Sammlung wird zuerst angemeldet und liegt damit ganz unten; der + // Mixer schiebt sich darüber, direkt unter die Decks. Genau die + // Reihenfolge, in der man vor dem Gerät sitzt. + egui::Panel::bottom("sammlung") + .resizable(true) + .default_size(SAMMLUNG_HOEHE) + .show(ui, |ui| self.sammlung(ui)); + egui::Panel::bottom("mixer") + .exact_size(MIXER_HOEHE) + .show(ui, |ui| self.mixer(ui)); + + // Rechts und nicht unten: Der Plan gehört neben die Decks, weil man + // ihn beim Mixen im Blick hat, nicht beim Suchen. + egui::Panel::right("regie") + .resizable(true) + .default_size(REGIE_BREITE) + .show(ui, |ui| self.regie(ui)); + + egui::CentralPanel::default().show(ui, |ui| { + // `columns` gibt jeder Spalte ein eigenes Top-down-Layout. Ein + // `horizontal` täte das nicht — die Decks lägen dann seitwärts. + let anzahl = self.decks.len().max(1); + ui.columns(anzahl, |spalten| { + for (index, spalte) in spalten.iter_mut().enumerate() { + self.deck(spalte, index); + } + }); + }); + + self.screenshot_schritt(&ctx); + } +} + +impl MusikApp { + /// Setzt fertig geladene Tracks ein. + /// + /// Im UI-Thread, weil die Wellenform-Spitzen hier liegen und weil das + /// Einsetzen selbst kurz ist — die teure Arbeit ist längst getan. + fn ladeergebnisse_einsetzen(&mut self) { + while let Ok(ergebnis) = self.ergebnisse.try_recv() { + let deck = ergebnis.deck; + + match ergebnis.ausgang { + Ok(fertig) => { + let Ok(mut pult) = self.pult.lock() else { + return; + }; + let Some(kanal) = pult.decks().get(deck).map(|d| d.kanal) else { + continue; + }; + + if !pult + .handle_mut() + .load(kanal, Box::new(audio_engine::DeckSource::new(fertig.voice))) + { + // Die Ladeschlange ist voll. Das ist ein echter + // Fehlschlag und darf nicht als Erfolg dastehen. + if let Some(e) = pult.deck_mut(deck) { + e.lade_status = "fehler: Ladeschlange voll".into(); + } + self.status = "Ladeschlange voll — gleich noch einmal".into(); + continue; + } + + // Erst jetzt auf den ersten Downbeat, nachdem die neue + // Stimme im Mixer liegt. Vorher gesetzt verbraucht ihn die + // alte, und das Deck steht danach wieder auf Sekunde 0 — + // genau der Fehler, den die Mitschrift aufgedeckt hat. + // + // Wer von Hand auflegt, cued auf den ersten Schlag. Ein + // Track, der bei Sekunde 0 einsetzt, kommt neben seiner + // eigenen Eins herein, weil vorne Stille steht. + if let (Some(einstieg), Some(d)) = (fertig.einstieg, pult.decks().get(deck)) { + d.state.seek_frames(einstieg); + } + + if let Some(e) = pult.deck_mut(deck) { + e.titel = fertig.titel.clone(); + e.artist = fertig.artist; + e.frames = fertig.frames; + e.tonart = fertig.tonart; + e.struktur = fertig.struktur; + e.stems = fertig.stems; + // Ohne den Pfad landet kein gesetzter Cue je wieder in + // der Sammlung. + e.pfad = ergebnis.pfad.clone(); + e.lade_status = "bereit".into(); + } + drop(pult); + + if let Some(ui) = self.decks.get_mut(deck) { + ui.peaks = fertig.peaks; + ui.frames = fertig.frames; + } + self.status = format!("{} → Deck {}", fertig.titel, deck + 1); + } + Err(text) => { + if let Ok(mut pult) = self.pult.lock() { + if let Some(e) = pult.deck_mut(deck) { + e.lade_status = format!("fehler: {text}"); + } + } + self.status = format!("{} ließ sich nicht laden: {text}", ergebnis.pfad); + } + } + } + } + + fn kopfzeile(&mut self, ui: &mut Ui) { + ui.add_space(3.0); + ui.horizontal(|ui| { + ui.label(RichText::new("MUSIK").strong().size(15.0)); + ui.label( + RichText::new(&self.audio_hinweis) + .color(theme::TEXT_LEISE) + .size(11.0), + ); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if !self.status.is_empty() { + ui.label( + RichText::new(&self.status) + .color(theme::TEXT_LEISE) + .size(11.0), + ); + } + }); + }); + ui.add_space(3.0); + } + + fn deck(&mut self, ui: &mut Ui, index: usize) { + let farbe = theme::deck_farbe(index); + + // Was nach Kopfzeilen, Transport und Hot Cues übrig bleibt, bekommen + // die Wellenformen. Sonst klebt das Deck oben und darunter ist Leere. + const RANDRUM: f32 = 178.0; + let rest = (ui.available_height() - RANDRUM).max(120.0); + let uebersicht = (rest * 0.26).clamp(38.0, 90.0); + let zoom = rest - uebersicht; + + egui::Frame::new() + .fill(theme::PANEL) + .inner_margin(8.0) + .corner_radius(4.0) + .show(ui, |ui| { + // Der Rahmen erbt das Layout des Elternteils; im Mixer wäre das + // waagerecht. Explizit senkrecht, damit die Zeilen untereinander + // stehen und nicht nebeneinander. + ui.vertical(|ui| self.deck_inhalt(ui, index, farbe, uebersicht, zoom)); + }); + } + + fn deck_inhalt( + &mut self, + ui: &mut Ui, + index: usize, + farbe: Color32, + uebersicht: f32, + zoom: f32, + ) { + let (titel, artist, tonart) = match self.pult.lock() { + Ok(pult) => match pult.decks().get(index) { + Some(d) => (d.titel.clone(), d.artist.clone(), d.tonart), + None => (String::new(), String::new(), None), + }, + Err(_) => (String::new(), String::new(), None), + }; + + let (dauer, position, bpm, keylock, laeuft) = { + let deck = &self.decks[index]; + ( + deck.dauer_secs(), + deck.position_secs(), + deck.state.effective_bpm(), + deck.state.keylock(), + deck.state.is_playing(), + ) + }; + + ui.horizontal(|ui| { + ui.label(RichText::new(&self.decks[index].name).color(farbe).strong()); + ui.label(RichText::new(titel).strong()); + ui.label(RichText::new(artist).color(theme::TEXT_LEISE).size(11.0)); + }); + + ui.horizontal(|ui| { + let tempo = match bpm { + Some(b) => format!("{b:6.2} BPM"), + None => " — BPM".into(), + }; + ui.label( + RichText::new(tempo) + .monospace() + .color(farbe) + .size(17.0) + .strong(), + ); + ui.label( + RichText::new(format!("{} / {}", zeit(position), zeit(dauer))) + .monospace() + .color(theme::TEXT_LEISE), + ); + + // Beide Schreibweisen nebeneinander: Der Name sagt, was klingt, + // die Camelot-Zahl sagt, wozu es passt. Wer harmonisch mischt, + // vergleicht die Zahlen der beiden Decks. + if let Some(k) = tonart { + ui.label( + RichText::new(format!("{} {}", k.name(), k.camelot())) + .monospace() + .color(theme::TEXT_LEISE), + ) + .on_hover_text("Tonart und Camelot-Zahl — gleiche Zahl oder ±1 passt"); + } + }); + + ui.add_space(2.0); + self.wellenformen(ui, index, farbe, uebersicht, zoom); + ui.add_space(4.0); + self.transport(ui, index, laeuft, keylock); + ui.add_space(2.0); + self.hot_cues(ui, index); + } + + fn wellenformen( + &mut self, + ui: &mut Ui, + index: usize, + farbe: Color32, + hoehe_uebersicht: f32, + hoehe_zoom: f32, + ) { + let deck = &self.decks[index]; + let gesamt = deck.frames as f64; + let pos = deck.state.position_frames() as f64; + let grid = deck.state.grid(); + let rate = deck.sample_rate; + let cues = deck.cues(); + let zoom_frames = (deck.zoom_secs as f64 * rate as f64).max(1.0); + + // Übersicht über den ganzen Track. + let (rect, antwort) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), hoehe_uebersicht), + egui::Sense::click(), + ); + if !deck.peaks.is_empty() && gesamt > 0.0 { + let stufe = waveform::passende_stufe(&deck.peaks, gesamt, rect.width()); + waveform::zeichne(ui.painter(), rect, stufe, 0.0, gesamt, farbe, Some(pos)); + waveform::zeichne_cues(ui.painter(), rect, &cues, 0.0, gesamt); + let x = rect.left() + (pos / gesamt) as f32 * rect.width(); + waveform::zeichne_playhead(ui.painter(), rect, x); + } + + // Klick in die Übersicht springt dorthin. + if antwort.clicked() { + if let Some(p) = antwort.interact_pointer_pos() { + let t = ((p.x - rect.left()) / rect.width()).clamp(0.0, 1.0) as f64; + self.decks[index].state.seek_frames((t * gesamt) as u64); + } + } + + ui.add_space(3.0); + + // Ausschnitt um die Abspielposition. + let deck = &self.decks[index]; + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), hoehe_zoom), + egui::Sense::hover(), + ); + let von = pos - zoom_frames * 0.5; + let bis = pos + zoom_frames * 0.5; + + if !deck.peaks.is_empty() { + let stufe = waveform::passende_stufe(&deck.peaks, zoom_frames, rect.width()); + waveform::zeichne(ui.painter(), rect, stufe, von, bis, farbe, Some(pos)); + if let Some(g) = grid { + waveform::zeichne_grid(ui.painter(), rect, &g, rate, von, bis, gesamt); + } + waveform::zeichne_cues(ui.painter(), rect, &cues, von, bis); + waveform::zeichne_playhead(ui.painter(), rect, rect.center().x); + } + } + + fn transport(&mut self, ui: &mut Ui, index: usize, laeuft: bool, keylock: bool) { + ui.horizontal(|ui| { + let beschriftung = if laeuft { "⏸ Pause" } else { "▶ Play" }; + if ui.button(RichText::new(beschriftung).size(13.0)).clicked() { + self.decks[index].state.toggle_playing(); + } + + if ui.button("⏮").on_hover_text("An den Anfang").clicked() { + self.decks[index].state.seek_frames(0); + } + + let mut kl = keylock; + if ui.checkbox(&mut kl, "Keylock").changed() { + self.decks[index].state.set_keylock(kl); + } + + // Sync richtet sich immer am jeweils anderen Deck aus. + let anderes = 1 - index.min(1); + if self.decks.len() > 1 && ui.button("SYNC").clicked() { + let rate = self.decks[index].sample_rate; + let master = Arc::clone(&self.decks[anderes].state); + let slave = Arc::clone(&self.decks[index].state); + + match audio_engine::sync(&master, &slave, rate) { + Some(plan) => { + self.status = format!( + "{} auf {} gezogen: Tempo ×{:.4}, Phase {:+.3} Beats", + self.decks[index].name, + self.decks[anderes].name, + plan.tempo, + plan.phase_error_beats + ) + } + None => self.status = "Sync braucht auf beiden Decks ein Beatgrid".into(), + } + } + }); + + ui.horizontal(|ui| { + let mut tempo = self.decks[index].state.tempo(); + let antwort = ui.add( + egui::Slider::new(&mut tempo, 0.92..=1.08) + .custom_formatter(|v, _| format!("{:+.2} %", (v - 1.0) * 100.0)) + .text("Tempo"), + ); + if antwort.changed() { + self.decks[index].state.set_tempo(tempo); + } + if antwort.double_clicked() { + self.decks[index].state.set_tempo(1.0); + } + }); + } + + fn hot_cues(&mut self, ui: &mut Ui, index: usize) { + ui.horizontal_wrapped(|ui| { + for i in 0..HOT_CUES { + let gesetzt = self.decks[index].state.cue(i).is_some(); + let farbe = if gesetzt { + theme::CUE + } else { + theme::TEXT_LEISE + }; + + let knopf = ui.add( + egui::Button::new(RichText::new(format!("{}", i + 1)).color(farbe).monospace()) + .min_size(egui::vec2(24.0, 20.0)), + ); + + // Über das Pult, nicht am Deck vorbei: Nur dieser Weg schreibt + // den Cue auch in die Sammlung zurück. Ein zweiter Pfad wäre + // genau die Sorte Abkürzung, die später als „warum sind meine + // Cues weg" auffällt. + if knopf.clicked() { + if gesetzt { + self.deck_aktion(index, "jump_cue", Some(&format!("{}", i + 1))); + } else { + let sekunden = self.decks[index].position_secs(); + self.deck_schreiben(index, &cue_name(i), Wert::Zahl(sekunden)); + } + } + if knopf.secondary_clicked() { + self.deck_schreiben(index, &cue_name(i), Wert::Leer); + } + } + + ui.separator(); + + let mut beats = self.decks[index].loop_beats; + egui::ComboBox::from_id_salt(("loop", index)) + .width(58.0) + .selected_text(format!("{} Beat", kurz(beats))) + .show_ui(ui, |ui| { + for wert in [1.0, 2.0, 4.0, 8.0, 16.0] { + ui.selectable_value(&mut beats, wert, kurz(wert)); + } + }); + self.decks[index].loop_beats = beats; + + ui.separator(); + + // Grid-Korrektur. Der Detektor liegt bei sperrigem Material daneben, + // und ohne diese drei Knöpfe wäre so ein Track unbrauchbar statt in + // zehn Sekunden gerade gezogen. + ui.label(RichText::new("GRID").color(theme::TEXT_LEISE).size(10.0)); + if ui + .small_button("÷2") + .on_hover_text("Grid-Tempo halbieren — der übliche Oktavfehler") + .clicked() + { + self.deck_aktion(index, "grid_scale", Some("0.5")); + } + if ui + .small_button("×2") + .on_hover_text("Grid-Tempo verdoppeln") + .clicked() + { + self.deck_aktion(index, "grid_scale", Some("2")); + } + if ui + .small_button("HIER") + .on_hover_text("Die Eins liegt an der Abspielposition") + .clicked() + { + self.deck_aktion(index, "grid_here", None); + } + + ui.separator(); + + let laeuft = self.decks[index].state.is_looping(); + let text = if laeuft { "LOOP AUS" } else { "LOOP" }; + if ui + .add(egui::Button::new(text).fill(if laeuft { + theme::AKTIV + } else { + theme::PANEL_HELL + })) + .clicked() + { + let deck = &self.decks[index]; + if laeuft { + deck.state.set_loop_active(false); + } else if deck.state.set_loop_beats(beats, deck.sample_rate) { + deck.state.set_loop_active(true); + } else { + self.status = "Beat-Loop braucht ein Beatgrid".into(); + } + } + }); + } + + fn mixer(&mut self, ui: &mut Ui) { + ui.add_space(4.0); + + // Einmal je Bild sperren. Der Audio-Thread ist daran nicht beteiligt — + // er sieht nur die lock-freie Schlange, die das Pult füllt. Hier + // konkurriert die Oberfläche nur mit anderen Bedienern, und die sind + // langsam. + let Ok(mut pult) = self.pult.lock() else { + ui.label(RichText::new("Steuerpult nicht erreichbar").color(theme::WARNUNG)); + return; + }; + + ui.horizontal_top(|ui| { + for i in 0..pult.kanaele().len() { + kanalzug(ui, &mut pult, i, theme::deck_farbe(i)); + } + ui.separator(); + summe(ui, &mut pult); + }); + } + fn sammlung(&mut self, ui: &mut Ui) { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(RichText::new("SAMMLUNG").strong().size(12.0)); + + let feld = ui.add( + egui::TextEdit::singleline(&mut self.suche) + .hint_text("Titel, Künstler, Album …") + .desired_width(240.0), + ); + if feld.changed() { + self.suchen(); + } + + if ui.button("Mischbar zu A").clicked() { + self.mischbar_suchen(0); + } + if ui.button("Mischbar zu B").clicked() { + self.mischbar_suchen(1); + } + + // Tempo und Tonart sind zwei Fragen, und die Antworten + // überschneiden sich nur zufällig. Deshalb zwei Knöpfe und keiner, + // der beides zugleich einschränkt: Wer harmonisch sucht, nimmt + // gern ein Tempo in Kauf, das der Fader noch hinbiegt. + if ui + .button("Harmonisch zu A") + .on_hover_text("Tracks, deren Tonart zu Deck A passt") + .clicked() + { + self.harmonisch_suchen(0); + } + if ui + .button("Harmonisch zu B") + .on_hover_text("Tracks, deren Tonart zu Deck B passt") + .clicked() + { + self.harmonisch_suchen(1); + } + + // Playlists gab es in der Sammlung schon lange; erreichbar waren + // sie nie — weder hier noch über den Steuerraum. + if !self.playlisten.is_empty() { + let vorher = self.playliste.clone(); + egui::ComboBox::from_id_salt("playliste") + .width(150.0) + .selected_text(if self.playliste.is_empty() { + "Playlist …" + } else { + &self.playliste + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.playliste, String::new(), "— alle —"); + for name in self.playlisten.clone() { + ui.selectable_value(&mut self.playliste, name.clone(), name); + } + }); + + if self.playliste != vorher { + self.playliste_zeigen(); + } + } + + if self.treffer.is_empty() && self.suche.is_empty() { + ui.label( + RichText::new("keine Sammlung geladen — mit --db öffnen") + .color(theme::WARNUNG) + .size(11.0), + ); + } + }); + + ui.add_space(3.0); + + egui::ScrollArea::vertical() + // Ohne das schrumpft die Plattenkiste bei leerer Liste auf eine + // Zeile zusammen und das Panel klappt mit. + .auto_shrink(false) + .show(ui, |ui| { + egui::Grid::new("trefferliste") + .num_columns(5) + .striped(true) + .min_col_width(70.0) + .show(ui, |ui| { + for spalte in ["BPM", "KEY", "KÜNSTLER", "TITEL", "AUF DECK / LISTE"] { + ui.label( + RichText::new(spalte) + .color(theme::TEXT_LEISE) + .size(10.0) + .strong(), + ); + } + ui.end_row(); + + for eintrag in self.treffer.clone() { + let bpm = eintrag + .bpm + .map(|b| format!("{b:6.2}")) + .unwrap_or_else(|| " —".into()); + ui.label(RichText::new(bpm).monospace()); + + // Camelot statt Name: In einer Liste zählt, wozu + // etwas passt, und dafür ist die Zahl auf dem Rad + // die Form, die sich vergleichen lässt. + let key = eintrag + .tonart + .map(|k| k.camelot()) + .unwrap_or_else(|| "—".into()); + ui.label(RichText::new(key).monospace()).on_hover_text( + eintrag + .tonart + .map(|k| k.name()) + .unwrap_or_else(|| "keine Tonart bekannt".into()), + ); + + ui.label(eintrag.artist.clone().unwrap_or_else(|| "—".into())); + ui.label(&eintrag.titel); + + // Nur „A" und „B": ein Pfeilzeichen wäre schöner, + // aber die mitgelieferte Schrift hat keins, und ein + // fehlender Glyph wird zum leeren Kästchen. + ui.horizontal(|ui| { + for (deck, name) in [(0usize, "A"), (1usize, "B")] { + let farbe = theme::deck_farbe(deck); + if ui + .small_button(RichText::new(name).color(farbe).strong()) + .on_hover_text(format!("auf Deck {name} laden")) + .clicked() + { + self.laden(deck, &eintrag); + } + } + + // Ohne diesen Knopf wäre die Liste von der + // Oberfläche aus nur lesbar — füllen könnte sie + // allein ein Agent. + if ui + .small_button("+") + .on_hover_text("Für später vormerken") + .clicked() + { + self.master_aktion("queue_add", Some(&eintrag.pfad)); + } + }); + ui.end_row(); + } + }); + }); + } + + /// Liest Plan und Liste in einem Rutsch. + /// + /// Ein Griff ins Schloss für beides, und danach ist es wieder frei. Der + /// Taktgeber-Thread nimmt es alle fünf Millisekunden; eine Oberfläche, die + /// es je Zeile nähme, stünde ihm im Weg. + fn regie_lesen(&self) -> (Vec, Vec, Vec) { + let Ok(pult) = self.pult.lock() else { + return (Vec::new(), Vec::new(), Vec::new()); + }; + + let jetzt = std::time::Instant::now(); + let signale = pult + .signale + .iter() + .filter(|s| !s.name.is_empty()) + .map(|s| SignalZeile { + name: s.name.clone(), + wert: s.wert(), + trend: s.trend(jetzt), + alter: s.alter(jetzt), + }) + .collect(); + + let plan = pult + .plan + .auftraege() + .iter() + .map(|a| { + let jetzt = control::zeitplan::beat_jetzt(&pult, a.takt_deck); + match &a.was { + control::zeitplan::Was::Rampe(r) => PlanZeile { + id: a.id, + text: format!("{} {:.2} nach {:.2}", r.control, r.von, r.nach), + anteil: jetzt.map(|j| { + if r.beats > 0.0 { + (((j - a.ab_beat) / r.beats) as f32).clamp(0.0, 1.0) + } else { + 1.0 + } + }), + }, + // Ein Wenn hängt an einem Wert, nicht an Takten — deshalb + // steht dort der Ist-Wert statt einer Restzeit. + control::zeitplan::Was::Wenn { + control, + vergleich, + schwelle, + zeile, + } => { + let steht = match pult.lies(control) { + Ok(control::Wert::Zahl(v)) => format!("{v:.2}"), + _ => "?".into(), + }; + PlanZeile { + id: a.id, + text: format!( + "wenn {control} {} {schwelle} ({steht}): {zeile}", + vergleich.zeichen() + ), + anteil: None, + } + } + control::zeitplan::Was::Spaeter { beim_beat, zeile } => PlanZeile { + id: a.id, + // Die Restzeit in Beats und nicht in Sekunden: Der Plan + // rechnet so, und bei gedrehtem Tempo wäre eine + // Sekundenangabe im nächsten Moment falsch. + text: match jetzt { + Some(j) => format!("in {:.0} Beats: {zeile}", (beim_beat - j).max(0.0)), + None => format!("wartet: {zeile}"), + }, + anteil: None, + }, + } + }) + .collect(); + + let liste = pult + .liste + .eintraege() + .iter() + .map(|e| ListenZeile { + id: e.id, + name: dateiname(&e.pfad), + notiz: e.notiz.clone(), + }) + .collect(); + + (plan, liste, signale) + } + + /// Der Blick auf das, was die anderen vorhaben. + fn regie(&mut self, ui: &mut Ui) { + let (plan, liste, signale) = self.regie_lesen(); + + ui.add_space(4.0); + ui.label(RichText::new("REGIE").strong().size(12.0)) + .on_hover_text("Was vorgemerkt ist — von der Oberfläche wie von einem Agenten"); + ui.add_space(6.0); + + egui::ScrollArea::vertical() + .auto_shrink(false) + .show(ui, |ui| { + self.plan_zeigen(ui, &plan); + ui.add_space(10.0); + self.liste_zeigen(ui, &liste); + if !signale.is_empty() { + ui.add_space(10.0); + Self::signale_zeigen(ui, &signale); + } + }); + } + + /// Was von außen gemeldet wurde. + /// + /// Ohne diesen Block wüsste der Mensch am Pult nicht, worauf die Agenten + /// gerade reagieren — sie sähen den Raum, er nur ihre Reglerbewegungen. + fn signale_zeigen(ui: &mut Ui, signale: &[SignalZeile]) { + ui.label( + RichText::new("AUS DEM RAUM") + .color(theme::TEXT_LEISE) + .size(10.0) + .strong(), + ); + + for s in signale { + ui.horizontal(|ui| { + let wert = match s.wert { + Some(v) => format!("{v:+.2}"), + None => " —".into(), + }; + ui.label(RichText::new(wert).monospace().size(11.0)); + ui.label(RichText::new(&s.name).size(11.0)); + + if let Some(trend) = s.trend { + // Farbe statt Pfeil: Die mitgelieferte Schrift hat keine + // Pfeile, und ein fehlender Glyph wird zum leeren Kästchen. + let farbe = if trend > 0.02 { + theme::deck_farbe(0) + } else if trend < -0.02 { + theme::WARNUNG + } else { + theme::TEXT_LEISE + }; + ui.label( + RichText::new(format!("{trend:+.2}/min")) + .monospace() + .color(farbe) + .size(10.0), + ); + } + }); + + // Ein Wert von vor zwanzig Minuten ist keine Lüge, aber auch keine + // Auskunft über jetzt. + if s.alter + .is_some_and(|a| a > control::signal::FENSTER.as_secs_f64()) + { + ui.label( + RichText::new(format!("seit {:.0} s nichts mehr", s.alter.unwrap())) + .color(theme::WARNUNG) + .size(10.0), + ); + } + } + } + + fn plan_zeigen(&mut self, ui: &mut Ui, plan: &[PlanZeile]) { + ui.horizontal(|ui| { + ui.label( + RichText::new("PLAN") + .color(theme::TEXT_LEISE) + .size(10.0) + .strong(), + ); + if !plan.is_empty() && ui.small_button("alle weg").clicked() { + if let Ok(mut pult) = self.pult.lock() { + let weg = pult.plan.streichen(None); + self.status = format!("{weg} vorgemerkte Aufträge gestrichen"); + } + } + }); + + if plan.is_empty() { + ui.label( + RichText::new("nichts vorgemerkt") + .color(theme::TEXT_LEISE) + .size(11.0), + ); + return; + } + + for zeile in plan { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("{}", zeile.id)) + .monospace() + .color(theme::TEXT_LEISE) + .size(11.0), + ); + // In einer Spalte von 250 Pixeln passt eine Bedingung samt + // Befehl nicht in eine Zeile. Abgeschnitten und stumm wäre das + // eine halbe Auskunft — deshalb steht sie vollständig unter der + // Maus. + ui.label(RichText::new(&zeile.text).size(11.0)) + .on_hover_text(&zeile.text); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("weg").clicked() { + // Direkt auf dem Plan und nicht über eine Aktion: Das + // Streichen selbst *ist* diese Methode, dieselbe, die + // auch `cancel` am Socket aufruft. Ein zweiter Name + // dafür wäre eine zweite Stelle zum Auseinanderlaufen. + if let Ok(mut pult) = self.pult.lock() { + pult.plan.streichen(Some(zeile.id)); + } + self.status = format!("Auftrag {} gestrichen", zeile.id); + } + }); + }); + + if let Some(anteil) = zeile.anteil { + ui.add(egui::ProgressBar::new(anteil).desired_height(4.0)); + } + } + } + + fn liste_zeigen(&mut self, ui: &mut Ui, liste: &[ListenZeile]) { + ui.horizontal(|ui| { + ui.label( + RichText::new("LISTE") + .color(theme::TEXT_LEISE) + .size(10.0) + .strong(), + ); + if !liste.is_empty() + && ui + .small_button("auflegen") + .on_hover_text("Den vordersten auf ein Deck legen, das nicht läuft") + .clicked() + { + self.master_aktion("queue_next", None); + } + }); + + if liste.is_empty() { + ui.label( + RichText::new("nichts in der Liste") + .color(theme::TEXT_LEISE) + .size(11.0), + ); + return; + } + + for zeile in liste { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("{}", zeile.id)) + .monospace() + .color(theme::TEXT_LEISE) + .size(11.0), + ); + ui.label(RichText::new(&zeile.name).size(11.0)) + .on_hover_text(&zeile.name); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("weg").clicked() { + self.master_aktion("queue_drop", Some(&zeile.id.to_string())); + } + if ui + .small_button("vor") + .on_hover_text("Zum Nächsten machen") + .clicked() + { + self.master_aktion("queue_bump", Some(&zeile.id.to_string())); + } + }); + }); + + // Die Notiz ist der Grund, warum der Track dort steht. Ohne sie + // wäre die Liste eine Playlist. + if !zeile.notiz.is_empty() { + ui.label( + RichText::new(&zeile.notiz) + .color(theme::TEXT_LEISE) + .size(10.0), + ); + } + } + } + + /// Löst eine Master-Aktion über das Pult aus — denselben Weg, den ein + /// Agent nimmt. + fn master_aktion(&mut self, element: &str, argument: Option<&str>) { + let ergebnis = { + let Ok(mut pult) = self.pult.lock() else { + return; + }; + pult.ausloesen(&Schluessel::neu(Gruppe::Master, element), argument) + }; + match ergebnis { + Ok(zeilen) => { + if let Some(erste) = zeilen.first() { + self.status = erste.clone(); + } + } + Err(e) => self.status = e.to_string(), + } + } + + /// Schreibt ein Deck-Control über das Pult. + /// + /// Fehler landen in der Statuszeile statt im Nichts — was hier scheitert, + /// scheitert auch für einen Agenten, und dann will man es sehen. + fn deck_schreiben(&mut self, deck: usize, element: &str, wert: Wert) { + let ergebnis = { + let Ok(mut pult) = self.pult.lock() else { + return; + }; + pult.schreibe(&Schluessel::neu(Gruppe::Deck(deck), element), wert) + }; + if let Err(e) = ergebnis { + self.status = e.to_string(); + } + } + + fn deck_aktion(&mut self, deck: usize, element: &str, argument: Option<&str>) { + let ergebnis = { + let Ok(mut pult) = self.pult.lock() else { + return; + }; + pult.ausloesen(&Schluessel::neu(Gruppe::Deck(deck), element), argument) + }; + match ergebnis { + Ok(zeilen) => { + if let Some(erste) = zeilen.first() { + self.status = erste.clone(); + } + } + Err(e) => self.status = e.to_string(), + } + } + + fn playliste_zeigen(&mut self) { + let Ok(pult) = self.pult.lock() else { + return; + }; + if self.playliste.is_empty() { + self.treffer = pult.suche(""); + self.status = String::new(); + } else { + self.treffer = pult.playlist(&self.playliste); + self.status = format!("{}: {} Tracks", self.playliste, self.treffer.len()); + } + } + + /// Sucht über das Pult — denselben Weg, den ein Agent nimmt. + fn suchen(&mut self) { + self.playliste.clear(); + if let Ok(pult) = self.pult.lock() { + self.treffer = pult.suche(&self.suche); + } + } + + fn mischbar_suchen(&mut self, deck: usize) { + let Some(bpm) = self.decks.get(deck).and_then(|d| d.state.effective_bpm()) else { + self.status = "Das Deck hat kein Tempo".into(); + return; + }; + + if let Ok(pult) = self.pult.lock() { + self.treffer = pult.suche_mischbar(bpm); + } + self.status = format!("Mischbar mit {bpm:.2} BPM: {} Treffer", self.treffer.len()); + } + + fn harmonisch_suchen(&mut self, deck: usize) { + let tonart = { + let Ok(pult) = self.pult.lock() else { + return; + }; + pult.deck_tonart(deck) + }; + + // Ohne Tonart gibt es nichts zu suchen. Die Liste unverändert stehen + // zu lassen und nichts zu sagen, sähe aus wie ein leeres Ergebnis. + let Some(tonart) = tonart else { + self.status = format!( + "Deck {} hat keine Tonart — analysiert wird beim Laden", + if deck == 0 { "A" } else { "B" } + ); + return; + }; + + self.playliste.clear(); + if let Ok(pult) = self.pult.lock() { + self.treffer = pult.suche_harmonisch(tonart); + } + self.status = format!( + "Harmonisch zu {} ({}): {} Treffer", + tonart.name(), + tonart.camelot(), + self.treffer.len() + ); + } + + /// Lädt über denselben Weg, den ein Agent nimmt. + /// + /// Vorher hatte die Oberfläche ihren eigenen Ladepfad. Zwei Wege zum + /// selben Ziel heißt zwei Stellen, an denen es schiefgehen kann — und die + /// seltener benutzte fällt seltener auf. + fn laden(&mut self, deck: usize, eintrag: &Treffer) { + let ergebnis = { + let Ok(mut pult) = self.pult.lock() else { + return; + }; + let schluessel = Schluessel::neu(Gruppe::Deck(deck), "load"); + pult.ausloesen(&schluessel, Some(&eintrag.pfad)) + }; + + self.status = match ergebnis { + Ok(_) => format!("{} wird geladen …", eintrag.titel), + Err(e) => format!("Laden fehlgeschlagen: {e}"), + }; + } + + /// Nimmt nach ein paar Bildern ein Abbild auf und beendet sich. + fn screenshot_schritt(&mut self, ctx: &egui::Context) { + let Some(auftrag) = self.screenshot.as_mut() else { + return; + }; + + if !auftrag.angefordert { + if std::time::Instant::now() < auftrag.warte_bis { + ctx.request_repaint(); + return; + } + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); + auftrag.angefordert = true; + ctx.request_repaint(); + return; + } + + let bild = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Screenshot { image, .. } => Some(Arc::clone(image)), + _ => None, + }) + }); + + if let Some(bild) = bild { + let pfad = auftrag.pfad.clone(); + match speichern(&bild, &pfad) { + Ok(()) => println!("Screenshot: {}", pfad.display()), + Err(e) => eprintln!("Screenshot fehlgeschlagen: {e}"), + } + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + ctx.request_repaint(); + } +} + +fn speichern(bild: &egui::ColorImage, pfad: &std::path::Path) -> anyhow::Result<()> { + let [breite, hoehe] = bild.size; + let mut roh = Vec::with_capacity(breite * hoehe * 4); + for pixel in &bild.pixels { + roh.extend_from_slice(&[pixel.r(), pixel.g(), pixel.b(), pixel.a()]); + } + + let puffer = image::RgbaImage::from_raw(breite as u32, hoehe as u32, roh) + .ok_or_else(|| anyhow::anyhow!("Bildgröße passt nicht zu den Daten"))?; + puffer.save(pfad)?; + Ok(()) +} + +/// Zuweisung eines Decks am Crossfader nach Index. +pub fn assign_fuer(index: usize) -> Assign { + match index { + 0 => Assign::A, + 1 => Assign::B, + _ => Assign::Thru, + } +} + +/// Zahl ohne überflüssige Nachkommastellen — "4" statt "4.00". +/// `cue1` bis `cue8` — die Namen, unter denen das Pult sie kennt. +fn cue_name(index: usize) -> String { + format!("cue{}", index + 1) +} + +fn kurz(wert: f64) -> String { + if wert.fract().abs() < 1e-9 { + format!("{}", wert.round() as i64) + } else { + format!("{wert:.2}") + } +} + +fn zeit(secs: f64) -> String { + let secs = secs.max(0.0); + let m = (secs / 60.0).floor() as u64; + format!("{m}:{:05.2}", secs - m as f64 * 60.0) +} + +/// Ein Schieberegler, der an einem Control hängt. +/// +/// Die Oberfläche kennt weder Bereich noch Einheit noch Bedeutung — das steht +/// alles im Katalog, und der Regler holt es sich von dort. Ein neues Control +/// ist damit ohne eine Zeile hier bedienbar, und der Hilfetext, den ein Agent +/// über `list` bekommt, ist derselbe, der hier als Tooltip erscheint. Zwei +/// Beschreibungen, die auseinanderlaufen könnten, gibt es nicht. +fn regler(ui: &mut Ui, pult: &mut Steuerpult, key: &Schluessel, name: &str, senkrecht: bool) { + let Some(b) = pult.beschreibung(key) else { + return; + }; + let Some((min, max)) = b.bereich else { + return; + }; + let Ok(Wert::Zahl(aktuell)) = pult.lies(key) else { + return; + }; + + let mut wert = aktuell; + let mut schieber = egui::Slider::new(&mut wert, min..=max).show_value(false); + schieber = if senkrecht { + schieber.vertical() + } else { + schieber.text(RichText::new(name).size(10.0)) + }; + + let antwort = ui.add(schieber).on_hover_text(b.text); + if antwort.changed() { + let _ = pult.schreibe(key, Wert::Zahl(wert)); + } + // Doppelklick stellt zurück auf den Anfangswert des Bereichs bzw. die + // Mitte — bei einem bipolaren Control ist das die Raste. + if antwort.double_clicked() { + let zurueck = if b.einheit == Einheit::Bipolar { + 0.0 + } else if min <= 1.0 && max >= 1.0 { + 1.0 + } else { + min + }; + let _ = pult.schreibe(key, Wert::Zahl(zurueck)); + } +} + +fn kanalzug(ui: &mut Ui, pult: &mut Steuerpult, index: usize, farbe: Color32) { + egui::Frame::new() + .fill(theme::PANEL) + .inner_margin(7.0) + .corner_radius(4.0) + .show(ui, |ui| { + // Senkrecht, sonst stünden die Regler eines Zuges nebeneinander + // statt übereinander — der Rahmen erbt hier ein waagerechtes + // Layout vom Mixer. + ui.vertical(|ui| kanalzug_inhalt(ui, pult, index, farbe)); + }); +} + +fn kanalzug_inhalt(ui: &mut Ui, pult: &mut Steuerpult, index: usize, farbe: Color32) { + let Some(kanal) = pult.kanaele().get(index) else { + return; + }; + let name = kanal.name.clone(); + let cue_an = kanal.cue; + + ui.set_width(KANALBREITE); + ui.label(RichText::new(&name).color(farbe).strong().size(12.0)); + ui.spacing_mut().slider_width = KANALBREITE - 40.0; + // Enger als sonst: Acht Regler übereinander brauchen den Platz, und die + // Decks darüber brauchen ihn auch. + ui.spacing_mut().item_spacing.y = 2.0; + + // Nicht von Hand aufgezählt, sondern aus dem Katalog. Ein neues Control + // erscheint damit ohne eine Zeile hier — die Effekte waren die Probe + // darauf, und sie standen sofort da. + let gruppe = Gruppe::Kanal(index); + let controls: Vec<_> = pult + .liste() + .into_iter() + .filter(|(k, b)| { + k.gruppe == gruppe + && b.schreibbar + && !b.kurz.is_empty() + // Fader und Cue sind unten, als Fader und als Knopf. + && k.element != "fader" + && k.element != "cue" + }) + .collect(); + + for (schluessel, b) in controls { + match b.art { + control::Art::Zahl => regler(ui, pult, &schluessel, b.kurz, false), + control::Art::Auswahl => auswahl(ui, pult, &schluessel, &b), + _ => {} + } + } + + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.vertical(|ui| { + let knopf = ui.add( + egui::Button::new(RichText::new("CUE").size(10.0)) + .fill(if cue_an { + theme::CUE + } else { + theme::PANEL_HELL + }) + .min_size(egui::vec2(42.0, 22.0)), + ); + if knopf.clicked() { + let _ = pult.schreibe(&Schluessel::neu(gruppe, "cue"), Wert::Schalter(!cue_an)); + } + }); + + // Der Linefader steht senkrecht wie am Gerät. + ui.spacing_mut().slider_width = FADERHOEHE; + regler(ui, pult, &Schluessel::neu(gruppe, "fader"), "", true); + }); +} + +/// Ein Auswahlfeld, das seine Optionen aus dem Katalog holt. +fn auswahl(ui: &mut Ui, pult: &mut Steuerpult, key: &Schluessel, b: &control::Beschreibung) { + let Ok(Wert::Auswahl(aktuell)) = pult.lies(key) else { + return; + }; + + let mut gewaehlt = aktuell.clone(); + ui.horizontal(|ui| { + ui.label(RichText::new(b.kurz).size(10.0)); + egui::ComboBox::from_id_salt(key.to_string()) + .width(KANALBREITE - 46.0) + .selected_text(&gewaehlt) + .show_ui(ui, |ui| { + for option in b.auswahl { + ui.selectable_value(&mut gewaehlt, option.to_string(), *option); + } + }) + .response + .on_hover_text(b.text); + }); + + if gewaehlt != aktuell { + let _ = pult.schreibe(key, Wert::Auswahl(gewaehlt)); + } +} + +fn summe(ui: &mut Ui, pult: &mut Steuerpult) { + egui::Frame::new() + .fill(theme::PANEL) + .inner_margin(7.0) + .corner_radius(4.0) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.set_width(250.0); + ui.spacing_mut().slider_width = 214.0; + ui.label(RichText::new("SUMME").strong().size(12.0)); + + let g = Gruppe::Master; + regler( + ui, + pult, + &Schluessel::neu(g, "crossfader"), + "A « Crossfader » B", + false, + ); + regler( + ui, + pult, + &Schluessel::neu(g, "crossfader_curve"), + "Kurve weich » hart", + false, + ); + ui.separator(); + regler(ui, pult, &Schluessel::neu(g, "gain"), "MASTER", false); + regler( + ui, + pult, + &Schluessel::neu(g, "cue_gain"), + "KOPFHÖRER", + false, + ); + regler( + ui, + pult, + &Schluessel::neu(g, "cue_mix"), + "CUE « Mix » MASTER", + false, + ); + }); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ganze_zahlen_werden_ohne_nachkomma_gezeigt() { + assert_eq!(kurz(4.0), "4"); + assert_eq!(kurz(16.0), "16"); + assert_eq!(kurz(0.5), "0.50"); + } + + #[test] + fn zeit_wird_als_minuten_und_sekunden_gezeigt() { + assert_eq!(zeit(0.0), "0:00.00"); + assert_eq!(zeit(65.5), "1:05.50"); + assert_eq!(zeit(-3.0), "0:00.00"); + } + + #[test] + fn die_liste_zeigt_den_dateinamen_nicht_den_pfad() { + // In einer Spalte von 250 Pixeln ist der Ordner Ballast — und die + // interessante Hälfte steht hinten. + assert_eq!( + dateiname("/musik/haus/nachtschicht.mp3"), + "nachtschicht.mp3" + ); + assert_eq!(dateiname("nachtschicht.mp3"), "nachtschicht.mp3"); + // Was keinen Dateinamen hat, wird nicht zu einer leeren Zeile. + assert_eq!(dateiname("/musik/"), "musik"); + assert_eq!(dateiname(""), ""); + } + + #[test] + fn decks_liegen_auf_den_crossfader_seiten() { + assert_eq!(assign_fuer(0), Assign::A); + assert_eq!(assign_fuer(1), Assign::B); + // Alles Weitere — AUX etwa — bleibt unberührt vom Crossfader. + assert_eq!(assign_fuer(2), Assign::Thru); + } +} diff --git a/crates/musik-app/src/demo.rs b/crates/musik-app/src/demo.rs new file mode 100644 index 0000000..6002e26 --- /dev/null +++ b/crates/musik-app/src/demo.rs @@ -0,0 +1,260 @@ +//! Synthetische Tracks für den Fall ohne Musiksammlung. +//! +//! Damit lässt sich die Oberfläche starten und beurteilen, ohne dass Dateien +//! vorliegen müssen — und sie zeigt dabei echte Wellenformen, ein echtes +//! Beatgrid und eine echte Tonart, weil das Material durch dieselbe Analyse +//! läuft wie alles andere. +//! +//! Deshalb liegen über den Drums auch Akkorde und nicht nur ein Bass: Aus +//! Bass und Schlagzeug allein ermittelt die Analyse **keine** Tonart, und das +//! zu Recht (siehe `analysis::tonart`). Ohne Akkorde könnte die Demo die halbe +//! Anzeige nicht vorführen. +//! +//! Die beiden Decks stehen in a-Moll (8A) und e-Moll (9A) — auf dem +//! Camelot-Rad Nachbarn, also ein Paar, das sich harmonisch mischen lässt. + +use std::f32::consts::PI; + +use audio_core::Track; + +pub struct DemoTrack { + pub track: Track, + pub artist: String, + pub title: String, +} + +pub fn deck_a(sample_rate: u32) -> DemoTrack { + DemoTrack { + track: bauen(sample_rate, 128.0, 16, muster_a), + artist: "Demo".into(), + title: "Vier auf die Eins · 128 · Am".into(), + } +} + +pub fn deck_b(sample_rate: u32) -> DemoTrack { + DemoTrack { + track: bauen(sample_rate, 124.0, 16, muster_b), + artist: "Demo".into(), + title: "Snare-Muster · 124 · Em".into(), + } +} + +/// Die Akkordfolgen der beiden Demos, als Halbtöne über C4. +/// +/// Deck A: Am – Am – Dm – C, also a-Moll. Deck B: Em – Em – Am – G, also +/// e-Moll. Der Bass nimmt jeweils den Grundakkord eine bzw. zwei Oktaven +/// tiefer. +const STUFEN_A: [[i32; 3]; 4] = [[9, 12, 16], [9, 12, 16], [2, 5, 9], [0, 4, 7]]; +const STUFEN_B: [[i32; 3]; 4] = [[4, 7, 11], [4, 7, 11], [9, 12, 16], [7, 11, 14]]; + +fn muster_a(bar: usize, step: usize, add: &mut dyn FnMut(usize, f32), rate: u32) { + if step.is_multiple_of(4) { + kick(add, rate); + } + if step % 4 == 2 { + hat(add, rate, 0.22); + } + let akkord = STUFEN_A[bar % 4]; + if step.is_multiple_of(2) { + bass(add, hz(akkord[0] - 24), rate, 0.30); + } + // Einmal je Takt, dafür lang: Ein Akkord, der auf jedem Sechzehntel neu + // anschlägt, wäre ein Stakkato und kein Flächenklang. + if step == 0 { + flaeche(add, &akkord, rate, 0.10); + } +} + +fn muster_b(bar: usize, step: usize, add: &mut dyn FnMut(usize, f32), rate: u32) { + if step.is_multiple_of(4) { + kick(add, rate); + } + if step % 8 == 4 { + snare(add, rate); + } + let akkord = STUFEN_B[bar % 4]; + if step % 2 == 1 { + bass(add, hz(akkord[0] - 24), rate, 0.26); + } + if step == 0 { + flaeche(add, &akkord, rate, 0.20); + } +} + +/// Halbtöne über C4 in Hertz. +fn hz(halbton_ueber_c4: i32) -> f32 { + 261.63 * 2.0f32.powf(halbton_ueber_c4 as f32 / 12.0) +} + +/// Ein Muster füllt einen Sechzehntel-Schritt eines Taktes. +/// +/// Die Klänge werden über einen Rückruf addiert statt zurückgegeben, damit +/// sich mehrere Klänge im selben Schritt überlagern können. +type Muster = fn(usize, usize, &mut dyn FnMut(usize, f32), u32); + +fn bauen(rate: u32, bpm: f32, bars: usize, muster: Muster) -> Track { + let frames_je_step = rate as f32 * 60.0 / bpm / 4.0; + let total = (frames_je_step * 16.0 * bars as f32) as usize; + let mut mono = vec![0.0f32; total]; + + for bar in 0..bars { + for step in 0..16 { + let start = ((bar * 16 + step) as f32 * frames_je_step) as usize; + let mut add = |offset: usize, value: f32| { + let idx = start + offset; + if idx < total { + mono[idx] += value; + } + }; + muster(bar, step, &mut add, rate); + } + } + + let peak = mono.iter().fold(0.0f32, |m, v| m.max(v.abs())).max(1e-6); + let scale = 0.75 / peak; + + Track { + samples: mono + .into_iter() + .flat_map(|v| { + let s = v * scale; + [s, s] + }) + .collect(), + sample_rate: rate, + stems: Vec::new(), + } +} + +fn kick(add: &mut dyn FnMut(usize, f32), rate: u32) { + let n = (rate as f32 * 0.25) as usize; + for i in 0..n { + let t = i as f32 / rate as f32; + let env = (-t * 22.0).exp(); + let f = 110.0 * (-t * 30.0).exp() + 45.0; + add(i, (2.0 * PI * f * t).sin() * env * 0.9); + } +} + +fn snare(add: &mut dyn FnMut(usize, f32), rate: u32) { + let n = (rate as f32 * 0.19) as usize; + let mut seed = 0x2545_F491u32; + for i in 0..n { + let t = i as f32 / rate as f32; + let env = (-t * 30.0).exp(); + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let noise = (seed >> 8) as f32 / 8_388_608.0 - 1.0; + add( + i, + (noise * 0.6 + (2.0 * PI * 190.0 * t).sin() * 0.4) * env * 0.6, + ); + } +} + +fn hat(add: &mut dyn FnMut(usize, f32), rate: u32, gain: f32) { + let n = (rate as f32 * 0.05) as usize; + let mut seed = 0x9E37_79B9u32; + for i in 0..n { + let t = i as f32 / rate as f32; + let env = (-t * 120.0).exp(); + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let noise = (seed >> 8) as f32 / 8_388_608.0 - 1.0; + add(i, noise * env * gain); + } +} + +/// Ein Akkord als Flächenklang — die Harmonik der Demo. +/// +/// Jede Stimme bekommt ein paar Obertöne, damit sie im Spektrum breiter steht +/// als ein nackter Sinus. Leise, weil sie unter den Drums liegen soll und +/// nicht darüber. +fn flaeche(add: &mut dyn FnMut(usize, f32), halbtoene: &[i32], rate: u32, gain: f32) { + let n = (rate as f32 * 1.6) as usize; + for i in 0..n { + let t = i as f32 / rate as f32; + // Weich ein und aus, sonst knackt es und schmiert breitbandig. + let huelle = (t * 8.0).min(1.0).min((1.6 - t) * 6.0).max(0.0); + + let mut wert = 0.0; + for halbton in halbtoene { + let grund = hz(*halbton); + for (ober, staerke) in [(1.0, 1.0), (2.0, 0.4), (3.0, 0.2)] { + wert += (2.0 * PI * grund * ober * t).sin() * staerke; + } + } + add(i, wert * huelle * gain / halbtoene.len() as f32); + } +} + +fn bass(add: &mut dyn FnMut(usize, f32), freq: f32, rate: u32, gain: f32) { + let n = (rate as f32 * 0.3) as usize; + for i in 0..n { + let t = i as f32 / rate as f32; + let env = (1.0 - (-t * 120.0).exp()) * (-t * 6.0).exp(); + let saw = 2.0 * ((freq * t) % 1.0) - 1.0; + add(i, saw * env * gain); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Die Demo verspricht in ihrem Titel ein Tempo. Findet die Analyse es + /// nicht wieder, zeigt die Oberfläche „— BPM" und Sync bleibt tot — dann + /// taugt die Demo nicht als Vorführung. + #[test] + fn die_analyse_findet_das_versprochene_tempo() { + for (demo, erwartet) in [(deck_a(48_000), 128.0), (deck_b(48_000), 124.0)] { + let analyse = analysis::analyze(&demo.track); + let bpm = analyse + .bpm + .unwrap_or_else(|| panic!("{}: kein Tempo erkannt", demo.title)); + assert!( + (bpm - erwartet).abs() < 1.0, + "{}: {bpm} statt {erwartet}", + demo.title + ); + assert!( + analyse.beat_anchor_frames.is_some(), + "{}: kein Anker", + demo.title + ); + } + } + + /// Dasselbe für die Tonart — und aus demselben Grund. + /// + /// Die Demo ist die einzige Stelle, an der sich die Anzeige ohne eigene + /// Sammlung beurteilen lässt. Zeigt sie hier „—", ist entweder die + /// Erkennung kaputt oder das Material taugt nicht als Vorführung; beides + /// will man wissen, bevor jemand das Fenster aufmacht. + #[test] + fn die_analyse_findet_die_versprochene_tonart() { + for (demo, erwartet) in [(deck_a(48_000), "Am"), (deck_b(48_000), "Em")] { + let analyse = analysis::analyze(&demo.track); + let tonart = analyse + .tonart() + .unwrap_or_else(|| panic!("{}: keine Tonart erkannt", demo.title)); + assert_eq!(tonart.name(), erwartet, "{}", demo.title); + } + } + + /// Die beiden Decks sollen sich harmonisch mischen lassen. + /// + /// Sonst führt die Demo zwar eine Tonart vor, aber nicht, wozu sie da ist. + #[test] + fn die_beiden_demo_decks_passen_harmonisch_zueinander() { + let a = analysis::analyze(&deck_a(48_000).track).tonart().unwrap(); + let b = analysis::analyze(&deck_b(48_000).track).tonart().unwrap(); + + assert!( + a.passt_zu(&b), + "{} ({}) passt nicht zu {} ({})", + a.name(), + a.camelot(), + b.name(), + b.camelot() + ); + } +} diff --git a/crates/musik-app/src/main.rs b/crates/musik-app/src/main.rs new file mode 100644 index 0000000..24c98d0 --- /dev/null +++ b/crates/musik-app/src/main.rs @@ -0,0 +1,476 @@ +//! Die Oberfläche des DJ-Werkzeugs. +//! +//! ```text +//! musik-app # mit synthetischen Demo-Tracks +//! musik-app --db musik.db # mit der eigenen Sammlung +//! musik-app --a a.mp3 --b b.mp3 # zwei Dateien direkt auf die Decks +//! musik-app --screenshot bild.png # ein Bild aufnehmen und beenden +//! ``` +//! +//! Ohne Audiogerät läuft alles trotzdem: Der Mixer wird dann von einem +//! Taktgeber im Leerlauf gerendert, sodass die Decks sich bewegen und die +//! Oberfläche sich beurteilen lässt. Nur hören kann man nichts. + +mod app; +mod demo; +mod sammlung; +mod theme; +mod waveform; + +use std::path::PathBuf; +use std::sync::Arc; + +use analysis::Store; +use anyhow::{Context, Result}; +use audio_core::deck::{DeckState, Voice}; +use audio_core::{Beatgrid, Track}; +use audio_engine::{aux_channel, DeckSource, Engine, EngineRunner, Output}; +use control::{DeckEintrag, KanalSpiegel, Server, Steuerpult}; +use library::Library; + +use app::{assign_fuer, DeckUi, MusikApp, Screenshot}; + +const RATE: u32 = 48_000; + +struct Args { + db: Option, + cache: PathBuf, + a: Option, + b: Option, + screenshot: Option, + screenshot_nach: std::time::Duration, + socket: PathBuf, + /// Auf welchem Port die Steuerung über die Rückschleife lauscht. + /// + /// `None` heißt: über den Unix-Socket. Wo es die nicht gibt — Windows —, + /// ist das die einzige Art, überhaupt eine Steuerung zu haben, und deshalb + /// dort die Vorgabe. + tcp: Option, + /// Woher der AUX-Kanal sein Audio bekommt. + /// + /// `None` heißt: gar nicht — dann bleibt AUX still wie bisher. `Some("")` + /// heißt: Standardgerät. Ein Text wählt ein Gerät über einen Teil seines + /// Namens. + aux_in: Option, +} + +/// Der Port für den Weg über die Rückschleife. +/// +/// Frei gewählt und hoch genug, dass er niemandem im Weg steht; wem er im Weg +/// steht, der nimmt `--tcp-port`. +const STANDARD_PORT: u16 = 7657; + +/// Wo die Steuerung lauscht. Im Laufzeitverzeichnis des Benutzers, nicht in +/// `/tmp` — dort könnte jeder andere Benutzer des Rechners mitsteuern. +fn standard_socket() -> PathBuf { + match std::env::var_os("XDG_RUNTIME_DIR") { + Some(dir) => PathBuf::from(dir).join("musik.sock"), + None => std::env::temp_dir().join(format!("musik-{}.sock", std::process::id())), + } +} + +fn main() -> Result<()> { + let args = parse_args()?; + + let mut engine = Engine::new(RATE as f32); + let cache = Store::new(&args.cache); + + let demo = args.a.is_none() && args.b.is_none(); + let mut decks = Vec::new(); + let mut eintraege = Vec::new(); + + // Vor der Deck-Schleife: Ein mit --a geladener Track soll seine Cues von + // Anfang an haben. Sonst stünde das Deck leer da, und der erste gesetzte + // Cue überschriebe stillschweigend die gespeicherten. + let mut library = args.db.as_ref().and_then(|p| Library::open(p).ok()); + + for (index, (name, pfad)) in [("DECK A", &args.a), ("DECK B", &args.b)] + .into_iter() + .enumerate() + { + let (track, artist, titel) = match pfad { + Some(p) => { + // Mit Einzelspuren, falls ein `.stems`-Ordner danebenliegt — + // derselbe Weg wie beim Laden zur Laufzeit. + let track = Track::decode_mit_stems(p) + .with_context(|| format!("{} nicht lesbar", p.display()))? + .resampled_to(RATE); + let titel = p + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + (track, String::new(), titel) + } + None => { + let d = if index == 0 { + demo::deck_a(RATE) + } else { + demo::deck_b(RATE) + }; + (d.track, d.artist, d.title) + } + }; + + let (analyse, gerechnet) = analysis::analyze_cached(&track, &cache); + if gerechnet && pfad.is_some() { + let _ = cache.save(&analyse); + } + + let state = Arc::new(DeckState::new()); + state.set_keylock(true); + // Was in der Sammlung steht, schlägt die frische Analyse — dieselbe + // Regel wie beim Laden zur Laufzeit, siehe `sammlung::fertigen`. + let gespeichert = pfad + .as_ref() + .and_then(|p| gespeichertes(library.as_ref(), &p.to_string_lossy(), RATE)); + let (gespeicherte_cues, gespeichertes_grid) = gespeichert.unwrap_or_default(); + + for (nummer, frame) in &gespeicherte_cues { + state.set_cue(*nummer, Some(*frame)); + } + state.set_grid(gespeichertes_grid.or_else(|| { + analyse + .bpm + .map(|bpm| Beatgrid::new(bpm, analyse.beat_anchor_frames.unwrap_or(0), 1.0)) + })); + + let frames = track.frames() as u64; + let stems: Vec = track.stems.iter().map(|s| s.name.clone()).collect(); + let voice = Voice::new(Arc::new(track), Arc::clone(&state)); + let kanal = engine.add_channel(name, Box::new(DeckSource::new(voice))); + engine.channel(kanal).set_assign(assign_fuer(index)); + + // Im Demobetrieb laufen die Decks sofort, damit es etwas zu sehen gibt. + // Mit eigenen Dateien bleibt der Fader unten — ein Deck, das beim + // Start auf die Anlage geht, ist ein Unfall. + let fader = if demo { 0.8 } else { 0.0 }; + engine.channel(kanal).set_fader(fader); + state.set_playing(demo); + + // Auf den ersten Downbeat, nicht auf Sekunde 0 — dieselbe Regel wie + // beim Laden zur Laufzeit, siehe `sammlung::fertigen`. Im Demobetrieb + // nicht: Dort laufen die Decks schon, und ein Sprung mitten hinein + // wäre ein Ruckler ohne Grund. + if !demo { + if let Some(einstieg) = analyse.struktur().and_then(|s| s.einstieg_frames()) { + state.seek_frames(einstieg); + } + } + + let mut eintrag = DeckEintrag::neu(Arc::clone(&state), kanal, RATE); + eintrag.frames = frames; + eintrag.titel = titel; + eintrag.artist = artist; + eintrag.tonart = analyse.tonart(); + eintrag.struktur = analyse.struktur(); + eintrag.stems = stems; + // Nur bei echten Dateien: Ein Demo-Track steht in keiner Sammlung, und + // ein Cue darauf hat nichts, wohin er gespeichert werden könnte. + if let Some(p) = pfad { + eintrag.pfad = p.to_string_lossy().into_owned(); + } + eintraege.push((eintrag, fader, name)); + + decks.push(DeckUi { + name: name.to_string(), + state, + peaks: analyse.peaks.iter().filter_map(|p| p.to_level()).collect(), + frames, + sample_rate: RATE, + zoom_secs: 8.0, + loop_beats: 4.0, + }); + } + + // Ohne `--aux-in` bleibt AUX still, ist aber vorhanden und bedienbar. + let (aux_writer, aux_source) = aux_channel(RATE as usize * 2); + let aux_kanal = engine.add_channel("AUX", Box::new(aux_source)); + engine.channel(aux_kanal).set_assign(assign_fuer(2)); + + // Der Eingang wird **gehalten**, nicht nur geöffnet: Fällt er weg, schließt + // sich der Stream, und AUX ist wieder still — ohne dass etwas danach + // aussähe. + let _eingang = match &args.aux_in { + Some(wunsch) => { + let wunsch = Some(wunsch.as_str()).filter(|w| !w.is_empty()); + match audio_engine::Eingang::open(aux_writer, RATE, wunsch) { + Ok(e) => { + println!( + "AUX: {} — {} Kanal/Kanäle bei {} Hz", + e.device_name(), + e.channels(), + e.sample_rate() + ); + Some(e) + } + // Kein Abbruch: Ein fehlender Eingang ist kein Grund, das Pult + // nicht zu öffnen. Verschwiegen wird er nicht. + Err(e) => { + eprintln!("AUX: {e}"); + eprintln!(" Der Kanal bleibt still; alles andere läuft."); + None + } + } + } + None => None, + }; + + // Der Mitschnitt greift hinter dem Begrenzer ab. Angelegt vor der + // Kommandoschlange, weil die Engine danach in den Audio-Thread wandert. + let (tap, aufnahme) = audio_engine::mitschnitt(RATE); + engine.set_mitschnitt(tap); + + let (handle, runner) = audio_engine::engine_channel(engine, 512); + + // Das Steuerpult bekommt alles, was bedienbar ist — und ist danach die + // einzige Stelle, an der Reglerstellungen stehen. + let mut pult = Steuerpult::neu(handle); + for (eintrag, fader, name) in eintraege { + let assign = assign_fuer(pult.decks().len()); + let mut spiegel = KanalSpiegel::neu(name, assign); + spiegel.fader = fader as f64; + pult.kanal_hinzufuegen(spiegel); + pult.deck_hinzufuegen(eintrag); + } + pult.kanal_hinzufuegen(KanalSpiegel::neu("AUX", assign_fuer(2))); + + // Suchen und Laden hängen das Pult an die Sammlung und an den Dekodierer. + // Ohne das antworten `search` und `load` mit einem Fehler, statt + // stillschweigend nichts zu tun. + let deck_zustaende: Vec<_> = pult + .decks() + .iter() + .map(|d| (Arc::clone(&d.state), d.sample_rate)) + .collect(); + let (sammlung, ergebnisse) = + sammlung::AppSammlung::neu(library.take(), deck_zustaende, args.cache.clone()); + pult.sammlung_setzen(Box::new(sammlung)); + pult.aufnahme_setzen(aufnahme); + // Die erste Liste kommt über denselben Weg wie jede spätere Suche. + let treffer = pult.suche(""); + let playlisten = pult.playlists(); + + let pult = Arc::new(std::sync::Mutex::new(pult)); + + // Der Zeitplan läuft unabhängig vom Socket: Auch die Oberfläche darf eine + // Rampe auslösen, und dann muss sie jemand weiterdrehen. + let _takt = control::zeitplan::takt_starten(Arc::clone(&pult)); + + let (output, hinweis) = match Output::open(runner) { + Ok(out) => { + let cue = if out.has_cue_output() { + "Cue auf 3/4".to_string() + } else { + format!("nur {} Kanäle — kein Vorhören", out.channels()) + }; + let text = format!("{} · {} Hz · {}", out.device_name(), out.sample_rate(), cue); + (Some(out), text) + } + Err(fehler) => { + let text = format!("kein Audiogerät ({fehler}) — Trockenlauf"); + // Der Mixer läuft weiter, nur eben ins Leere — sonst stünde die + // Oberfläche still und wäre nicht zu beurteilen. + trockenlauf(fehler.runner); + (None, text) + } + }; + + // Ab hier ist die Anlage von außen bedienbar. Scheitert das, läuft die + // Oberfläche trotzdem — nur eben allein. + let gestartet = match args.tcp { + Some(port) => { + let adresse = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + Server::starten_tcp(adresse, Arc::clone(&pult)) + } + None => Server::starten(&args.socket, Arc::clone(&pult)), + }; + let steuerung = match gestartet { + Ok(server) => { + println!("Steuerung: {}", server.adresse()); + if let Some(schluessel) = server.schluessel() { + // Der Schlüssel steht nur hier. Er in eine Datei zu schreiben + // hieße, ihn dort liegen zu lassen, wo ihn jeder findet — und + // genau davor soll er schützen. + println!("Schlüssel: {schluessel}"); + println!(" Erste Zeile jeder Verbindung: auth {schluessel}"); + } + Some(server) + } + Err(e) => { + eprintln!("Steuerung nicht verfügbar: {e}"); + None + } + }; + + let anwendung = MusikApp { + ergebnisse, + pult, + output, + audio_hinweis: hinweis, + decks, + suche: String::new(), + treffer, + playlisten, + playliste: String::new(), + status: if demo { + "Demobetrieb mit synthetischen Tracks".into() + } else { + String::new() + }, + screenshot: args.screenshot.map(|pfad| Screenshot { + pfad, + warte_bis: std::time::Instant::now() + args.screenshot_nach, + angefordert: false, + }), + }; + + let optionen = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([1500.0, 950.0]) + .with_min_inner_size([1100.0, 760.0]) + .with_title("Musik"), + ..Default::default() + }; + + eframe::run_native( + "Musik", + optionen, + Box::new(|cc| { + theme::stil(&cc.egui_ctx); + Ok(Box::new(anwendung)) + }), + ) + .map_err(|e| anyhow::anyhow!("Fenster ließ sich nicht öffnen: {e}"))?; + + // Erst hier fallen lassen: Der Socket wird beim Aufräumen entfernt, und + // das soll nicht schon passieren, während das Fenster noch steht. + drop(steuerung); + Ok(()) +} + +fn parse_args() -> Result { + let mut args = Args { + db: None, + cache: PathBuf::from(".musik-analyse"), + a: None, + b: None, + screenshot: None, + // Lang genug, dass das Fenster steht und die erste Wellenform + // gezeichnet ist. + screenshot_nach: std::time::Duration::from_millis(600), + socket: standard_socket(), + // Ohne Unix-Sockets bleibt nur die Rückschleife. Lieber eine Steuerung + // mit Schlüssel als gar keine — ohne sie fehlt auf Windows der ganze + // Teil, um dessentwillen das Projekt gebaut wird. + tcp: (!cfg!(unix)).then_some(STANDARD_PORT), + aux_in: None, + }; + + let mut iter = std::env::args().skip(1); + while let Some(arg) = iter.next() { + let mut wert = || iter.next().context("fehlender Wert"); + match arg.as_str() { + "--db" => args.db = Some(PathBuf::from(wert()?)), + "--cache" => args.cache = PathBuf::from(wert()?), + "--a" => args.a = Some(PathBuf::from(wert()?)), + "--b" => args.b = Some(PathBuf::from(wert()?)), + "--screenshot" => args.screenshot = Some(PathBuf::from(wert()?)), + "--socket" => args.socket = PathBuf::from(wert()?), + "--tcp" => args.tcp = Some(STANDARD_PORT), + "--tcp-port" => { + args.tcp = Some( + wert()? + .parse() + .context("--tcp-port braucht eine Portnummer")?, + ) + } + "--aux-in" => args.aux_in = Some(String::new()), + "--aux-gerät" | "--aux-geraet" => args.aux_in = Some(wert()?), + "--aux-liste" => { + let geraete = audio_engine::eingang::geraete(); + if geraete.is_empty() { + println!("Kein Aufnahmegerät gefunden."); + } else { + println!("Aufnahmegeräte:"); + for g in geraete { + println!(" {g}"); + } + } + std::process::exit(0); + } + "--screenshot-nach" => { + let sekunden: f64 = wert()? + .parse() + .context("--screenshot-nach braucht Sekunden")?; + args.screenshot_nach = std::time::Duration::from_secs_f64(sekunden.max(0.0)); + } + "-h" | "--help" => { + println!("Aufruf: musik-app [--db ] [--a ] [--b ]"); + println!(" [--cache ] [--screenshot ]"); + println!(" [--socket ] [--screenshot-nach ]"); + println!(" [--aux-in] [--aux-gerät ] [--aux-liste]"); + println!(" [--tcp] [--tcp-port ]"); + println!(); + println!("--aux-in legt den AUX-Kanal auf das Standard-Aufnahmegerät"); + println!("--aux-gerät wählt eines über einen Teil seines Namens"); + println!("--aux-liste zeigt, welche es gibt"); + println!(); + println!("--tcp Steuerung über 127.0.0.1 statt über den Socket,"); + println!(" mit Schlüssel. Auf Windows die Vorgabe, weil es"); + println!(" dort keine Unix-Sockets gibt."); + std::process::exit(0); + } + other => anyhow::bail!("unbekannte Option: {other}"), + } + } + + Ok(args) +} + +/// Was die Sammlung einem Deck mitgibt: Hot Cues als (Nummer, Frame) und ein +/// Beatgrid. +type Gespeichertes = (Vec<(usize, u64)>, Option); + +/// Hot Cues und Beatgrid, die für diesen Pfad in der Sammlung stehen. +/// +/// Dasselbe, was `sammlung::AppSammlung` beim Laden zur Laufzeit tut — hier +/// noch einmal, weil die Decks beim Start an der Sammlung vorbei geladen +/// werden. +fn gespeichertes(library: Option<&Library>, pfad: &str, rate: u32) -> Option { + let lib = library?; + let eintrag = lib.track_by_path(pfad).ok().flatten()?; + let grid = eintrag.beatgrid(rate); + + let cues = eintrag + .id + .and_then(|id| lib.cues(id).ok()) + .map(|zeilen| { + zeilen + .iter() + .filter_map(|c| { + let nummer = c.hotcue? as usize; + (nummer < audio_core::deck::HOT_CUES).then(|| (nummer, c.frame(rate))) + }) + .collect() + }) + .unwrap_or_default(); + + Some((cues, grid)) +} + +/// Rendert den Mixer im Leerlauf, wenn kein Audiogerät da ist. +/// +/// Ohne das stünden die Decks still und die Oberfläche wäre ein Standbild — +/// gerade in einer Umgebung ohne Soundkarte wäre sie dann nicht zu beurteilen. +fn trockenlauf(mut runner: EngineRunner) { + std::thread::spawn(move || { + const BLOCK: usize = 1_024; + let mut puffer = vec![0.0f32; BLOCK * 4]; + let takt = std::time::Duration::from_secs_f64(BLOCK as f64 / RATE as f64); + + loop { + runner.render(&mut puffer, 4); + std::thread::sleep(takt); + } + }); +} diff --git a/crates/musik-app/src/sammlung.rs b/crates/musik-app/src/sammlung.rs new file mode 100644 index 0000000..94cbc48 --- /dev/null +++ b/crates/musik-app/src/sammlung.rs @@ -0,0 +1,429 @@ +//! Suchen und Laden für das Steuerpult. +//! +//! Das Pult darf beim Laden nicht blockieren: Dekodieren und Analysieren +//! dauern Sekunden, und es liegt währenddessen unter einem Mutex, an dem die +//! Oberfläche hängt. Ein `do deck1.load` würde das Bild einfrieren. +//! +//! Deshalb drei Stationen. Der Auftrag wird angenommen und weitergereicht; ein +//! Arbeiter-Thread macht die langsame Arbeit ohne jedes Schloss; der +//! UI-Thread setzt das Ergebnis ein, weil dort ohnehin die Wellenform-Spitzen +//! liegen. Wer wissen will, wann der Track liegt, fragt `deckN.load_status` +//! oder abonniert es. + +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::Arc; + +use analysis::peaks::PeakLevel; +use analysis::Store; +use audio_core::deck::{DeckState, Voice}; +use audio_core::{Beatgrid, Tonart, Track}; +use control::{Sammlung, Treffer}; +use library::{CueKind, CueRecord, Library, Query, TrackRecord}; + +/// Ein angenommener Auftrag auf dem Weg zum Arbeiter. +pub struct Auftrag { + pub deck: usize, + pub pfad: String, + pub state: Arc, + pub sample_rate: u32, + /// Hot Cues aus der Sammlung, als (Nummer ab 0, Frames). + /// + /// Gelesen wird beim Annehmen des Auftrags, nicht im Arbeiter: Dort liegt + /// die Sammlung, und acht Zeilen SQLite kosten nichts. + pub cues: Vec<(usize, u64)>, + /// Beatgrid aus der Sammlung. Schlägt das der Analyse — was hier steht, + /// kann aus Traktor stammen oder von Hand korrigiert sein. + pub grid: Option, +} + +/// Was der Arbeiter zurückgibt. +pub struct Ergebnis { + pub deck: usize, + pub pfad: String, + pub ausgang: Result, +} + +pub struct Fertig { + pub voice: Voice, + pub peaks: Vec, + pub frames: u64, + pub titel: String, + pub artist: String, + pub tonart: Option, + /// Gliederung aus der Analyse — Intro, Drop, Outro und der Einstiegspunkt. + pub struktur: Option, + /// Namen der Einzelspuren, falls der Track welche mitbringt. + pub stems: Vec, + /// Der erste Downbeat in Frames — dorthin gehört das Deck nach dem Laden. + /// + /// Steht hier und wird nicht im Arbeiter gesprungen: Dort hängt am + /// `DeckState` noch die *alte* Stimme, und die verbraucht den Sprung, bevor + /// die neue überhaupt eingesetzt ist. Das Deck stand danach wieder auf + /// Sekunde 0 — genau der Fehler, den die Mitschrift aufgedeckt hat, nur + /// eine Ebene tiefer. + pub einstieg: Option, +} + +/// Die Umsetzung, die das Pult bekommt. +pub struct AppSammlung { + library: Option, + auftraege: Sender, + /// Deck-Zustände, damit ein Auftrag weiß, worauf er wirkt. + decks: Vec<(Arc, u32)>, + /// Die Sidecar-Ablage — dort liegt die Gliederung, aus der sich die + /// Energie eines Tracks ergibt, ohne dass sie in der Datenbank stünde. + store: Store, +} + +impl AppSammlung { + /// Was die Sammlung über einen Track weiß, das ein Deck braucht. + /// + /// Leer, wenn keine Sammlung offen ist oder der Track nicht darin steht — + /// dann trägt allein die Analyse, und das ist kein Fehler. + fn gespeichertes(&self, pfad: &str, rate: u32) -> (Vec<(usize, u64)>, Option) { + let Some(lib) = self.library.as_ref() else { + return (Vec::new(), None); + }; + let Ok(Some(eintrag)) = lib.track_by_path(pfad) else { + return (Vec::new(), None); + }; + + let grid = eintrag.beatgrid(rate); + let cues = match eintrag.id.map(|id| lib.cues(id)) { + Some(Ok(zeilen)) => zeilen + .iter() + .filter_map(|c| { + let nummer = c.hotcue? as usize; + (nummer < audio_core::deck::HOT_CUES).then(|| (nummer, c.frame(rate))) + }) + .collect(), + _ => Vec::new(), + }; + + (cues, grid) + } + + pub fn neu( + library: Option, + decks: Vec<(Arc, u32)>, + cache: PathBuf, + ) -> (AppSammlung, Receiver) { + let (auftraege, eingang) = std::sync::mpsc::channel::(); + let (ausgang, ergebnisse) = std::sync::mpsc::channel::(); + + let store = Store::new(&cache); + std::thread::Builder::new() + .name("lader".into()) + .spawn(move || arbeiten(eingang, ausgang, cache)) + .expect("Lade-Thread ließ sich nicht starten"); + + ( + AppSammlung { + library, + auftraege, + decks, + store, + }, + ergebnisse, + ) + } +} + +impl Sammlung for AppSammlung { + fn suchen(&self, text: &str, grenze: usize) -> Vec { + let Some(lib) = self.library.as_ref() else { + return Vec::new(); + }; + + let mut query = if text.is_empty() { + Query::default() + } else { + Query::text(text) + }; + query.limit = Some(grenze as u32); + + lib.search(&query) + .unwrap_or_default() + .into_iter() + .map(|e| treffer_aus(&self.store, e)) + .collect() + } + + fn suchen_mischbar(&self, bpm: f32, grenze: usize) -> Vec { + let Some(lib) = self.library.as_ref() else { + return Vec::new(); + }; + + let mut query = Query::mixable_with(bpm, 0.06); + query.limit = Some(grenze as u32); + lib.search(&query) + .unwrap_or_default() + .into_iter() + .map(|e| treffer_aus(&self.store, e)) + .collect() + } + + fn suchen_harmonisch(&self, tonart: Tonart, grenze: usize) -> Vec { + let Some(lib) = self.library.as_ref() else { + return Vec::new(); + }; + + let mut query = Query::harmonic_with(tonart); + query.limit = Some(grenze as u32); + lib.search(&query) + .unwrap_or_default() + .into_iter() + .map(|e| treffer_aus(&self.store, e)) + .collect() + } + + fn playlists(&self) -> Vec { + let Some(lib) = self.library.as_ref() else { + return Vec::new(); + }; + lib.playlists() + .unwrap_or_default() + .into_iter() + .map(|(_, name)| name) + .collect() + } + + fn playlist(&self, name: &str, grenze: usize) -> Vec { + let Some(lib) = self.library.as_ref() else { + return Vec::new(); + }; + let Ok(listen) = lib.playlists() else { + return Vec::new(); + }; + let Some((id, _)) = listen.into_iter().find(|(_, n)| n == name) else { + return Vec::new(); + }; + + lib.playlist_tracks(id) + .unwrap_or_default() + .into_iter() + .take(grenze) + .map(|e| treffer_aus(&self.store, e)) + .collect() + } + + fn laden(&self, deck: usize, pfad: &str) -> Result<(), String> { + let Some((state, sample_rate)) = self.decks.get(deck) else { + return Err(format!("deck{} gibt es nicht", deck + 1)); + }; + + // Früh prüfen: Ein Tippfehler im Pfad soll sofort auffallen und nicht + // erst Sekunden später als Status am Deck. + if !std::path::Path::new(pfad).is_file() { + return Err(format!("{pfad} gibt es nicht")); + } + + let (cues, grid) = self.gespeichertes(pfad, *sample_rate); + + self.auftraege + .send(Auftrag { + deck, + pfad: pfad.to_string(), + state: Arc::clone(state), + sample_rate: *sample_rate, + cues, + grid, + }) + .map_err(|_| "der Lader läuft nicht mehr".to_string()) + } + + fn hot_cues_speichern(&self, pfad: &str, cues: &[(usize, f64)]) -> Result<(), String> { + let Some(lib) = self.library.as_ref() else { + return Err("keine Sammlung geöffnet — mit --db starten".into()); + }; + let id = lib + .track_id_by_path(pfad) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("{pfad} steht nicht in der Sammlung"))?; + + let zeilen: Vec = cues + .iter() + .map(|(nummer, sekunden)| CueRecord { + id: None, + track_id: id, + hotcue: Some(*nummer as u8), + position_ms: sekunden * 1_000.0, + name: None, + kind: CueKind::Cue, + }) + .collect(); + + // Nur die Hot Cues — der Grid-Marker aus dem Traktor-Import liegt in + // derselben Tabelle und geht das Deck nichts an. + lib.replace_hot_cues(id, &zeilen).map_err(|e| e.to_string()) + } + + fn grid_speichern(&self, pfad: &str, bpm: f32, anker_sekunden: f64) -> Result<(), String> { + let Some(lib) = self.library.as_ref() else { + return Err("keine Sammlung geöffnet — mit --db starten".into()); + }; + let mut eintrag = lib + .track_by_path(pfad) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("{pfad} steht nicht in der Sammlung"))?; + + eintrag.bpm = Some(bpm); + eintrag.beat_anchor_ms = Some(anker_sekunden * 1_000.0); + lib.upsert_track(&eintrag) + .map(|_| ()) + .map_err(|e| e.to_string()) + } +} + +/// Was in der Liste als Titel steht. +/// +/// Ohne Tags — bei WAV-Dateien der Normalfall — bleibt der Dateiname. Der +/// ganze Pfad wäre in einer Trefferliste unbrauchbar: er ist zu lang, und der +/// unterscheidende Teil steht ganz hinten. +pub fn anzeigename(eintrag: &TrackRecord) -> String { + if let Some(titel) = eintrag.title.as_ref().filter(|t| !t.trim().is_empty()) { + return titel.clone(); + } + + std::path::Path::new(&eintrag.path) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| eintrag.path.clone()) +} + +fn treffer_aus(store: &Store, eintrag: TrackRecord) -> Treffer { + // Die Energie steht nicht in der Datenbank, sondern in der Gliederung + // neben der Datei. Das ist Absicht: Sie hängt am *Inhalt* und nicht am + // Eintrag, und ein neu analysierter Track soll sie mitbringen, ohne dass + // jemand die Sammlung nachpflegt. Fehlt die Analyse, bleibt sie leer — + // eine erfundene mittlere Energie wäre schlimmer als keine. + let energie = eintrag + .fingerprint + .as_deref() + .and_then(|f| store.load(f)) + .and_then(|a| a.struktur()) + .and_then(|s| control::bogen::energie_des_tracks(&s)); + + Treffer { + titel: anzeigename(&eintrag), + artist: eintrag.artist, + bpm: eintrag.bpm, + // Was sich nicht lesen lässt, wird nicht gezeigt — eine falsch + // gedeutete Tonart wäre schlimmer als eine fehlende. + tonart: eintrag.musical_key.as_deref().and_then(Tonart::parse), + energie, + pfad: eintrag.path, + } +} + +/// Der Arbeiter. Nimmt sich Zeit, hält kein Schloss. +fn arbeiten(eingang: Receiver, ausgang: Sender, cache: PathBuf) { + let store = Store::new(&cache); + + for auftrag in eingang { + let ergebnis = fertigen(&auftrag, &store); + if ausgang + .send(Ergebnis { + deck: auftrag.deck, + pfad: auftrag.pfad, + ausgang: ergebnis, + }) + .is_err() + { + // Niemand hört mehr zu — die Anwendung wird beendet. + return; + } + } +} + +fn fertigen(auftrag: &Auftrag, store: &Store) -> Result { + // Mit Stems, wenn welche danebenliegen. Ohne Ordner ist das Ergebnis + // dasselbe wie ohne — kein Sonderweg, den jemand einschalten müsste. + let track = Track::decode_mit_stems(std::path::Path::new(&auftrag.pfad)) + .map_err(|e| format!("nicht lesbar: {e}"))? + .resampled_to(auftrag.sample_rate); + + let (analyse, gerechnet) = analysis::analyze_cached(&track, store); + if gerechnet { + let _ = store.save(&analyse); + } + + let titel = std::path::Path::new(&auftrag.pfad) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| auftrag.pfad.clone()); + + let peaks = analyse + .peaks + .iter() + .filter_map(|p| p.to_level()) + .collect::>(); + let frames = track.frames() as u64; + + // Der neue Track startet gestoppt — ein Deck, das beim Laden losläuft, ist + // ein Unfall. Auch und gerade, wenn ein Agent geladen hat. + auftrag.state.set_playing(false); + auftrag.state.seek_frames(0); + auftrag.state.set_loop_active(false); + + // Erst alles leeren, dann das Gespeicherte einsetzen. Ohne das Leeren + // stünden die Cues des vorigen Tracks noch da, wo der neue keine hat. + for i in 0..audio_core::deck::HOT_CUES { + auftrag.state.set_cue(i, None); + } + for (nummer, frame) in &auftrag.cues { + auftrag.state.set_cue(*nummer, Some(*frame)); + } + + // Was in der Sammlung steht, schlägt die frische Analyse: Es kann aus + // Traktor stammen oder von Hand korrigiert sein, und beides weiß mehr als + // ein Detektor. + let grid = auftrag.grid.or_else(|| { + analyse + .bpm + .map(|bpm| Beatgrid::new(bpm, analyse.beat_anchor_frames.unwrap_or(0), 1.0)) + }); + auftrag.state.set_grid(grid); + + let stems: Vec = track.stems.iter().map(|s| s.name.clone()).collect(); + // Voll auf, egal was der vorige Track hinterlassen hat. Ein neu geladenes + // Stück mit weggedrehter Stimme wäre eine Überraschung, die niemand + // bestellt hat. + for i in 0..audio_core::MAX_STEMS { + auftrag.state.set_stem_pegel(i, 1.0); + } + + let einstieg = analyse.struktur().and_then(|s| s.einstieg_frames()); + + Ok(Fertig { + einstieg, + voice: Voice::new(Arc::new(track), Arc::clone(&auftrag.state)), + peaks, + frames, + titel, + artist: String::new(), + tonart: analyse.tonart(), + struktur: analyse.struktur(), + stems, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ohne_titel_bleibt_der_dateiname_stehen() { + let mut eintrag = + TrackRecord::from_path("/musik/Ordner/Alpenglühen - Vier auf die Eins.wav"); + assert_eq!(anzeigename(&eintrag), "Alpenglühen - Vier auf die Eins"); + + eintrag.title = Some("Richtiger Titel".into()); + assert_eq!(anzeigename(&eintrag), "Richtiger Titel"); + + // Ein leerer Tag ist so gut wie keiner. + eintrag.title = Some(" ".into()); + assert_eq!(anzeigename(&eintrag), "Alpenglühen - Vier auf die Eins"); + } +} diff --git a/crates/musik-app/src/theme.rs b/crates/musik-app/src/theme.rs new file mode 100644 index 0000000..6e95f30 --- /dev/null +++ b/crates/musik-app/src/theme.rs @@ -0,0 +1,54 @@ +//! Farben und Maße. +//! +//! Dunkel, weil man in einem Club sitzt und nicht in einem Büro. Die Deckfarben +//! unterscheiden sich deutlich voneinander — im Halbdunkel muss auf einen Blick +//! klar sein, welche Hälfte gerade läuft. + +use egui::Color32; + +pub const BG: Color32 = Color32::from_rgb(16, 17, 21); +pub const PANEL: Color32 = Color32::from_rgb(24, 26, 32); +pub const PANEL_HELL: Color32 = Color32::from_rgb(34, 37, 45); +pub const RAHMEN: Color32 = Color32::from_rgb(52, 56, 68); + +pub const TEXT: Color32 = Color32::from_rgb(226, 229, 236); +pub const TEXT_LEISE: Color32 = Color32::from_rgb(138, 145, 160); + +pub const DECK_A: Color32 = Color32::from_rgb(94, 190, 255); +pub const DECK_B: Color32 = Color32::from_rgb(255, 158, 84); +pub const AUX: Color32 = Color32::from_rgb(150, 230, 160); + +pub const GESPIELT: Color32 = Color32::from_rgb(78, 84, 98); +pub const BEAT: Color32 = Color32::from_rgb(70, 76, 92); +pub const TAKT: Color32 = Color32::from_rgb(120, 128, 150); +pub const CUE: Color32 = Color32::from_rgb(255, 214, 92); +pub const PLAYHEAD: Color32 = Color32::from_rgb(255, 96, 96); + +pub const AKTIV: Color32 = Color32::from_rgb(96, 220, 140); +pub const WARNUNG: Color32 = Color32::from_rgb(255, 128, 96); + +/// Farbe eines Decks nach Index. +pub fn deck_farbe(index: usize) -> Color32 { + match index { + 0 => DECK_A, + 1 => DECK_B, + _ => AUX, + } +} + +pub fn stil(ctx: &egui::Context) { + let mut visuals = egui::Visuals::dark(); + visuals.panel_fill = BG; + visuals.window_fill = PANEL; + visuals.extreme_bg_color = Color32::from_rgb(12, 13, 16); + visuals.widgets.noninteractive.bg_fill = PANEL; + visuals.widgets.inactive.bg_fill = PANEL_HELL; + visuals.widgets.hovered.bg_fill = RAHMEN; + visuals.override_text_color = Some(TEXT); + ctx.set_visuals(visuals); + + ctx.all_styles_mut(|style| { + style.spacing.item_spacing = egui::vec2(6.0, 5.0); + style.spacing.slider_width = 130.0; + }); +} diff --git a/crates/musik-app/src/waveform.rs b/crates/musik-app/src/waveform.rs new file mode 100644 index 0000000..883d9d2 --- /dev/null +++ b/crates/musik-app/src/waveform.rs @@ -0,0 +1,278 @@ +//! Wellenform-Darstellung. +//! +//! Zwei Ansichten, wie bei jedem DJ-Programm: eine Übersicht über den ganzen +//! Track und ein Ausschnitt um die Abspielposition. Die Übersicht dient dem +//! Springen, der Ausschnitt dem Mixen. +//! +//! Im Zeichenpfad wird **nicht gerechnet**. Die Spitzen liegen aus der Analyse +//! vorberechnet in mehreren Auflösungen vor; hier wird nur noch ausgewählt und +//! gemalt. Über die Rohdaten zu laufen hieße dreizehn Millionen Werte pro Deck +//! und Bild — bei 60 Bildern pro Sekunde aussichtslos. + +use analysis::peaks::PeakLevel; +use audio_core::Beatgrid; +use egui::{Color32, Painter, Pos2, Rect, Stroke}; + +use crate::theme; + +/// Wählt die Auflösung, die zur Zoomstufe passt. +/// +/// Zu fein heißt: hunderte Spitzen je Bildspalte zusammenfassen, also Arbeit +/// ohne Nutzen. Zu grob heißt: Treppenstufen. +pub fn passende_stufe(levels: &[PeakLevel], sichtbare_frames: f64, breite_px: f32) -> &PeakLevel { + let gewuenscht = (sichtbare_frames / breite_px.max(1.0) as f64).max(1.0); + + levels + .iter() + .filter(|l| !l.is_empty()) + .min_by_key(|l| { + let verhaeltnis = l.samples_per_peak as f64 / gewuenscht; + // Logarithmischer Abstand, damit „doppelt so fein" und „halb so + // fein" gleich schlecht zählen. + (verhaeltnis.log2().abs() * 1_000.0) as i64 + }) + .unwrap_or(&levels[0]) +} + +/// Welche Spitzen zu einer Bildspalte gehören. +/// +/// `None`, wenn die Spalte ganz vor dem Anfang oder hinter dem Ende des Tracks +/// liegt. Das ist der Normalfall in der Zoom-Ansicht: sie ist um die +/// Abspielposition zentriert, also ragt sie in den ersten Sekunden links über +/// den Track hinaus. Ohne die Prüfung wird dort die erste Spitze für jede +/// Spalte wiederholt — ein Balken, den es nicht gibt. +fn spitzen_bereich( + von_frame: f64, + bis_frame: f64, + samples_je_spitze: u32, + anzahl: usize, +) -> Option<(usize, usize)> { + if bis_frame <= 0.0 || anzahl == 0 || samples_je_spitze == 0 { + return None; + } + + let je = samples_je_spitze as f64; + let i0 = (von_frame / je).floor().max(0.0) as usize; + if i0 >= anzahl { + return None; + } + + let i1 = ((bis_frame / je).ceil().max(0.0) as usize).clamp(i0 + 1, anzahl); + Some((i0, i1)) +} + +/// Zeichnet einen Frame-Bereich als Wellenform. +#[allow(clippy::too_many_arguments)] +pub fn zeichne( + painter: &Painter, + rect: Rect, + level: &PeakLevel, + von_frame: f64, + bis_frame: f64, + farbe: Color32, + gespielt_bis: Option, +) { + painter.rect_filled(rect, 2.0, Color32::from_rgb(12, 13, 16)); + + if level.is_empty() || bis_frame <= von_frame { + return; + } + + let mitte = rect.center().y; + let halbe_hoehe = rect.height() * 0.5 - 1.0; + let spalten = rect.width().max(1.0) as usize; + let frames_je_spalte = (bis_frame - von_frame) / spalten as f64; + + for spalte in 0..spalten { + let x = rect.left() + spalte as f32 + 0.5; + let f0 = von_frame + spalte as f64 * frames_je_spalte; + let f1 = f0 + frames_je_spalte; + + let Some((i0, i1)) = spitzen_bereich(f0, f1, level.samples_per_peak, level.len()) else { + continue; + }; + + let mut lo = i8::MAX; + let mut hi = i8::MIN; + for i in i0..i1 { + lo = lo.min(level.min[i]); + hi = hi.max(level.max[i]); + } + if lo > hi { + continue; + } + + let y0 = mitte - hi as f32 / 127.0 * halbe_hoehe; + let y1 = mitte - lo as f32 / 127.0 * halbe_hoehe; + + let bereits_gespielt = gespielt_bis.is_some_and(|p| f1 <= p); + let farbe = if bereits_gespielt { + theme::GESPIELT + } else { + farbe + }; + + painter.line_segment( + [Pos2::new(x, y0), Pos2::new(x, y1)], + Stroke::new(1.0, farbe), + ); + } +} + +/// Legt Beat- und Taktlinien über einen Bereich. +/// +/// Takte kräftiger als Beats: Beim Mixen zählt man Vierertakte, nicht einzelne +/// Schläge. +#[allow(clippy::too_many_arguments)] +pub fn zeichne_grid( + painter: &Painter, + rect: Rect, + grid: &Beatgrid, + sample_rate: u32, + von_frame: f64, + bis_frame: f64, + laenge_frames: f64, +) { + let frames_je_beat = grid.frames_per_beat(sample_rate); + if frames_je_beat <= 0.0 || bis_frame <= von_frame { + return; + } + + let sichtbar = bis_frame - von_frame; + // Bei zu vielen Linien wird das Bild zum Zaun — dann nur noch Takte. + let nur_takte = sichtbar / frames_je_beat > 64.0; + + let erster = grid.beat_at(von_frame, sample_rate).floor() as i64; + let letzter = grid.beat_at(bis_frame, sample_rate).ceil() as i64; + + for beat in erster..=letzter { + let taktanfang = beat.rem_euclid(4) == 0; + if nur_takte && !taktanfang { + continue; + } + + let frame = grid.frame_of_beat(beat as f64, sample_rate); + // Vor dem ersten und hinter dem letzten Sample gibt es keine Beats. + // Die Zoom-Ansicht ist um die Abspielposition zentriert und ragt am + // Anfang eines Tracks über ihn hinaus — dort ein Raster zu zeigen, + // behauptete Musik, die noch gar nicht anfängt. + if frame < 0.0 || frame > laenge_frames { + continue; + } + + let t = (frame - von_frame) / sichtbar; + if !(0.0..=1.0).contains(&t) { + continue; + } + + let x = rect.left() + t as f32 * rect.width(); + let (farbe, breite) = if taktanfang { + (theme::TAKT, 1.4) + } else { + (theme::BEAT, 0.8) + }; + + painter.line_segment( + [Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())], + Stroke::new(breite, farbe), + ); + } +} + +/// Markiert Cue-Punkte. +pub fn zeichne_cues( + painter: &Painter, + rect: Rect, + cues: &[(usize, u64)], + von_frame: f64, + bis_frame: f64, +) { + if bis_frame <= von_frame { + return; + } + + for (index, frame) in cues { + let t = (*frame as f64 - von_frame) / (bis_frame - von_frame); + if !(0.0..=1.0).contains(&t) { + continue; + } + + let x = rect.left() + t as f32 * rect.width(); + painter.line_segment( + [Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())], + Stroke::new(1.5, theme::CUE), + ); + painter.text( + Pos2::new(x + 2.0, rect.top() + 1.0), + egui::Align2::LEFT_TOP, + format!("{}", index + 1), + egui::FontId::monospace(9.0), + theme::CUE, + ); + } +} + +pub fn zeichne_playhead(painter: &Painter, rect: Rect, x: f32) { + painter.line_segment( + [Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())], + Stroke::new(1.6, theme::PLAYHEAD), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stufe(samples_per_peak: u32, len: usize) -> PeakLevel { + PeakLevel { + samples_per_peak, + min: vec![-100; len], + max: vec![100; len], + } + } + + #[test] + fn die_stufe_passt_sich_dem_zoom_an() { + let levels = vec![stufe(256, 10_000), stufe(2_048, 1_250), stufe(16_384, 160)]; + + // Ganzer Track auf 800 px: grob genügt. + let weit = passende_stufe(&levels, 10_000_000.0, 800.0); + assert_eq!(weit.samples_per_peak, 16_384); + + // Wenige Sekunden auf 800 px: fein. + let nah = passende_stufe(&levels, 200_000.0, 800.0); + assert_eq!(nah.samples_per_peak, 256); + } + + #[test] + fn vor_dem_anfang_und_hinter_dem_ende_wird_nichts_gezeichnet() { + // Zoom-Ansicht in der ersten Sekunde: die linke Hälfte liegt vor dem + // Track. Dort darf keine Spitze wiederholt werden. + assert_eq!(spitzen_bereich(-8_000.0, -4_000.0, 256, 100), None); + assert_eq!(spitzen_bereich(30_000.0, 34_000.0, 256, 100), None); + + // Der Übergang gehört noch dazu, aber nur mit dem vorhandenen Teil. + assert_eq!(spitzen_bereich(-500.0, 500.0, 256, 100), Some((0, 2))); + assert_eq!( + spitzen_bereich(25_000.0, 26_000.0, 256, 100), + Some((97, 100)) + ); + } + + #[test] + fn leere_stufen_werden_uebersprungen() { + let levels = vec![ + PeakLevel { + samples_per_peak: 256, + min: vec![], + max: vec![], + }, + stufe(2_048, 100), + ]; + + assert_eq!( + passende_stufe(&levels, 100_000.0, 800.0).samples_per_peak, + 2_048 + ); + } +} diff --git a/crates/musik-cli/Cargo.toml b/crates/musik-cli/Cargo.toml new file mode 100644 index 0000000..f1e5c72 --- /dev/null +++ b/crates/musik-cli/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "musik-cli" +version = "0.1.0" +edition = "2024" + +[dependencies] +analysis = { version = "0.1.0", path = "../analysis" } +anyhow = "1.0.104" +audio-core = { version = "0.1.0", path = "../audio-core" } +audio-engine = { version = "0.1.0", path = "../audio-engine" } +control = { version = "0.1.0", path = "../control" } +library = { version = "0.1.0", path = "../library" } diff --git a/crates/musik-cli/src/bin/musik-analyze.rs b/crates/musik-cli/src/bin/musik-analyze.rs new file mode 100644 index 0000000..80d70a6 --- /dev/null +++ b/crates/musik-cli/src/bin/musik-analyze.rs @@ -0,0 +1,143 @@ +//! Analysiert Tracks und legt die Ergebnisse als Sidecar ab. +//! +//! Läuft komplett ohne Audiogerät — Tempo, Beatgrid und Wellenform-Spitzen +//! entstehen offline. Das ist der Schritt, der Phase 2 aus `docs/PLAN.md` +//! bedient und die Grundlage für Sync und Waveform-Anzeige legt. + +use std::path::{Path, PathBuf}; + +use analysis::{Store, analyze_cached}; +use anyhow::{Context, Result}; +use audio_core::Track; + +const DEFAULT_CACHE: &str = ".musik-analyse"; + +fn main() -> Result<()> { + let mut cache = PathBuf::from(DEFAULT_CACHE); + let mut dateien: Vec = Vec::new(); + + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--cache" => { + let dir = args.next().context("--cache braucht ein Verzeichnis")?; + cache = PathBuf::from(dir); + } + "-h" | "--help" => { + hilfe(); + return Ok(()); + } + other => dateien.push(PathBuf::from(other)), + } + } + + if dateien.is_empty() { + hilfe(); + std::process::exit(2); + } + + let store = Store::new(&cache); + println!("Cache: {}", store.root().display()); + + let mut fehler = 0; + for pfad in &dateien { + if let Err(e) = eine_datei(pfad, &store) { + eprintln!("{}: {e:#}", pfad.display()); + fehler += 1; + } + } + + if fehler > 0 { + std::process::exit(1); + } + Ok(()) +} + +fn eine_datei(pfad: &Path, store: &Store) -> Result<()> { + let track = Track::decode_file(pfad) + .with_context(|| format!("konnte {} nicht dekodieren", pfad.display()))?; + + let (analyse, gerechnet) = analyze_cached(&track, store); + if gerechnet { + store + .save(&analyse) + .context("Sidecar konnte nicht geschrieben werden")?; + } + + let tempo = match (analyse.bpm, analyse.beat_anchor_frames) { + (Some(bpm), Some(anker)) => { + let sek = anker as f64 / analyse.sample_rate as f64; + let konfidenz = analyse.bpm_confidence.unwrap_or(0.0); + format!("{bpm:6.2} BPM Anker {sek:6.3}s Konfidenz {konfidenz:.2}") + } + _ => "kein verlässliches Tempo erkannt".to_string(), + }; + + let spitzen: Vec = analyse + .peaks + .iter() + .map(|p| format!("{}", p.samples_per_peak)) + .collect(); + + println!( + "\n{}\n {}\n {}\n Dauer {:.1}s Spitzen-Stufen {} {}", + pfad.display(), + analyse.fingerprint, + tempo, + analyse.duration_secs, + spitzen.join("/"), + if gerechnet { + "berechnet" + } else { + "aus dem Cache" + }, + ); + gliederung(&analyse); + + Ok(()) +} + +/// Die Gliederung, mit den Zahlen daneben, aus denen der Name kommt. +/// +/// Die Zahlen stehen bewusst mit da. Die Grenzen sind an gebautem Material +/// geprüft, die **Benennung nicht** — wer sie für falsch hält, soll sehen +/// können, woran sie hängt, statt sie glauben zu müssen. +fn gliederung(analyse: &analysis::Analysis) { + let Some(s) = analyse.struktur() else { + if analyse.bpm.is_some() { + println!(" Gliederung: keine erkennbar"); + } + return; + }; + + let rate = analyse.sample_rate as f64; + println!(" Gliederung ({} Abschnitte):", s.abschnitte.len()); + for a in &s.abschnitte { + println!( + " {:>6.1}s {:>7.1} Beats {:<6} Pegel {:.2} Bass {:.2} Dichte {:.2}", + a.von_frames as f64 / rate, + a.beats(), + a.art.name(), + a.pegel, + a.bass, + a.dichte + ); + } + if let Some(einstieg) = s.einstieg_frames() { + println!(" Einstieg bei {:.2}s", einstieg as f64 / rate); + } + match s.outro_frames() { + Some(f) => println!(" Outro ab {:.2}s", f as f64 / rate), + None => println!(" Kein Outro — dieser Track blendet nicht aus"), + } +} + +fn hilfe() { + println!("Aufruf: musik-analyze [--cache ] ..."); + println!(); + println!("Analysiert Tempo, Beatgrid und Wellenform-Spitzen und legt sie als"); + println!("Sidecar ab, adressiert über einen Hash des Audioinhalts. Ein erneuter"); + println!("Lauf über dieselbe Datei liest aus dem Cache."); + println!(); + println!(" --cache Ablageort der Sidecars (Vorgabe: {DEFAULT_CACHE})"); +} diff --git a/crates/musik-cli/src/bin/musik-eingang.rs b/crates/musik-cli/src/bin/musik-eingang.rs new file mode 100644 index 0000000..ac7618d --- /dev/null +++ b/crates/musik-cli/src/bin/musik-eingang.rs @@ -0,0 +1,387 @@ +//! Nachsehen, ob am AUX-Eingang wirklich etwas ankommt. +//! +//! Bevor jemand einen Abend lang mitschneidet, sollte er wissen, ob überhaupt +//! etwas hereinkommt und wie laut. Beides sieht man am Pult erst, wenn es zu +//! spät ist: Eine stille Stunde sieht aus wie eine Stunde, und eine +//! übersteuerte klingt erst beim Anhören falsch. +//! +//! Also vorher fragen. Das Werkzeug öffnet dasselbe Gerät auf demselben Weg +//! wie die Anwendung, hört ein paar Sekunden zu und sagt drei Dinge: +//! +//! - **Kommt etwas an?** Spitze und Mittelwert, in Dezibel. +//! - **Ist Luft nach oben?** Über −0,2 dBFS greift der Begrenzer der Summe ein, +//! und was er einmal zusammengedrückt hat, ist im Mitschnitt zusammengedrückt. +//! - **Kommt es lückenlos an?** Unterläufe heißen: Der Ring lief leer, es fehlt +//! Material. Überläufe heißen das Gegenteil. +//! +//! # Und mitschreiben +//! +//! `--aufnehmen ` schreibt, was hereinkommt, direkt auf die Platte — +//! **am Mixer vorbei**. Das ist der Unterschied zu `do master.record`: Jener +//! nimmt die Summe auf, hinter Fader, EQ und Begrenzer, denn er ist für den +//! Mitschnitt eines Abends gedacht. Wer Lieder gewinnen will, will keinen +//! Abend, sondern die Quelle: ohne Begrenzer, ohne Kanalzug, so wie sie +//! ankommt. +//! +//! **Ein Gerät, ein Zugriff.** Die meisten Treiber lassen sich nur von einem +//! Programm zugleich aufnehmen. Entweder läuft `musik-app --aux-in` und der +//! Abend wird mit `do master.record` mitgeschnitten — oder `musik-eingang +//! --aufnehmen` holt die Quelle sauber ab. Beides gleichzeitig auf demselben +//! Gerät geht nicht, und was dann kommt, ist eine Fehlermeldung des Treibers. +//! +//! Der Kopf der Datei wird jede Sekunde nachgetragen. Eine WAV-Datei trägt ihre +//! Länge vorn, und die kennt man beim Anlegen noch nicht; wer die Aufnahme mit +//! Strg-C beendet, hätte sonst eine Datei mit falschem Kopf, die manche +//! Programme gar nicht lesen. So fehlt im schlimmsten Fall die letzte Sekunde. + +use std::fs::File; +use std::io::{BufWriter, Seek, SeekFrom, Write}; +use std::path::PathBuf; + +use anyhow::{Context, Result, bail}; + +use audio_engine::source::Source; +use audio_engine::{Eingang, aux_channel, eingang}; + +const RATE: u32 = 48_000; +const BLOCK: usize = 512; + +/// Ab hier greift der Begrenzer der Summe ein. +/// +/// Er steht bei 0,98 — das sind rund −0,18 dBFS. Wer darunter bleibt, kommt +/// unangetastet durch; wer darüber geht, bekommt eine Dynamik, die er nicht +/// gewählt hat. +const KOPFRAUM: f32 = 0.98; + +fn main() -> Result<()> { + let opts = argumente()?; + + if opts.liste { + let geraete = eingang::geraete(); + if geraete.is_empty() { + println!("Kein Aufnahmegerät gefunden."); + return Ok(()); + } + println!("Aufnahmegeräte:"); + for g in geraete { + println!(" {g}"); + } + return Ok(()); + } + + let (writer, mut source) = aux_channel(RATE as usize * 2); + let eingang = + Eingang::open(writer, RATE, opts.geraet.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?; + + println!("Eingang: {}", eingang.device_name()); + println!( + " {} Kanal/Kanäle bei {} Hz, gehört wird {:.0} s\n", + eingang.channels(), + eingang.sample_rate(), + opts.sekunden + ); + + // Denselben Weg nehmen wie der Mixer: aus der Quelle lesen, Block für + // Block. Wer stattdessen den Ring direkt anschaute, prüfte etwas anderes + // als das, was später läuft. + let mut schreiber = match &opts.aufnehmen { + Some(pfad) => { + println!( + "Mitgeschrieben wird nach {} — am Mixer vorbei.", + pfad.display() + ); + if opts.sekunden.is_infinite() { + println!("Beenden mit Strg-C; der Kopf wird jede Sekunde nachgetragen.\n"); + } else { + println!(); + } + Some( + WavSchreiber::anlegen(pfad, RATE) + .with_context(|| format!("{} ließ sich nicht anlegen", pfad.display()))?, + ) + } + None => None, + }; + + let mut block = vec![0.0f32; BLOCK * 2]; + let mut spitze = 0.0f32; + let mut summe = 0.0f64; + let mut gezaehlt = 0u64; + let start = std::time::Instant::now(); + let mut zuletzt_nachgetragen = std::time::Instant::now(); + + while start.elapsed().as_secs_f64() < opts.sekunden { + source.render(&mut block); + for s in &block { + spitze = spitze.max(s.abs()); + summe += (*s as f64) * (*s as f64); + } + gezaehlt += block.len() as u64; + + if let Some(w) = schreiber.as_mut() { + w.schreiben(&block).context("Schreiben fehlgeschlagen")?; + if zuletzt_nachgetragen.elapsed().as_secs_f64() >= 1.0 { + w.kopf_nachtragen() + .context("Kopf nachtragen fehlgeschlagen")?; + zuletzt_nachgetragen = std::time::Instant::now(); + } + } + // Nicht schneller lesen, als das Gerät liefert — sonst besteht die + // Messung fast nur aus Unterläufen, die keine sind. + std::thread::sleep(std::time::Duration::from_micros( + (BLOCK as u64 * 1_000_000) / RATE as u64, + )); + } + + if let Some(mut w) = schreiber.take() { + let frames = w.frames(); + w.abschliessen().context("Abschließen fehlgeschlagen")?; + println!( + "Geschrieben: {:.1} s nach {}\n", + frames as f64 / RATE as f64, + opts.aufnehmen + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default() + ); + } + + let rms = (summe / gezaehlt.max(1) as f64).sqrt() as f32; + println!("── Was ankam ────────────────────────────────"); + println!(" Spitze {}", db(spitze)); + println!(" Mittelwert {}", db(rms)); + // Der erste Block ist immer leer: Gelesen wird, sobald das Gerät offen ist, + // gefüllt wird es erst mit dem ersten Callback. Am `null`-Gerät gemessen — + // genau 1024 Werte, also genau ein Block. Das als Fehler zu melden hieße, + // bei jedem gesunden Eingang zu warnen. + let echte = source.underruns().saturating_sub(block.len() as u64); + let anteil = echte as f64 / gezaehlt.max(1) as f64; + println!( + " Unterläufe {echte} von {gezaehlt} Werten ({:.2} %), Anlauf abgezogen", + anteil * 100.0 + ); + + println!(); + if spitze <= f32::EPSILON { + println!("⚠ Es kam nichts an. Das Gerät ist offen, aber still — Kabel,"); + println!(" Eingangswahl am Interface oder die Quelle selbst."); + } else if spitze > KOPFRAUM { + println!( + "⚠ Über {:.2} — der Begrenzer der Summe greift ein.", + KOPFRAUM + ); + println!(" Was er zusammendrückt, ist im Mitschnitt zusammengedrückt."); + println!(" Am Gerät leiser machen, nicht in der Anwendung."); + } else if spitze < 0.05 { + println!("⚠ Sehr leise. Aufnehmen geht, aber die Analyse arbeitet dann"); + println!(" dicht am Rauschen — am Gerät lauter wäre besser."); + } else { + println!("Der Pegel taugt: unter dem Begrenzer und deutlich über dem Rauschen."); + } + + if echte > 0 { + println!(); + println!("⚠ {echte} Unterläufe nach dem Anlauf — es fehlt Material."); + println!(" Zwei Uhren, die auseinanderlaufen. Ein größerer Ring hilft,"); + println!(" eine überlastete Maschine nicht."); + } + + Ok(()) +} + +/// Eine WAV-Datei, die wächst und deren Kopf laufend nachgetragen wird. +/// +/// Die Länge steht in einer WAV-Datei vorn, und beim Anlegen kennt man sie +/// nicht. Der übliche Weg ist, am Ende zurückzuspringen — nur endet eine +/// Aufnahme, die bis Strg-C läuft, nie ordentlich. Deshalb wird der Kopf jede +/// Sekunde nachgetragen: Im schlimmsten Fall fehlt die letzte Sekunde, statt +/// dass die ganze Datei unlesbar ist. +struct WavSchreiber { + schreiber: BufWriter, + frames: u64, +} + +impl WavSchreiber { + fn anlegen(pfad: &std::path::Path, rate: u32) -> std::io::Result { + let mut schreiber = BufWriter::new(File::create(pfad)?); + schreiber.write_all(b"RIFF")?; + schreiber.write_all(&0u32.to_le_bytes())?; + schreiber.write_all(b"WAVEfmt ")?; + schreiber.write_all(&16u32.to_le_bytes())?; + schreiber.write_all(&1u16.to_le_bytes())?; + schreiber.write_all(&2u16.to_le_bytes())?; + schreiber.write_all(&rate.to_le_bytes())?; + schreiber.write_all(&(rate * 4).to_le_bytes())?; + schreiber.write_all(&4u16.to_le_bytes())?; + schreiber.write_all(&16u16.to_le_bytes())?; + schreiber.write_all(b"data")?; + schreiber.write_all(&0u32.to_le_bytes())?; + Ok(WavSchreiber { + schreiber, + frames: 0, + }) + } + + fn frames(&self) -> u64 { + self.frames + } + + fn schreiben(&mut self, block: &[f32]) -> std::io::Result<()> { + for s in block { + let wert = (s.clamp(-1.0, 1.0) * 32_767.0) as i16; + self.schreiber.write_all(&wert.to_le_bytes())?; + } + self.frames += (block.len() / 2) as u64; + Ok(()) + } + + /// Trägt die beiden Längen im Kopf nach und springt ans Ende zurück. + fn kopf_nachtragen(&mut self) -> std::io::Result<()> { + let daten = (self.frames * 4) as u32; + self.schreiber.seek(SeekFrom::Start(4))?; + self.schreiber.write_all(&(36 + daten).to_le_bytes())?; + self.schreiber.seek(SeekFrom::Start(40))?; + self.schreiber.write_all(&daten.to_le_bytes())?; + self.schreiber.seek(SeekFrom::End(0))?; + Ok(()) + } + + fn abschliessen(&mut self) -> std::io::Result<()> { + self.kopf_nachtragen()?; + self.schreiber.flush() + } +} + +/// Pegel in Dezibel, mit einem Boden statt eines minus Unendlich. +fn db(wert: f32) -> String { + if wert <= 1e-6 { + return " still".to_string(); + } + format!("{:+6.1} dBFS", 20.0 * wert.log10()) +} + +struct Optionen { + liste: bool, + geraet: Option, + sekunden: f64, + /// Wohin mitgeschrieben wird. `None` heißt: nur hinhören. + aufnehmen: Option, +} + +fn argumente() -> Result { + let mut opts = Optionen { + liste: false, + geraet: None, + // Zum Hinhören reichen fünf Sekunden. Beim Aufnehmen wäre das eine + // Falle, deshalb steht die Vorgabe dort woanders. + sekunden: 5.0, + aufnehmen: None, + }; + let mut sekunden_gesetzt = false; + let mut iter = std::env::args().skip(1); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--liste" => opts.liste = true, + "--gerät" | "--geraet" => { + opts.geraet = Some(iter.next().context("--gerät braucht einen Namen")?) + } + "--sekunden" => { + opts.sekunden = iter + .next() + .context("--sekunden braucht eine Zahl")? + .parse() + .context("--sekunden braucht eine Zahl")?; + sekunden_gesetzt = true; + } + "--aufnehmen" => { + opts.aufnehmen = Some(PathBuf::from( + iter.next().context("--aufnehmen braucht einen Pfad")?, + )) + } + "-h" | "--help" => { + println!("Aufruf: musik-eingang [--liste] [--gerät ] [--sekunden ]"); + println!(" [--aufnehmen ]"); + println!(); + println!("Öffnet ein Aufnahmegerät auf demselben Weg wie die Anwendung,"); + println!("hört zu und sagt, ob etwas ankommt, wie laut und ob lückenlos."); + println!(); + println!("--aufnehmen schreibt mit — am Mixer vorbei, also ohne Begrenzer"); + println!(" und ohne Kanalzug. Ohne --sekunden läuft es, bis"); + println!(" Strg-C kommt. Danach: musik-schneiden "); + println!(); + println!("Ein Gerät lässt sich meist nur von einem Programm zugleich"); + println!("aufnehmen: entweder musik-app --aux-in oder dieses hier."); + std::process::exit(0); + } + other => bail!("unbekannte Option: {other}"), + } + } + // Wer aufnimmt und nichts sagt, meint „bis ich aufhöre" — nicht fünf + // Sekunden. Fünf Sekunden wären genau die Art Vorgabe, die einem erst nach + // dem Abend auffällt. + if opts.aufnehmen.is_some() && !sekunden_gesetzt { + opts.sekunden = f64::INFINITY; + } + Ok(opts) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("musik-eingang-test-{name}.wav")) + } + + /// **Eine abgebrochene Aufnahme bleibt lesbar.** + /// + /// Der Kopf trägt die Länge, und die kennt man beim Anlegen nicht. Wer erst + /// am Ende zurückspringt, hat nach einem Strg-C eine Datei, die manche + /// Programme gar nicht öffnen. Hier steht, dass der nachgetragene Kopf zu + /// dem passt, was tatsächlich geschrieben wurde — auch mitten im Lauf. + #[test] + fn der_kopf_passt_auch_mitten_in_der_aufnahme() { + let pfad = temp("mitten"); + let mut w = WavSchreiber::anlegen(&pfad, 48_000).expect("anlegen"); + for _ in 0..10 { + w.schreiben(&[0.25f32; 512]).expect("schreiben"); + } + w.kopf_nachtragen().expect("nachtragen"); + // Ohne Abschließen weiterschreiben: genau der Zustand, in dem ein + // Strg-C die Datei erwischt. + w.schreiben(&[0.25f32; 512]).expect("schreiben"); + drop(w); + + let (riff, daten) = kopf_lesen(&pfad); + assert_eq!(daten, 10 * 512 * 2, "die Datenlänge stimmt nicht"); + assert_eq!( + riff, + 36 + daten, + "die RIFF-Länge passt nicht zur Datenlänge" + ); + let _ = std::fs::remove_file(&pfad); + } + + #[test] + fn nach_dem_abschliessen_stimmt_alles() { + let pfad = temp("fertig"); + let mut w = WavSchreiber::anlegen(&pfad, 48_000).expect("anlegen"); + w.schreiben(&[0.5f32; 2_048]).expect("schreiben"); + assert_eq!(w.frames(), 1_024); + w.abschliessen().expect("abschließen"); + drop(w); + + let laenge = std::fs::metadata(&pfad).expect("metadata").len(); + let (riff, daten) = kopf_lesen(&pfad); + assert_eq!(daten, 2_048 * 2); + assert_eq!(riff as u64 + 8, laenge, "der Kopf passt nicht zur Datei"); + let _ = std::fs::remove_file(&pfad); + } + + fn kopf_lesen(pfad: &std::path::Path) -> (u32, u32) { + let roh = std::fs::read(pfad).expect("lesen"); + let riff = u32::from_le_bytes([roh[4], roh[5], roh[6], roh[7]]); + let daten = u32::from_le_bytes([roh[40], roh[41], roh[42], roh[43]]); + (riff, daten) + } +} diff --git a/crates/musik-cli/src/bin/musik-kritik.rs b/crates/musik-cli/src/bin/musik-kritik.rs new file mode 100644 index 0000000..aa3b369 --- /dev/null +++ b/crates/musik-cli/src/bin/musik-kritik.rs @@ -0,0 +1,998 @@ +//! Der Kritiker: liest einen Mitschnitt und benennt die Handwerksfehler. +//! +//! **Warum das früh kommt und nicht spät.** Ohne ihn bleibt „klingt besser" +//! eine Meinung. Jede Regel im Mixen ist eine Behauptung, bis jemand sie am +//! Ergebnis misst — und in diesem Projekt hat genau diese Wendung schon +//! mehrfach einen Fehler ans Licht geholt, den niemand vermutet hatte. +//! +//! Er läuft **offline über den Mitschnitt**, nicht im Echtzeitpfad. Damit darf +//! er so gründlich sein, wie er will, und er braucht von der Anlage nichts als +//! die WAV-Datei, die sie ohnehin schreibt. +//! +//! Was er misst, misst er ohne Geschmacksurteil: +//! +//! - **Wo ein Übergang liegt** — über die Änderung des Klangbilds, nicht über +//! ein Protokoll. Was er findet, hat wirklich geklungen. +//! - **Wie lang er dauert** — und wie unscharf sein Beginn ist. +//! - **Was der Pegel dabei macht** — das Loch in der Mitte einer Blende hört +//! man, bevor man es erklären kann. +//! - **Ob das Tempo durchhält** — vor und nach dem Übergang. +//! +//! **Eine Grenze, die er selbst benennt:** Am Anfang einer langen Blende ist +//! der eingehende Track per Konstruktion unhörbar — das ist, was eine Blende +//! ausmacht. Der Griff an den Fader lässt sich aus dem Mitschnitt allein +//! deshalb nicht auf den Beat genau zurückverfolgen, und der Nullpunkt eines +//! nachträglich geschätzten Rasters ist nicht der Anfang einer Phrase. +//! +//! **Deshalb liest er die Mitschrift mit.** Sie liegt neben dem Mitschnitt und +//! hält fest, welcher Befehl bei welchem Frame ankam und wo die Decks dabei +//! standen. Damit muss er den Griff an den Fader nicht mehr erraten: Der +//! Mitschnitt sagt, was herauskam, die Mitschrift, was gemeint war. Und der +//! Abstand zwischen beiden ist selbst ein Befund — er misst, wie weit die +//! Schätzung danebenliegt, wenn keine Mitschrift da ist. +//! +//! Die Mitschrift ersetzt das Hören ausdrücklich nicht. Sie sagt, wann der +//! Fader bewegt wurde, nicht, ob es gut klang. Wo beide sich widersprechen, +//! ist das ein Befund und keine Fehlerquelle: Ein Griff ohne hörbaren Wechsel +//! heißt, dass die Bewegung nichts bewirkt hat. +//! +//! Was er ausdrücklich **nicht** tut: eine Note geben. Ob ein Set gut war, +//! entscheidet weiterhin jemand, der dabei war. Der Kritiker nimmt ihm die +//! Handwerksfehler ab, nicht das Urteil. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +use analysis::wechsel::{self, FENSTER_SEK, Uebergang}; +use analysis::{onset, tempo}; +use audio_core::track::Track; +use control::mitschrift::{self, Protokoll, Richtung, Stand}; + +/// Wie weit ein gemessener Übergang von einem festgehaltenen Griff entfernt +/// sein darf, um noch als derselbe zu gelten. +/// +/// Eine Blende dauert selten über eine Minute; was weiter auseinanderliegt, +/// gehört nicht zusammen. Lieber „kein Griff in der Nähe" sagen als zwei Dinge +/// zusammenzwingen, die nichts miteinander zu tun haben. +const ZUORDNUNG_SEK: f64 = 45.0; + +struct Optionen { + datei: PathBuf, + mitschrift: Option, +} + +fn main() -> Result<()> { + let opts = argumente()?; + let track = Track::decode_file(&opts.datei) + .with_context(|| format!("{} ließ sich nicht lesen", opts.datei.display()))?; + + println!("Mitschnitt: {}", opts.datei.display()); + println!( + " {:.1} s, {} Hz\n", + track.duration_secs(), + track.sample_rate + ); + + let absicht = match &opts.mitschrift { + Some(pfad) => match mitschrift::lesen(pfad) { + Ok(p) => { + absicht_bericht(pfad, &p); + Some(Absicht { + rate: p.kopf.rate, + einsaetze: einsaetze(&p), + griffe: griffe(&p), + }) + } + Err(e) => { + // Kein Abbruch: Der Mitschnitt bleibt lesbar, auch wenn die + // Mitschrift es nicht ist. Verschwiegen wird es nicht. + println!("Mitschrift {}: {e}\n", pfad.display()); + None + } + }, + None => { + println!("Keine Mitschrift daneben — Beginn und Phrasenlage bleiben geschätzt."); + println!(" Sie entsteht beim Aufnehmen von selbst und heißt wie der"); + println!( + " Mitschnitt, nur mit der Endung .{}.\n", + mitschrift::ENDUNG + ); + None + } + }; + + let kurve = wechsel::wechselkurve(&track); + let uebergaenge = wechsel::uebergaenge_finden(&kurve, FENSTER_SEK); + + pegel_bericht(&track); + + if uebergaenge.is_empty() { + println!("\nKein Übergang gefunden."); + println!(" Entweder lief nur ein Track, oder der Wechsel war zu leise für"); + println!(" die Schwelle. Das ist ein Befund, keine Entwarnung."); + // Ein Griff ohne hörbaren Wechsel ist selbst ein Befund: Die Bewegung + // hat nichts bewirkt. Das darf hier nicht untergehen. + if let Some(a) = absicht.as_ref().filter(|a| !a.griffe.is_empty()) { + println!( + "\n⚠ Die Mitschrift kennt {} Griff(e), gehört hat man keinen.", + a.griffe.len() + ); + println!(" Entweder stand der Kanal zu, oder die Bewegung war zu klein."); + } + return Ok(()); + } + + println!("\n{} Übergänge gefunden:", uebergaenge.len()); + for (i, u) in uebergaenge.iter().enumerate() { + println!("\n── Übergang {} ──────────────────────────────", i + 1); + uebergang_bericht(&track, u, absicht.as_ref()); + } + + if let Some(a) = &absicht { + schaetzfehler_bericht(&uebergaenge, a); + } + + Ok(()) +} + +/// Was die Anlage vorhatte, gelesen aus der Mitschrift. +struct Absicht { + rate: u32, + griffe: Vec, + /// Wann welches Deck losgelaufen ist — `(deck, frame)`, nullbasiert. + einsaetze: Vec<(usize, u64)>, +} + +impl Absicht { + fn sekunden(&self, frame: u64) -> f64 { + frame as f64 / self.rate.max(1) as f64 + } + + /// Der Griff, der einem gemessenen Beginn am nächsten liegt. + /// + /// Bewusst über den Abstand und nicht über die Reihenfolge: Nicht jeder + /// Griff wird hörbar, und nicht jeder hörbare Wechsel hat einen Griff — + /// eine Paarung nach Position wäre eine Behauptung. + fn naechster(&self, sekunde: f64) -> Option<(&Griff, f64)> { + self.griffe + .iter() + .map(|g| (g, self.sekunden(g.frame) - sekunde)) + .min_by(|a, b| a.1.abs().total_cmp(&b.1.abs())) + .filter(|(_, ab)| ab.abs() <= ZUORDNUNG_SEK) + } +} + +/// Eine Reglerbewegung, wie die Mitschrift sie festgehalten hat. +/// +/// **Das ist die Absicht, nicht das Ergebnis.** Ob der Griff hörbar war, sagt +/// weiterhin nur der Mitschnitt — hier steht, wann er geschah und wo die Decks +/// dabei standen. +struct Griff { + frame: u64, + control: String, + ziel: f64, + /// Bestellte Länge in Beats. + beats: f64, + /// Nummer im Plan, über die sich das Ende wiederfindet. + plan: Option, + staende: Vec, + ende: Option, +} + +/// Wie eine Bewegung ausging. +struct Ende { + frame: u64, + /// `fertig`, `abgeloest` oder `abgebrochen` — die Worte des Zeitplans. + ausgang: String, + staende: Vec, +} + +/// Liest die Bewegungen aus einer Mitschrift. +fn griffe(p: &Protokoll) -> Vec { + let mut gefunden = Vec::new(); + + for (i, e) in p.ereignisse.iter().enumerate() { + if e.richtung != Richtung::Befehl { + continue; + } + let Some(rest) = e.text.strip_prefix("ramp ") else { + continue; + }; + let mut worte = rest.split_whitespace(); + let (Some(control), Some(ziel), Some(beats)) = (worte.next(), worte.next(), worte.next()) + else { + continue; + }; + let (Ok(ziel), Ok(beats)) = (ziel.parse::(), beats.parse::()) else { + continue; + }; + + // Genau die nächste Zeile, nicht die nächste passende: Die Antwort + // folgt dem Befehl unmittelbar. Weiter zu suchen hieße, bei einem + // abgelehnten Befehl die Nummer des übernächsten zu erwischen. + let plan = p + .ereignisse + .get(i + 1) + .filter(|n| n.richtung == Richtung::Meldung) + .and_then(|n| plan_nummer(&n.text)); + + let ende = plan.and_then(|id| ende_von(p, i + 1, id)); + + gefunden.push(Griff { + frame: e.frame, + control: control.to_string(), + ziel, + beats, + plan, + staende: e.staende.clone(), + ende, + }); + } + + gefunden +} + +/// Die Nummer aus einer Zeile, die von einem Plan spricht. +fn plan_nummer(text: &str) -> Option { + let mut worte = text.split_whitespace(); + while let Some(w) = worte.next() { + if w == "plan" { + return worte.next()?.parse().ok(); + } + } + None +} + +/// Sucht die Meldung, mit der ein Auftrag zu Ende ging. +fn ende_von(p: &Protokoll, ab: usize, id: u64) -> Option { + const AUSGAENGE: [&str; 3] = ["fertig", "abgeloest", "abgebrochen"]; + + p.ereignisse.iter().skip(ab).find_map(|e| { + if e.richtung != Richtung::Meldung || plan_nummer(&e.text) != Some(id) { + return None; + } + let wort = e.text.split_whitespace().nth(2)?; + AUSGAENGE.contains(&wort).then(|| Ende { + frame: e.frame, + ausgang: wort.to_string(), + staende: e.staende.clone(), + }) + }) +} + +/// Was die Mitschrift für sich genommen sagt. +fn absicht_bericht(pfad: &Path, p: &Protokoll) { + println!("Mitschrift: {}", pfad.display()); + println!( + " {} Ereignisse, {} Bewegungen", + p.ereignisse.len(), + griffe(p).len() + ); + if p.unlesbar > 0 { + println!( + " ⚠ {} Zeilen ließen sich nicht lesen — die Mitschrift hat Lücken.", + p.unlesbar + ); + } + println!(); +} + +/// Wann welches Deck eingesetzt hat. +/// +/// Ein Deck, das genau bei einem Griff losläuft, steht dabei noch auf seinem +/// Einstiegspunkt — die Position wird erst nach dem nächsten Audioblock +/// veröffentlicht. Genau darin liegt aber der Befund: Wo im *eigenen* Raster +/// der eingehende Track anfängt, ist die Frage, die ein Cue-Punkt beantwortet. +fn einsaetze(p: &Protokoll) -> Vec<(usize, u64)> { + p.ereignisse + .iter() + .filter(|e| e.richtung == Richtung::Befehl) + .filter_map(|e| { + let rest = e.text.strip_prefix("set deck")?; + let (nummer, rest) = rest.split_once(".play")?; + let an = matches!(rest.trim(), "1" | "true" | "on"); + let deck: usize = nummer.parse().ok()?; + (an && deck > 0).then_some((deck - 1, e.frame)) + }) + .collect() +} + +/// Ein Griff mit allem, was die Mitschrift über ihn weiß. +fn griff_bericht(g: &Griff, rate: u32, einzug: &str, einsaetze: &[(usize, u64)]) { + let sek = |frame: u64| frame as f64 / rate.max(1) as f64; + println!( + "{einzug}Griff bei {:.2} s: {} → {:.2} über {:.0} Beats", + sek(g.frame), + g.control, + g.ziel, + g.beats + ); + + for s in &g.staende { + let einsatz = einsaetze.contains(&(s.deck, g.frame)); + // Ein stehendes Deck bekommt keine Phrasenlage. Sie wäre ausrechenbar + // und würde gelesen wie „es kam neben der Eins herein" — dabei kam es + // überhaupt nicht herein. + let Some(lage) = s.phrasenlage() else { + println!("{einzug} deck{}: steht bei Beat {:.2}", s.deck + 1, s.beat); + continue; + }; + let hinweis = if lage < 0.25 { + " — auf der Eins" + } else if (s.phrase_beats - lage) < 0.25 { + " — knapp vor der Eins" + } else if einsatz { + " ⚠ nicht auf seiner Eins" + } else { + "" + }; + let was = if einsatz { + "setzt hier ein bei Beat" + } else { + "Beat" + }; + println!( + "{einzug} deck{}: {was} {:.2}, {lage:.2} in die Phrase ({:.0}){hinweis}", + s.deck + 1, + s.beat, + s.phrase_beats + ); + } + if g.staende.is_empty() { + println!("{einzug} Kein Deck hatte ein Beatgrid — die Phrasenlage fehlt."); + } + + match &g.ende { + Some(e) => { + let dauer = sek(e.frame) - sek(g.frame); + print!("{einzug} {} nach {dauer:.1} s", e.ausgang); + // Was wirklich an Beats vergangen ist, weiß nur die Mitschrift: + // Wer am Tempo dreht, verschiebt Sekunden, nicht Beats. + match (g.staende.first(), e.staende.first()) { + (Some(a), Some(b)) if a.deck == b.deck => { + let gefahren = b.beat - a.beat; + print!(" · {gefahren:.1} von {:.0} Beats", g.beats); + if (gefahren - g.beats).abs() > 1.0 { + print!(" ⚠"); + } + } + _ => {} + } + println!(); + } + None => println!( + "{einzug} Kein Ende in der Mitschrift{}.", + match g.plan { + Some(id) => format!(" (Plan {id} läuft noch oder das Set brach ab)"), + None => " — der Befehl kam nicht durch".to_string(), + } + ), + } +} + +/// Wie weit die Schätzung aus dem Klang danebenlag. +/// +/// **Das ist die Zahl, die diese ganze Übung rechtfertigt.** Ohne Mitschrift +/// bleibt sie unbekannt, und dann hält man die Schätzung für die Wahrheit. +fn schaetzfehler_bericht(uebergaenge: &[Uebergang], a: &Absicht) { + let abweichungen: Vec = uebergaenge + .iter() + .filter_map(|u| a.naechster(u.beginn).map(|(_, ab)| ab)) + .collect(); + if abweichungen.is_empty() { + return; + } + + let mittel = abweichungen.iter().map(|d| d.abs()).sum::() / abweichungen.len() as f64; + let groesste = abweichungen + .iter() + .copied() + .max_by(|x, y| x.abs().total_cmp(&y.abs())) + .unwrap_or(0.0); + + println!("\n── Was die Schätzung taugt ──────────────────"); + println!( + " {} von {} Übergängen ließen sich einem Griff zuordnen.", + abweichungen.len(), + uebergaenge.len() + ); + println!(" Im Mittel lag die Schätzung {mittel:.1} s daneben, am weitesten {groesste:+.1} s."); + println!(" Ein negatives Vorzeichen heißt: zu spät geschätzt — genau der Fehler,"); + println!(" den eine lange Blende erzwingt, weil ihr Anfang unhörbar ist."); +} + +/// Wie breit ein Pegelwert ist, in Sekunden. +/// +/// Steht hier als Konstante, weil zwei Stellen sie brauchen: das Rechnen und +/// das Suchen nach einer Sekunde im Ergebnis. **Sie standen auseinander** — +/// gerechnet wurde ein Wert je Sekunde, gesucht wurde mit zwei Sekunden je +/// Wert —, und damit beschrieb der Pegelbericht einer Blende eine Stelle bei +/// halber Zeit. Am laufenden Programm fiel es auf: Der Kritiker meldete +/// „64 % Einbruch in der Mitte" eines gefahrenen Übergangs und beschrieb +/// damit den Break des ausgehenden Tracks, fünfzehn Sekunden früher, an dem +/// kein Regler bewegt worden war. Der Befund darüber — „das ist die +/// Crossfader-Kurve" — war dann Rat zu einer Stelle, die niemand gefahren hat. +const PEGEL_SEK: f64 = 1.0; + +/// Pegel über den ganzen Mitschnitt, ein Wert je [`PEGEL_SEK`]. +fn pegel(track: &Track) -> Vec { + // Mal `CHANNELS`, weil `samples` verschränkt liegt. Zahlenmäßig dasselbe + // wie das frühere `* 2`; ausgeschrieben, damit ein drittes Deck oder ein + // Mono-Mitschnitt es nicht stillschweigend verschiebt. + let fenster = (track.sample_rate as f64 * PEGEL_SEK) as usize * audio_core::track::CHANNELS; + track + .samples + .chunks(fenster) + .map(|t| { + if t.is_empty() { + return 0.0; + } + (t.iter().map(|x| x * x).sum::() / t.len() as f32).sqrt() + }) + .collect() +} + +fn pegel_bericht(track: &Track) { + let p = pegel(track); + let laut: Vec = p.iter().copied().filter(|&x| x > 0.001).collect(); + if laut.is_empty() { + println!("Pegel: der Mitschnitt ist still."); + return; + } + let mittel = laut.iter().sum::() / laut.len() as f32; + let (min, max) = laut + .iter() + .fold((f32::MAX, 0.0f32), |(a, b), &x| (a.min(x), b.max(x))); + println!("Pegel: Mittel {mittel:.3}, von {min:.3} bis {max:.3}"); + if max > 0.99 { + println!(" ⚠ Der Begrenzer hat gearbeitet — die Summe stand am Anschlag."); + } +} + +/// Wie ein Pegelloch in einer Blende zu erklären ist. +/// +/// Zwei Ursachen sehen im Mitschnitt gleich aus und wollen Verschiedenes: eine +/// Kurve, die in der Mitte Leistung verliert — und zwei Tracks, die schlicht +/// verschieden laut sind. Trennen lassen sie sich an den Rändern: Steht der +/// Pegel *nach* dem Übergang deutlich anders als davor, war es das Material, +/// und eine härtere Kurve verdeckt den Unterschied nur. +/// +/// Bis dahin sagte der Kritiker in beiden Fällen „das ist die Crossfader-Kurve" +/// — an einem gefahrenen Übergang gemessen war das falsch: Ein Drop mit Kick +/// und Bass ging in ein reines Akkord-Intro über, zwölf Dezibel leiser, und +/// keine Kurve der Welt ändert daran etwas. +#[derive(Debug, PartialEq, Eq)] +enum Loch { + /// Kein nennenswerter Einbruch. + Keins, + /// Die Ränder sind verschieden laut — eine Stufe, keine Delle. + Stufe, + /// Die Ränder sind gleich laut, die Mitte nicht. + Kurve, + /// Auf einer Seite gibt es nichts zu vergleichen. + /// + /// Ein Übergang in den ersten Sekunden eines Mitschnitts hat kein Davor, + /// und einer am Ende kein Danach. Dann ist der Einbruch echt, seine + /// Ursache aber von hier aus nicht zu bestimmen — und das zu sagen ist + /// besser, als die häufigere der beiden zu raten. + Unklar, +} + +/// Ab wie viel Prozent ein Einbruch überhaupt genannt wird. +const EINBRUCH_PROZENT: f32 = 15.0; + +/// Ab welchem Verhältnis der Ränder es eine Stufe heißt. +/// +/// 0,7 sind gut drei Dezibel — hörbar verschieden, und mehr als der Abstand +/// zwischen zwei Abschnitten desselben Stücks. Gewählt und nicht gemessen; +/// gemessen ist nur der Fall, der dazu führte (0,20 gegen 0,38, also 0,53), +/// und der liegt weit auf der einen Seite. +const STUFE_ANTEIL: f32 = 0.7; + +/// Wie viele Pegelwerte links und rechts als „außen" gelten. +const RAND_WERTE: usize = 4; + +/// Die Pegelwerte um einen Übergang: das Minimum darin, das Lauteste davor, +/// das Lauteste danach. +/// +/// Eigene Funktion, weil hier eine Sekunde in einen Index umgerechnet wird und +/// genau diese Umrechnung schon einmal um den Faktor zwei danebenlag. In einer +/// `println!`-Strecke sieht man das nie. +fn pegel_um(p: &[f32], beginn: f64, ende: f64) -> Option<(f32, f32, f32)> { + let idx = |s: f64| (s / PEGEL_SEK) as usize; + let (a, b) = (idx(beginn), idx(ende).min(p.len())); + if b <= a || b > p.len() { + return None; + } + let innen = p[a..b].iter().copied().fold(f32::MAX, f32::min); + let davor = p[a.saturating_sub(RAND_WERTE)..a] + .iter() + .copied() + .fold(0.0f32, f32::max); + let danach = p[b..(b + RAND_WERTE).min(p.len())] + .iter() + .copied() + .fold(0.0f32, f32::max); + Some((innen, davor, danach)) +} + +fn loch(innen: f32, davor: f32, danach: f32) -> (f32, Loch) { + let aussen = davor.max(danach); + if aussen <= 0.001 { + return (0.0, Loch::Keins); + } + let verlust = 100.0 * (1.0 - innen / aussen); + if verlust <= EINBRUCH_PROZENT { + return (verlust, Loch::Keins); + } + if davor <= 0.001 || danach <= 0.001 { + return (verlust, Loch::Unklar); + } + // Symmetrisch: Ob der eingehende Track leiser ist oder der ausgehende war, + // ändert nichts daran, dass die Kurve nicht die Ursache ist. + let leiser = davor.min(danach); + if leiser < aussen * STUFE_ANTEIL { + (verlust, Loch::Stufe) + } else { + (verlust, Loch::Kurve) + } +} + +fn uebergang_bericht(track: &Track, u: &Uebergang, absicht: Option<&Absicht>) { + let dauer = u.ende - u.beginn; + println!( + " Beginn {:>7.2} s (±{:.0} s), Dauer {:.1} s, Stärke {:.2}", + u.beginn, u.unschaerfe, dauer, u.hoehe + ); + + // --- Was gemeint war ------------------------------------------------ + // Steht vor der Schätzung, weil es sie ablöst: Wo die Mitschrift den + // Griff kennt, ist alles Zurückverfolgen aus dem Klang nur noch die + // Gegenprobe. + let zugeordnet = absicht.and_then(|a| a.naechster(u.beginn)); + if let (Some(a), Some((g, ab))) = (absicht, &zugeordnet) { + println!(" ── laut Mitschrift ──"); + griff_bericht(g, a.rate, " ", &a.einsaetze); + println!( + " Die Schätzung aus dem Klang liegt {:.1} s {}.", + ab.abs(), + if *ab < 0.0 { "zu spät" } else { "zu früh" } + ); + } else if absicht.is_some() { + println!(" Kein Griff in der Mitschrift in der Nähe — der Wechsel kam nicht"); + println!(" von einem Regler, oder er stand nicht im Plan."); + } + + // --- Phrasenlage ---------------------------------------------------- + // Das Raster kommt aus dem Material *davor*: Dort läuft der ausgehende + // Track allein, und auf dessen Eins gehört der Übergang. + match raster_vor(track, u.beginn) { + Some(grid) => { + let rate = track.sample_rate; + // `analysis::Beatgrid` beschreibt das Raster, rechnen kann damit + // das aus `audio_core` — dieselben Zahlen, andere Aufgabe. + let raster = audio_core::Beatgrid::new(grid.bpm, grid.anchor_frames, 1.0); + let beat = raster.beat_at(u.beginn * rate as f64, rate); + let zum_beat = (beat - beat.round()).abs(); + println!( + " Raster davor: {:.2} BPM · Beginn auf Beat {:.2}", + grid.bpm, beat + ); + let unschaerfe_beats = u.unschaerfe * grid.bpm as f64 / 60.0; + println!( + " Abstand zum nächsten Schlag: {zum_beat:.2} Beats \ +(Unschärfe ±{unschaerfe_beats:.1})" + ); + + // Kein Urteil aus dem Klang allein — und mit Mitschrift ein + // anderes, kleineres. Zwei Gründe, beide hart: + // + // Das Fenster ist eine Sekunde breit, bei 126 BPM also zwei Beats. + // Damit lässt sich nicht feststellen, ob ein Einsatz auf dem + // Schlag sitzt — die Auflösung ist gröber als die Frage. + // + // Und der Anker des Detektors ist der erste starke Schlag im + // Analysefenster, nicht der Anfang einer Phrase. „Sechs Beats + // neben der Eins" wäre gegen einen willkürlichen Nullpunkt + // gemessen. + // + // Die Mitschrift schließt den zweiten Grund halb: Sie kennt den + // Beat, den die *Anlage* meinte, und damit lässt sich prüfen, ob + // die Anlage getan hat, was sie vorhatte. Ob ihr Beat 0 auch + // musikalisch ein Downbeat ist, weiß erst die Strukturanalyse. + if zugeordnet.is_some() { + println!(" · Die Phrasenlage oben ist gegen die Rechnung der Anlage gemessen:"); + println!(" Sie sagt, ob die Anlage traf, was sie vorhatte. Ob ihr Beat 0"); + println!(" auch musikalisch die Eins ist, weiß erst die Strukturanalyse."); + } else { + println!(" · Beat- und Phrasenlage sind so nicht beurteilbar: Die Unschärfe"); + println!(" (±{unschaerfe_beats:.0} Beats) ist größer als der Versatz, und der"); + println!(" Nullpunkt des Rasters ist nicht der Anfang einer Phrase."); + } + } + None => println!(" Raster davor nicht erkennbar — Phrasenlage nicht beurteilbar."), + } + + // --- Pegelloch ------------------------------------------------------ + let p = pegel(track); + if let Some((innen, davor, danach)) = pegel_um(&p, u.beginn, u.ende) { + let aussen = davor.max(danach); + if aussen > 0.001 { + let (verlust, art) = loch(innen, davor, danach); + println!(" Pegel in der Blende: {innen:.3} gegen {aussen:.3} außen"); + match art { + Loch::Keins => { + println!(" ✓ Pegel hält über die Blende ({verlust:.0} % Abweichung).") + } + Loch::Kurve => { + println!(" ⚠ {verlust:.0} % Einbruch in der Mitte, und beide Seiten sind"); + println!(" gleich laut ({davor:.3} / {danach:.3}). Das ist die Kurve;"); + println!(" `master.crossfader_curve` härter stellen."); + } + Loch::Unklar => { + println!(" ⚠ {verlust:.0} % Einbruch — aber auf einer Seite fehlt der"); + println!(" Vergleich (davor {davor:.3}, danach {danach:.3}). Ob das die"); + println!(" Kurve war oder zwei verschieden laute Tracks, sagt dieser"); + println!(" Mitschnitt nicht."); + } + Loch::Stufe => { + println!(" ⚠ {verlust:.0} % Einbruch — aber die Ränder sind verschieden"); + println!(" laut: davor {davor:.3}, danach {danach:.3}. Dann liegt es am"); + println!(" Material und nicht an der Kurve — Gain, oder eine andere"); + println!(" Stelle zum Einsteigen."); + } + } + } + } + + // --- Tempo davor und danach ----------------------------------------- + match (raster_vor(track, u.beginn), raster_nach(track, u.ende)) { + (Some(vor), Some(nach)) => { + let ab = (nach.bpm - vor.bpm).abs(); + println!(" Tempo: {:.2} → {:.2} BPM", vor.bpm, nach.bpm); + if ab > 0.5 { + println!(" ⚠ {ab:.2} BPM Unterschied über den Übergang — beide Decks liefen"); + println!(" nicht auf demselben Tempo, oder der Sync ist zwischendurch weg."); + } else { + println!(" ✓ Tempo hält über den Übergang."); + } + } + _ => println!(" Tempo vorher/nachher nicht beidseitig erkennbar."), + } +} + +/// Beatgrid aus dem Material vor einer Stelle. +fn raster_vor(track: &Track, sekunde: f64) -> Option { + let rate = track.sample_rate as f64; + let ende = (sekunde * rate) as usize * 2; + let laenge = (20.0 * rate) as usize * 2; + let start = ende.saturating_sub(laenge); + raster_von(track, start, ende) +} + +fn raster_nach(track: &Track, sekunde: f64) -> Option { + let rate = track.sample_rate as f64; + let start = (sekunde * rate) as usize * 2; + let ende = (start + (20.0 * rate) as usize * 2).min(track.samples.len()); + raster_von(track, start, ende) +} + +/// Erkennt ein Raster in einem Ausschnitt und rechnet seinen Anker auf den +/// ganzen Mitschnitt zurück. +/// +/// Ohne das Zurückrechnen läge der Anker relativ zum Ausschnitt, und jede +/// Beat-Angabe wäre um dessen Beginn verschoben. +fn raster_von(track: &Track, start: usize, ende: usize) -> Option { + if ende <= start || ende - start < track.sample_rate as usize { + return None; + } + let teil = &track.samples[start..ende.min(track.samples.len())]; + let hüll = onset::onset_envelope(teil, track.sample_rate); + let grid = tempo::detect(&hüll)?; + let grid = tempo::refine_anchor(teil, track.sample_rate, grid); + + Some(tempo::Beatgrid { + anchor_frames: grid.anchor_frames + (start / 2) as u64, + ..grid + }) +} + +fn argumente() -> Result { + let mut datei = None; + let mut mitschrift = None; + let mut warte_auf_mitschrift = false; + + for arg in std::env::args().skip(1) { + if warte_auf_mitschrift { + mitschrift = Some(PathBuf::from(arg)); + warte_auf_mitschrift = false; + continue; + } + match arg.as_str() { + "-h" | "--help" => { + hilfe(); + std::process::exit(0); + } + "--mitschrift" => warte_auf_mitschrift = true, + "--ohne-mitschrift" => mitschrift = Some(PathBuf::new()), + _ => datei = Some(PathBuf::from(arg)), + } + } + if warte_auf_mitschrift { + bail!("--mitschrift braucht eine Datei"); + } + + let Some(datei) = datei else { + hilfe(); + bail!("keine Datei angegeben"); + }; + if !Path::new(&datei).exists() { + bail!("{} gibt es nicht", datei.display()); + } + + // Ohne Angabe die daneben: Die Anlage legt sie beim Aufnehmen von selbst + // dorthin, und wer sie erst nennen müsste, ließe sie meistens liegen. + // `--ohne-mitschrift` trägt einen leeren Pfad und heißt: ausdrücklich + // nicht — dafür gibt es einen Grund, siehe `hilfe`. + let mitschrift = match mitschrift { + Some(p) if p.as_os_str().is_empty() => None, + Some(p) => { + if !p.exists() { + bail!("{} gibt es nicht", p.display()); + } + Some(p) + } + None => { + let daneben = datei.with_extension(mitschrift::ENDUNG); + daneben.exists().then_some(daneben) + } + }; + + Ok(Optionen { datei, mitschrift }) +} + +fn hilfe() { + println!("Aufruf: musik-kritik [--mitschrift ]"); + println!(); + println!("Liest einen Mitschnitt und benennt, was messbar ist: wo ein Übergang"); + println!("liegt, wie lang er dauert, was der Pegel dabei macht und ob das Tempo"); + println!("durchhält."); + println!(); + println!("Liegt eine Mitschrift daneben (gleicher Name, Endung .mitschrift),"); + println!("liest er sie mit. Dann muss er den Griff an den Fader nicht mehr aus"); + println!("dem Klang erraten, und die Phrasenlage wird messbar."); + println!(); + println!("--ohne-mitschrift ignoriert sie. Das ist kein Sparmodus, sondern die"); + println!("Probe aufs Exempel: Nur so sieht man, wie weit die Schätzung"); + println!("danebenliegt, wenn nur der Klang da ist."); + println!(); + println!("Er gibt keine Note. Ob ein Set gut war, entscheidet, wer dabei war."); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn protokoll(zeilen: &[&str]) -> Protokoll { + let mut text = String::from("# musik-mitschrift 1\n# mitschnitt set.wav\n# rate 48000\n"); + text.push_str(&zeilen.join("\n")); + mitschrift::aus_text(&text).expect("Mitschrift lesbar") + } + + /// Ein Wert je Sekunde — die Zahl, an der der Pegelbericht hängt. + /// + /// Stand hier einmal ein Fenster von `rate * 2` **Samples**, war ein Wert + /// eine halbe Sekunde breit, während `uebergang_bericht` mit zwei + /// Sekunden je Wert suchte. Der Bericht beschrieb dann eine Stelle bei + /// halber Zeit, ohne dass irgendetwas nach einem Fehler aussah. + #[test] + fn ein_pegelwert_ist_eine_sekunde_breit() { + let rate = 48_000u32; + let sekunden = 10; + let track = Track { + samples: vec![0.5f32; rate as usize * audio_core::track::CHANNELS * sekunden], + sample_rate: rate, + stems: Vec::new(), + }; + assert_eq!(track.duration_secs(), sekunden as f64); + assert_eq!(pegel(&track).len(), sekunden); + assert_eq!(PEGEL_SEK, 1.0); + } + + /// Eine Sekunde ist ein Wert — auch beim Suchen. + /// + /// Der Fehler, den diese Prüfung fängt, war unsichtbar: Der Bericht sah + /// vollständig aus und nannte plausible Zahlen, nur stammten sie aus der + /// halben Zeit. Deshalb ein Verlauf, in dem jede Sekunde anders klingt — + /// dann sagt die Zahl, wo geschaut wurde. + #[test] + fn die_pegelwerte_kommen_aus_der_richtigen_sekunde() { + let p: Vec = (0..40).map(|i| i as f32 / 100.0).collect(); + let (innen, davor, danach) = pegel_um(&p, 20.0, 30.0).expect("Fenster liegt im Verlauf"); + assert!((innen - 0.20).abs() < 1e-6, "innen {innen}"); + assert!((davor - 0.19).abs() < 1e-6, "davor {davor}"); + assert!((danach - 0.33).abs() < 1e-6, "danach {danach}"); + + // Am Anfang gibt es kein Davor, und das ist kein Fehler. + let (_, davor, _) = pegel_um(&p, 0.0, 10.0).expect("Fenster liegt im Verlauf"); + assert_eq!(davor, 0.0); + + // Über das Ende hinaus wird nichts erfunden. + assert_eq!(pegel_um(&p, 39.0, 39.0), None); + assert_eq!(pegel_um(&p, 50.0, 60.0), None); + } + + /// Der Befund, der am gefahrenen Übergang entstand. + #[test] + fn ein_leiserer_track_ist_keine_kurve() { + // Drop mit Kick und Bass hinein, reines Akkord-Intro heraus. + let (verlust, art) = loch(0.081, 0.381, 0.200); + assert_eq!(art, Loch::Stufe); + assert!(verlust > 70.0, "{verlust}"); + + // Gleich laute Ränder, Delle in der Mitte: dann ist es die Kurve. + assert_eq!(loch(0.20, 0.40, 0.38).1, Loch::Kurve); + + // Und andersherum ist es genauso wenig die Kurve. + assert_eq!(loch(0.081, 0.200, 0.381).1, Loch::Stufe); + } + + /// Ohne Rand kein Urteil: lieber „weiß nicht" als die häufigere Ursache. + #[test] + fn ein_fehlender_rand_ist_keine_stufe() { + // Ein Übergang gleich zu Beginn: kein Davor. + assert_eq!(loch(0.081, 0.0, 0.381).1, Loch::Unklar); + // Und einer am Ende: kein Danach. + assert_eq!(loch(0.081, 0.381, 0.0).1, Loch::Unklar); + } + + /// Ein Pegel, der hält, ist kein Befund — auch nicht mit Rauschen drauf. + #[test] + fn ohne_einbruch_wird_nichts_gemeldet() { + assert_eq!(loch(0.36, 0.40, 0.39).1, Loch::Keins); + // Stille auf beiden Seiten: nichts zu vergleichen, nichts zu melden. + assert_eq!(loch(0.0, 0.0, 0.0), (0.0, Loch::Keins)); + } + + #[test] + fn die_plannummer_steht_in_beiden_richtungen() { + assert_eq!( + plan_nummer("ok plan 3 ramp master.crossfader nach 1"), + Some(3) + ); + assert_eq!( + plan_nummer("plan 12 fertig master.crossfader 1.0000"), + Some(12) + ); + assert_eq!(plan_nummer("err kein Deck mit Beatgrid"), None); + assert_eq!(plan_nummer("ok plan viele"), None); + } + + /// Der Regelfall: bestellt, angenommen, zu Ende gefahren. + #[test] + fn aus_befehl_und_meldung_wird_ein_ganzer_griff() { + let p = protokoll(&[ + "480000 10.000 deck1=64.000/16 > ramp master.crossfader 1 32", + "480100 10.002 deck1=64.001/16 < ok plan 3 ramp master.crossfader nach 1 über 32 Beats", + "1200000 25.000 deck1=96.000/16 < plan 3 fertig master.crossfader 1.0000", + ]); + let g = griffe(&p); + assert_eq!(g.len(), 1); + assert_eq!(g[0].frame, 480_000); + assert_eq!(g[0].control, "master.crossfader"); + assert_eq!(g[0].beats, 32.0); + assert_eq!(g[0].plan, Some(3)); + assert_eq!(g[0].staende[0].beat, 64.0); + assert_eq!( + g[0].staende[0].phrasenlage(), + Some(0.0), + "64 ist eine Phrasengrenze" + ); + + let ende = g[0].ende.as_ref().expect("das Ende fehlt"); + assert_eq!(ende.ausgang, "fertig"); + assert_eq!(ende.staende[0].beat - g[0].staende[0].beat, 32.0); + } + + /// Ein abgelehnter Befehl darf sich nicht die Nummer des nächsten borgen. + /// + /// Ohne das hinge ein `plan 4 fertig` am falschen Griff, und der Bericht + /// behauptete eine Blende, die nie lief. + #[test] + fn ein_abgelehnter_befehl_bekommt_keine_fremde_nummer() { + let p = protokoll(&[ + "0 0.000 > ramp master.crossfader 1 32", + "0 0.000 < err kein Deck mit Beatgrid als Taktgeber", + "480000 10.000 deck1=64.000/16 > ramp channel1.eq_low 0 8", + "480100 10.002 deck1=64.001/16 < ok plan 4 ramp channel1.eq_low nach 0 über 8 Beats", + "600000 12.500 deck1=68.000/16 < plan 4 fertig channel1.eq_low 0.0000", + ]); + let g = griffe(&p); + assert_eq!(g.len(), 2); + assert_eq!( + g[0].plan, None, + "der abgelehnte Befehl hat eine Nummer bekommen" + ); + assert!( + g[0].ende.is_none(), + "der abgelehnte Befehl hat ein fremdes Ende" + ); + assert_eq!(g[1].plan, Some(4)); + assert_eq!( + g[1].ende.as_ref().map(|e| e.ausgang.as_str()), + Some("fertig") + ); + } + + /// Eine abgelöste Rampe ist kein Fehler im Lesen, sondern ein Befund: + /// Jemand anders hat den Regler angefasst. + #[test] + fn eine_abgeloeste_rampe_wird_als_solche_gelesen() { + let p = protokoll(&[ + "0 0.000 deck1=0.000/16 > ramp channel1.fader 0 16", + "0 0.000 deck1=0.000/16 < ok plan 1 ramp channel1.fader nach 0 über 16 Beats", + "96000 2.000 deck1=4.000/16 < plan 1 abgeloest channel1.fader — jemand anders hat den Regler", + ]); + let g = griffe(&p); + assert_eq!( + g[0].ende.as_ref().map(|e| e.ausgang.as_str()), + Some("abgeloest") + ); + } + + /// Zugeordnet wird über den Abstand, nicht über die Reihenfolge — und was + /// zu weit weg ist, wird gar nicht zugeordnet. + #[test] + fn der_griff_wird_ueber_den_abstand_gesucht() { + let a = Absicht { + rate: 48_000, + griffe: griffe(&protokoll(&[ + "480000 10.000 deck1=64.000/16 > ramp master.crossfader 1 32", + "480100 10.002 deck1=64.001/16 < ok plan 1 ramp master.crossfader nach 1", + "9600000 200.000 deck1=512.000/16 > ramp master.crossfader 0 32", + "9600100 200.002 deck1=512.001/16 < ok plan 2 ramp master.crossfader nach 0", + ])), + einsaetze: Vec::new(), + }; + assert_eq!(a.griffe.len(), 2); + + // Vier Sekunden zu spät geschätzt: negativer Abstand. + let (g, ab) = a.naechster(14.0).expect("kein Griff gefunden"); + assert_eq!(g.frame, 480_000); + assert!((ab + 4.0).abs() < 1e-6, "{ab}"); + + // Näher am zweiten. + assert_eq!(a.naechster(195.0).expect("kein Griff").0.frame, 9_600_000); + + // Und dazwischen gehört nichts zusammen. + assert!( + a.naechster(120.0).is_none(), + "zwei Dinge wurden zusammengezwungen, die 100 s auseinanderliegen" + ); + } + + /// Der Einsatz eines Decks ist ein eigenes Ereignis — und die Frage, ob er + /// auf der Eins des *eingehenden* Tracks lag, ist die, die ein Cue-Punkt + /// beantwortet. + #[test] + fn ein_einsatz_wird_als_solcher_erkannt() { + let p = protokoll(&[ + "1116160 23.253 deck1=48.012/16 deck2=-0.998/16~ > set deck2.play 1", + "1116160 23.253 deck1=48.012/16 deck2=-0.998/16 < ok deck2.play 1", + "1116160 23.253 deck1=48.012/16 deck2=-0.998/16 > ramp master.crossfader 1 32", + ]); + assert_eq!(einsaetze(&p), vec![(1, 1_116_160)]); + } + + /// Ein Stopp ist kein Einsatz, und eine Frage erst recht nicht. + #[test] + fn ein_stopp_ist_kein_einsatz() { + let p = protokoll(&[ + "0 0.000 deck1=0.000/16 > set deck1.play 0", + "0 0.000 deck1=0.000/16 < ok deck1.play 0", + "48000 1.000 deck1=2.000/16 < ok deck2.play 1", + ]); + assert!(einsaetze(&p).is_empty(), "{:?}", einsaetze(&p)); + } +} diff --git a/crates/musik-cli/src/bin/musik-lib.rs b/crates/musik-cli/src/bin/musik-lib.rs new file mode 100644 index 0000000..ac38c91 --- /dev/null +++ b/crates/musik-cli/src/bin/musik-lib.rs @@ -0,0 +1,239 @@ +//! Die Sammlung von der Kommandozeile: einlesen, importieren, durchsuchen. +//! +//! Führt Analyse und Library zusammen. Beim Einlesen wird jede Datei dekodiert, +//! analysiert und mit Tempo, Beatgrid und Inhalts-Hash abgelegt — der Hash +//! sorgt dafür, dass ein zweiter Lauf nicht noch einmal rechnet. +//! +//! Braucht **kein** Audiogerät. + +use std::path::{Path, PathBuf}; + +use analysis::Store; +use anyhow::{Context, Result, bail}; +use audio_core::Track; +use library::{Library, Query, TrackRecord, import_nml}; + +const AUDIO_EXT: [&str; 7] = ["mp3", "flac", "wav", "m4a", "aac", "ogg", "oga"]; + +fn main() -> Result<()> { + let mut args = std::env::args().skip(1); + let mut db = PathBuf::from("musik.db"); + let mut cache = PathBuf::from(".musik-analyse"); + let mut befehl: Option = None; + let mut rest: Vec = Vec::new(); + + while let Some(arg) = args.next() { + match arg.as_str() { + "--db" => db = PathBuf::from(args.next().context("--db braucht einen Pfad")?), + "--cache" => cache = PathBuf::from(args.next().context("--cache braucht einen Pfad")?), + "-h" | "--help" => { + hilfe(); + return Ok(()); + } + other if befehl.is_none() => befehl = Some(other.to_string()), + other => rest.push(other.to_string()), + } + } + + let Some(befehl) = befehl else { + hilfe(); + std::process::exit(2); + }; + + let lib = Library::open(&db).with_context(|| format!("{} nicht nutzbar", db.display()))?; + println!("Sammlung: {} ({} Tracks)", db.display(), lib.track_count()?); + + match befehl.as_str() { + "scan" => { + let ordner = rest.first().context("scan braucht ein Verzeichnis")?; + scan(&lib, Path::new(ordner), &Store::new(&cache))? + } + "import-traktor" => { + let datei = rest.first().context("import-traktor braucht eine .nml")?; + let xml = + std::fs::read_to_string(datei).with_context(|| format!("{datei} nicht lesbar"))?; + let bericht = import_nml(&xml, &lib)?; + + println!( + "\n{} Einträge gelesen, {} übernommen, {} Marker, {} ohne Pfad übersprungen", + bericht.entries_seen, + bericht.tracks_imported, + bericht.cues_imported, + bericht.skipped_without_location + ); + } + "search" => { + let text = rest.join(" "); + let treffer = lib.search(&Query::text(text))?; + zeige(&treffer); + } + "mixable" => { + let bpm: f32 = rest + .first() + .context("mixable braucht ein Tempo")? + .parse() + .context("Tempo ist keine Zahl")?; + let treffer = lib.search(&Query::mixable_with(bpm, 0.06))?; + println!("\nMischbar mit {bpm:.1} BPM (±6 %):"); + zeige(&treffer); + } + "harmonic" => { + let roh = rest.first().context("harmonic braucht eine Tonart")?; + let tonart = audio_core::Tonart::parse(roh).with_context(|| { + format!("'{roh}' ist keine Tonart — gemeint war Am, F# oder 8A") + })?; + let treffer = lib.search(&Query::harmonic_with(tonart))?; + println!("\nHarmonisch zu {} ({}):", tonart.name(), tonart.camelot()); + zeige(&treffer); + } + "missing-attribution" => { + let luecken = lib.tracks_missing_attribution()?; + if luecken.is_empty() { + println!("\nAlle Samples haben Lizenz und Urheber."); + } else { + println!( + "\n{} Samples ohne vollständige Rechteangabe:", + luecken.len() + ); + zeige(&luecken); + } + } + other => bail!("unbekannter Befehl: {other}"), + } + + Ok(()) +} + +fn scan(lib: &Library, ordner: &Path, store: &Store) -> Result<()> { + let dateien = sammle_dateien(ordner)?; + println!("{} Audiodateien gefunden\n", dateien.len()); + + let mut neu = 0usize; + let mut fehler = 0usize; + + for pfad in &dateien { + match einlesen(lib, pfad, store) { + Ok(gerechnet) => { + if gerechnet { + neu += 1; + } + } + Err(e) => { + eprintln!(" {}: {e:#}", pfad.display()); + fehler += 1; + } + } + } + + println!( + "\n{} eingelesen, {} neu analysiert, {} Fehler", + dateien.len(), + neu, + fehler + ); + Ok(()) +} + +/// Gibt zurück, ob gerechnet werden musste. +fn einlesen(lib: &Library, pfad: &Path, store: &Store) -> Result { + let track = Track::decode_file(pfad)?; + let (analyse, gerechnet) = analysis::analyze_cached(&track, store); + if gerechnet { + store.save(&analyse)?; + } + + let mut eintrag = TrackRecord::from_path(pfad.to_string_lossy().to_string()); + eintrag.fingerprint = Some(analyse.fingerprint.clone()); + eintrag.duration_secs = Some(analyse.duration_secs); + eintrag.title = pfad.file_stem().map(|s| s.to_string_lossy().to_string()); + + if let (Some(bpm), Some(anker)) = (analyse.bpm, analyse.beat_anchor_frames) { + eintrag.set_beatgrid( + Some(audio_core::Beatgrid::new(bpm, anker, 1.0)), + analyse.sample_rate, + ); + } + // `None` überschreibt beim Upsert nichts — eine aus Traktor importierte + // Tonart bleibt also stehen, wenn die eigene Analyse keine findet. + eintrag.musical_key = analyse.musical_key.clone(); + + lib.upsert_track(&eintrag)?; + + let tempo = match analyse.bpm { + Some(bpm) => format!("{bpm:6.2} BPM"), + None => " kein Tempo".to_string(), + }; + let key = match analyse.tonart() { + Some(k) => format!("{:>3} {:>3}", k.name(), k.camelot()), + None => " ".to_string(), + }; + println!( + " {tempo} {key} {} {}", + if gerechnet { "neu " } else { "Cache" }, + pfad.display() + ); + + Ok(gerechnet) +} + +fn sammle_dateien(ordner: &Path) -> Result> { + let mut out = Vec::new(); + let mut offen = vec![ordner.to_path_buf()]; + + while let Some(dir) = offen.pop() { + let Ok(eintraege) = std::fs::read_dir(&dir) else { + continue; + }; + for eintrag in eintraege.flatten() { + let pfad = eintrag.path(); + if pfad.is_dir() { + offen.push(pfad); + } else if pfad + .extension() + .and_then(|e| e.to_str()) + .map(|e| AUDIO_EXT.contains(&e.to_ascii_lowercase().as_str())) + .unwrap_or(false) + { + out.push(pfad); + } + } + } + + out.sort(); + Ok(out) +} + +fn zeige(tracks: &[TrackRecord]) { + if tracks.is_empty() { + println!(" (nichts gefunden)"); + return; + } + for t in tracks { + let bpm = t + .bpm + .map(|b| format!("{b:6.2}")) + .unwrap_or_else(|| " -".into()); + // Was in der Sammlung steht, kann aus Traktor stammen und in einer + // Schreibweise dastehen, die wir nicht deuten — dann eben so. + let key = t.musical_key.as_deref().unwrap_or("-"); + println!( + " {bpm} {key:<4} {:<28} {}", + t.artist.as_deref().unwrap_or("—"), + t.title.as_deref().unwrap_or(&t.path) + ); + } +} + +fn hilfe() { + println!("Aufruf: musik-lib [--db ] [--cache ] [argumente]"); + println!(); + println!(" scan Audiodateien einlesen, analysieren und ablegen"); + println!(" import-traktor Traktor-Sammlung übernehmen (Tempo, Grid, Cues)"); + println!(" search Titel, Künstler und Album durchsuchen"); + println!(" mixable Tracks im Tempofenster ±6 %"); + println!(" harmonic Tracks mit passender Tonart (Am, F# oder 8A)"); + println!(" missing-attribution Samples ohne Lizenz oder Urheber auflisten"); + println!(); + println!(" --db Sammlung (Vorgabe: musik.db)"); + println!(" --cache Analyse-Sidecars (Vorgabe: .musik-analyse)"); +} diff --git a/crates/musik-cli/src/bin/musik-material.rs b/crates/musik-cli/src/bin/musik-material.rs new file mode 100644 index 0000000..41ea4c0 --- /dev/null +++ b/crates/musik-cli/src/bin/musik-material.rs @@ -0,0 +1,285 @@ +//! Baut Prüfmaterial mit bekannter Gliederung — die schwierigen Fälle. +//! +//! Die Gliederung trägt inzwischen an drei Stellen: am Bogen (`arc_actual`), +//! an der Auswahl nach Energie (`master.search_next`) und an `beats_to_outro`, +//! nach dem ein Übergang liegt. Ihre Regeln haben unterwegs schon fünfmal am +//! Material gelegen — zuletzt bei einem Stück, das die meiste Zeit oben war +//! und deshalb „Intro, Break, Outro" hieß. +//! +//! Was dieses Werkzeug liefert, ist deshalb **kein Beweis, dass die Gliederung +//! stimmt.** Es liefert Fälle, bei denen von vornherein feststeht, was +//! herauskommen soll, und legt die Wahrheitsdatei gleich daneben: +//! +//! ```text +//! musik-material /tmp/schwierig +//! musik-pruefstand /tmp/schwierig/wahrheit.txt --cache /tmp/schwierig/.cache +//! ``` +//! +//! # Warum gebautes Material und nicht Musik +//! +//! Weil hier die *Struktur* geprüft wird und nicht das Hören. Ein Track ohne +//! Outro ist als gebautes Stück eindeutig — bei echter Musik wäre schon +//! strittig, ob die letzten sechzehn Takte eines sind. Das eine ersetzt das +//! andere nicht: Echte Musik mit gehörter Wahrheit steht weiter aus und ist +//! der eigentliche Prüfstein (siehe `docs/FAHRPLAN.md`, M1). +//! +//! Gebaut wird mit denselben drei Zutaten wie das Material der Testsuite — +//! Kick, Bass, Akkord —, damit hier die Struktur den Unterschied macht und +//! nicht der Klang. + +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +const RATE: u32 = 44_100; +const PHRASE_BEATS: f64 = 16.0; + +/// Ein Abschnitt: wie er heißen soll, wie lang er ist, und woraus er besteht. +#[derive(Clone, Copy)] +struct Teil { + art: &'static str, + phrasen: usize, + kick: bool, + bass: bool, + akkord: f32, +} + +const fn teil(art: &'static str, phrasen: usize, kick: bool, bass: bool, akkord: f32) -> Teil { + Teil { + art, + phrasen, + kick, + bass, + akkord, + } +} + +/// Ein Fall: Dateiname, Tempo, Abschnitte — und wonach gefragt wird. +struct Fall { + datei: &'static str, + bpm: f64, + /// Ab welchem Abschnitt ein zweites Tempo gilt; `None` heißt durchgehend. + tempowechsel: Option<(usize, f64)>, + teile: &'static [Teil], + frage: &'static str, +} + +/// Die schwierigen Fälle. +/// +/// Jeder ist gebaut, um **eine** Annahme zu brechen, die in der Gliederung +/// steckt. Was dabei herauskommt, ist erst dann ein Befund, wenn es gegen die +/// Wahrheitsdatei gehalten wurde — deshalb schreibt dieses Werkzeug beides. +static FAELLE: &[Fall] = &[ + Fall { + datei: "ohne-outro.wav", + bpm: 128.0, + tempowechsel: None, + // Endet auf dem Höhepunkt. Viele Produktionen tun das; die Regel + // „der letzte Abschnitt ist ein Outro, wenn er leise ist" darf daraus + // keins machen. + teile: &[ + teil("intro", 2, false, false, 0.20), + teil("aufbau", 2, true, false, 0.30), + teil("drop", 2, true, true, 0.32), + teil("break", 2, true, false, 0.12), + teil("drop", 3, true, true, 0.31), + ], + frage: "Endet auf dem Drop. Wird der letzte Abschnitt trotzdem Outro genannt?", + }, + Fall { + datei: "ohne-intro.wav", + bpm: 128.0, + tempowechsel: None, + // Fängt sofort oben an — ein Werkzeug-Edit, wie er in jedem + // DJ-Ordner liegt. `entry` und `intro_beats` hängen daran. + teile: &[ + teil("drop", 3, true, true, 0.32), + teil("break", 2, true, false, 0.12), + teil("drop", 2, true, true, 0.31), + teil("outro", 2, false, false, 0.18), + ], + frage: "Fängt sofort oben an. Wird der erste Abschnitt trotzdem Intro genannt?", + }, + Fall { + datei: "zwei-breaks.wav", + bpm: 128.0, + tempowechsel: None, + // Zwei Einbrüche statt einem. Die Benennung arbeitet mit Quantilen + // über die Abschnitte — bei zwei leisen Stellen verschiebt sich der + // Median, und das ist genau die Stelle, an der sie schon einmal lag. + teile: &[ + teil("intro", 2, false, false, 0.20), + teil("aufbau", 2, true, false, 0.30), + teil("drop", 2, true, true, 0.33), + teil("break", 2, true, false, 0.12), + teil("drop", 2, true, true, 0.31), + teil("break", 2, true, false, 0.11), + teil("drop", 2, true, true, 0.32), + teil("outro", 2, false, false, 0.18), + ], + frage: "Zwei Einbrüche. Werden beide Breaks gefunden, oder verschiebt der Median die Namen?", + }, + Fall { + datei: "tempowechsel.wav", + bpm: 124.0, + // Ab dem vierten Abschnitt schneller — ein Übergang zwischen zwei + // Stücken im selben File, wie ihn ein Mitschnitt hat. + tempowechsel: Some((3, 140.0)), + teile: &[ + teil("intro", 2, false, false, 0.20), + teil("aufbau", 2, true, false, 0.30), + teil("drop", 2, true, true, 0.32), + teil("teil", 2, true, true, 0.30), + teil("drop", 2, true, true, 0.32), + teil("outro", 2, false, false, 0.18), + ], + frage: "Tempo springt in der Mitte von 124 auf 140. Was macht das Beatgrid — \ +und was die Gliederung, die darauf rechnet?", + }, +]; + +fn main() -> Result<()> { + let mut args = std::env::args().skip(1); + let Some(ziel) = args.next() else { + hilfe(); + return Ok(()); + }; + if ziel == "--help" || ziel == "-h" { + hilfe(); + return Ok(()); + } + let ziel = PathBuf::from(ziel); + std::fs::create_dir_all(&ziel).with_context(|| format!("{} anlegen", ziel.display()))?; + + let mut wahrheit = String::from( + "# Gebautes Material mit bekannter Gliederung — die schwierigen Fälle.\n\ + # Geschrieben von `musik-material`. Die Zeiten sind nicht gehört,\n\ + # sondern gebaut: Sie stimmen auf den Frame.\n\n", + ); + + for fall in FAELLE { + let (pcm, grenzen) = bauen(fall); + let pfad = ziel.join(fall.datei); + schreibe_wav(&pfad, &pcm).with_context(|| format!("{} schreiben", pfad.display()))?; + + wahrheit.push_str(&format!("# {}\n", fall.frage)); + wahrheit.push_str(&format!("{} bpm {}", fall.datei, fall.bpm)); + for (art, sekunden) in &grenzen { + wahrheit.push_str(&format!(" {art} {}", zeit(*sekunden))); + } + wahrheit.push_str("\n\n"); + + println!( + "{:<18} {:>5.1} s {} Abschnitte", + fall.datei, + pcm.len() as f64 / 2.0 / RATE as f64, + fall.teile.len() + ); + } + + let wpfad = ziel.join("wahrheit.txt"); + std::fs::write(&wpfad, wahrheit).with_context(|| format!("{} schreiben", wpfad.display()))?; + println!("\nWahrheit: {}", wpfad.display()); + println!("Prüfen: musik-pruefstand {}", wpfad.display()); + Ok(()) +} + +/// Baut einen Fall und gibt die Grenzen zurück, die dabei entstanden sind. +/// +/// Die Grenzen kommen aus dem Bauen selbst und nicht aus einer Rechnung +/// daneben — sonst prüfte die Wahrheitsdatei irgendwann etwas anderes als das, +/// was in der Datei steht. +fn bauen(fall: &Fall) -> (Vec, Vec<(&'static str, f64)>) { + let mut mono: Vec = Vec::new(); + let mut grenzen = Vec::new(); + // Die Phase des Akkords läuft über das ganze Stück weiter. Bräche sie an + // jeder Teilgrenze, entstünde dort ein Knacks — also ein Onset, den die + // Gliederung dann zu Recht fände, und die Prüfung misst den Knacks statt + // der Struktur. + let mut phase = 0.0f32; + + for (i, t) in fall.teile.iter().enumerate() { + let bpm = match fall.tempowechsel { + Some((ab, neu)) if i >= ab => neu, + _ => fall.bpm, + }; + let je_beat = 60.0 / bpm * RATE as f64; + let laenge = (je_beat * PHRASE_BEATS) as usize * t.phrasen; + + grenzen.push((t.art, mono.len() as f64 / RATE as f64)); + let von = mono.len(); + mono.resize(von + laenge, 0.0); + + for wert in mono[von..].iter_mut() { + for hz in [220.0f32, 277.18, 329.63] { + *wert += (std::f32::consts::TAU * hz * phase).sin() * t.akkord / 3.0; + } + if t.bass { + *wert += (std::f32::consts::TAU * 55.0 * phase).sin() * 0.5; + } + phase += 1.0 / RATE as f32; + } + + if t.kick { + let schlag = je_beat as usize; + let mut start = von; + while start + RATE as usize / 4 < mono.len() { + for j in 0..RATE as usize / 4 { + let tt = j as f32 / RATE as f32; + let f = 110.0 * (-tt * 30.0).exp() + 45.0; + mono[start + j] += + (std::f32::consts::TAU * f * tt).sin() * (-tt * 22.0).exp() * 0.9; + } + start += schlag; + } + } + } + + let pcm = mono + .iter() + .flat_map(|v| [v.clamp(-1.0, 1.0), v.clamp(-1.0, 1.0)]) + .collect(); + (pcm, grenzen) +} + +fn zeit(sekunden: f64) -> String { + let ganze = sekunden.round() as u64; + format!("{}:{:02}", ganze / 60, ganze % 60) +} + +fn schreibe_wav(pfad: &Path, pcm: &[f32]) -> std::io::Result<()> { + let data_len = (pcm.len() * 2) as u32; + let mut out = BufWriter::new(File::create(pfad)?); + + out.write_all(b"RIFF")?; + out.write_all(&(36 + data_len).to_le_bytes())?; + out.write_all(b"WAVEfmt ")?; + out.write_all(&16u32.to_le_bytes())?; + out.write_all(&1u16.to_le_bytes())?; + out.write_all(&2u16.to_le_bytes())?; + out.write_all(&RATE.to_le_bytes())?; + out.write_all(&(RATE * 4).to_le_bytes())?; + out.write_all(&4u16.to_le_bytes())?; + out.write_all(&16u16.to_le_bytes())?; + out.write_all(b"data")?; + out.write_all(&data_len.to_le_bytes())?; + + for sample in pcm { + out.write_all(&((sample.clamp(-1.0, 1.0) * 32_767.0) as i16).to_le_bytes())?; + } + out.flush() +} + +fn hilfe() { + println!("Aufruf: musik-material "); + println!(); + println!("Baut Prüfmaterial mit bekannter Gliederung und legt die"); + println!("Wahrheitsdatei daneben. Vier Fälle, jeder bricht eine Annahme:"); + for fall in FAELLE { + println!(" {:<18} {}", fall.datei, fall.frage); + } + println!(); + println!("Danach: musik-pruefstand /wahrheit.txt"); +} diff --git a/crates/musik-cli/src/bin/musik-mix.rs b/crates/musik-cli/src/bin/musik-mix.rs new file mode 100644 index 0000000..03b4ccc --- /dev/null +++ b/crates/musik-cli/src/bin/musik-mix.rs @@ -0,0 +1,530 @@ +//! Rendert einen Mix offline in eine WAV-Datei. +//! +//! Zwei Zwecke. Erstens lässt sich die Engine damit ohne Audiogerät prüfen — +//! derselbe Signalweg, nur schneller als Echtzeit und in eine Datei statt auf +//! die Anlage. Zweitens kann man das Ergebnis hinterher anhören, was in einer +//! Umgebung ohne Soundkarte der einzige Weg ist, überhaupt etwas zu beurteilen. +//! +//! Der Übergang ist ein Bass-Swap, wie man ihn von Hand fahren würde: Der +//! Crossfader wandert hinüber, und in der Mitte wechselt der Bass von einem +//! Deck zum anderen. Zwei Bässe gleichzeitig matschen — deshalb macht das +//! niemand anders. + +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use audio_core::Track; +use audio_core::deck::{DeckState, Voice}; +use audio_engine::{Assign, DeckSource, Engine, Source, aux_channel}; + +const BLOCK: usize = 512; + +struct Options { + out: PathBuf, + /// Wohin der Kopfhörer-Bus geschrieben wird. + /// + /// Ohne Angabe gar nicht — dann rendert der Mixer wie bisher zweikanalig. + /// Mit Angabe läuft er vierkanalig, also über denselben Weg, den ein Gerät + /// mit vier Ausgängen nimmt. + cue_out: Option, + a: Option, + b: Option, + aux: Option, + seconds: f64, + transition: Option, + transition_len: f64, + sample_rate: u32, +} + +impl Default for Options { + fn default() -> Self { + Options { + out: PathBuf::from("mix.wav"), + cue_out: None, + a: None, + b: None, + aux: None, + seconds: 60.0, + transition: None, + transition_len: 16.0, + sample_rate: 48_000, + } + } +} + +fn main() -> Result<()> { + let opts = parse_args()?; + let rate = opts.sample_rate; + + // Deck B wird gestreckt und braucht dadurch mehr Quellmaterial als + // Renderzeit — mit Reserve. + let reserve = opts.seconds * 1.5 + 10.0; + let (track_a, quelle_a) = + lade_oder_synthetisiere(opts.a.as_deref(), rate, Variante::A, reserve)?; + let (track_b, quelle_b) = + lade_oder_synthetisiere(opts.b.as_deref(), rate, Variante::B, reserve)?; + + let mut engine = Engine::new(rate as f32); + + let deck_a = engine.add_channel("A", quelle_a); + let deck_b = engine.add_channel("B", quelle_b); + engine.channel(deck_a).set_assign(Assign::A); + engine.channel(deck_b).set_assign(Assign::B); + + // Tempo angleichen, sofern beide Seiten ein Grid haben. + let tempo_info = angleichen(&track_a, &track_b); + + // AUX läuft auf Thru — der Crossfader darf ihn nicht wegnehmen. + let aux_kanal = if let Some(pfad) = opts.aux.as_deref() { + let track = Track::decode_file(pfad) + .with_context(|| format!("AUX: {} nicht lesbar", pfad.display()))? + .resampled_to(rate); + let (mut writer, source) = aux_channel(rate as usize * 4); + writer.write(&track.samples); + + let idx = engine.add_channel("AUX", Box::new(source)); + engine.channel(idx).set_assign(Assign::Thru); + engine.channel(idx).set_fader(0.35); + Some(idx) + } else { + None + }; + + let transition = opts.transition.unwrap_or(opts.seconds * 0.5); + let frames_total = (opts.seconds * rate as f64) as usize; + + println!("Rendere {:.0}s @ {} Hz", opts.seconds, rate); + println!(" Deck A: {}", beschreibung(opts.a.as_deref(), Variante::A)); + println!(" Deck B: {}", beschreibung(opts.b.as_deref(), Variante::B)); + if let Some(idx) = aux_kanal { + println!(" AUX: {} (Thru, Fader 0.35)", engine.channel(idx).name); + } + if let Some(text) = &tempo_info { + println!(" {text}"); + } + println!( + " Übergang ab {transition:.1}s über {:.0}s", + opts.transition_len + ); + + // Mit `--cue` läuft der Mixer vierkanalig — derselbe Weg, den ein Gerät mit + // vier Ausgängen nimmt. Ohne bleibt es beim Stereo-Pfad, damit ein + // gewöhnlicher Mixdown nicht die doppelte Arbeit macht. + let kanaele = if opts.cue_out.is_some() { 4 } else { 2 }; + if let Some(pfad) = &opts.cue_out { + // Ohne Cue-Taste bliebe die Datei still — dann sähe eine Trennung, die + // nicht funktioniert, genauso aus wie eine, die funktioniert. + engine.channel(deck_b).set_cue(true); + println!(" Cue: {} (Deck B liegt vor)", pfad.display()); + } + + let mut pcm: Vec = Vec::with_capacity(frames_total * 2); + let mut cue_pcm: Vec = Vec::new(); + let mut block = vec![0.0f32; BLOCK * kanaele]; + let mut frame = 0usize; + + while frame < frames_total { + let n = BLOCK.min(frames_total - frame); + let zeit = frame as f64 / rate as f64; + + automatisieren( + &mut engine, + deck_a, + deck_b, + zeit, + transition, + opts.transition_len, + ); + + let slice = &mut block[..n * kanaele]; + engine.render(slice, kanaele); + + if kanaele == 2 { + pcm.extend_from_slice(slice); + } else { + aufteilen(slice, kanaele, &mut pcm, &mut cue_pcm); + } + + frame += n; + } + + schreibe_wav(&opts.out, &pcm, rate).context("WAV konnte nicht geschrieben werden")?; + println!( + "\n{} geschrieben ({:.1} MB)", + opts.out.display(), + (pcm.len() * 2) as f64 / 1_048_576.0 + ); + + if let Some(pfad) = &opts.cue_out { + schreibe_wav(pfad, &cue_pcm, rate).context("Cue-WAV konnte nicht geschrieben werden")?; + println!( + "{} geschrieben ({:.1} MB) — muss anders klingen als die Summe", + pfad.display(), + (cue_pcm.len() * 2) as f64 / 1_048_576.0 + ); + } + + Ok(()) +} + +/// Trennt einen vierkanaligen Block in Summe und Kopfhörer. +/// +/// 0/1 sind die Summe, 2/3 der Kopfhörer — genau die Belegung, die auch am +/// Gerät herauskommt. Vertauscht wäre sie hörbar falsch, aber erst beim +/// Anhören; deshalb steht sie in einer eigenen Funktion mit eigenem Test. +fn aufteilen(block: &[f32], kanaele: usize, summe: &mut Vec, kopfhoerer: &mut Vec) { + for f in block.chunks_exact(kanaele) { + summe.extend_from_slice(&f[..2]); + kopfhoerer.extend_from_slice(&f[2..4]); + } +} + +/// Fahrplan des Übergangs. +/// +/// Vor dem Übergang läuft A allein, danach B allein. Dazwischen wandert der +/// Crossfader hinüber, und der Bass wechselt in der Mitte die Seite. +fn automatisieren( + engine: &mut Engine, + deck_a: usize, + deck_b: usize, + zeit: f64, + start: f64, + dauer: f64, +) { + let t = ((zeit - start) / dauer).clamp(0.0, 1.0) as f32; + + engine.channel(deck_a).set_fader(1.0); + engine.channel(deck_b).set_fader(1.0); + engine.crossfader().set_position(t * 2.0 - 1.0); + + // Bass-Swap: B bringt seinen Bass erst nach der Hälfte, A gibt ihn dort ab. + let (bass_a, bass_b) = if t < 0.5 { + (1.0, (t * 2.0).powi(2) * 0.0) + } else { + let u = (t - 0.5) * 2.0; + (1.0 - u, u) + }; + + engine.channel(deck_a).set_eq(bass_a, 1.0, 1.0); + engine.channel(deck_b).set_eq(bass_b, 1.0, 1.0); + + // Gegen Ende des Übergangs ein kurzer Hochpass auf A — nimmt ihm das + // Fundament, bevor er ganz verschwindet. + let filter_a = if t > 0.6 { + ((t - 0.6) / 0.4).clamp(0.0, 1.0) * 0.7 + } else { + 0.0 + }; + engine.channel(deck_a).set_filter(filter_a); +} + +/// Zieht Deck B auf das Tempo von Deck A. +/// +/// Das ist der Grund, warum auch die synthetischen Loops als echte Decks +/// laufen und nicht als simple Schleifen: Nur so greift der Zeitstrecker, und +/// nur dann stimmt auch die Meldung darüber. +fn angleichen(a: &Deck, b: &Deck) -> Option { + let (bpm_a, bpm_b) = (a.bpm?, b.bpm?); + let ratio = bpm_a / bpm_b; + b.state.set_tempo(ratio); + + Some(format!( + "Tempo: B {bpm_b:.2} → {:.2} BPM (Faktor {:.4}, Keylock an)", + bpm_b * b.state.tempo(), + b.state.tempo() + )) +} + +struct Deck { + bpm: Option, + state: Arc, +} + +enum Variante { + A, + B, +} + +fn beschreibung(pfad: Option<&Path>, variante: Variante) -> String { + match pfad { + Some(p) => p.display().to_string(), + None => match variante { + Variante::A => "synthetisch, 128 BPM (Kick, Bass, Hats)".into(), + Variante::B => "synthetisch, 124 BPM (Kick, Bass, Snare)".into(), + }, + } +} + +/// Lädt eine Datei oder erzeugt einen synthetischen Loop, und hängt beides an +/// ein Deck. +fn lade_oder_synthetisiere( + pfad: Option<&Path>, + rate: u32, + variante: Variante, + mindestlaenge: f64, +) -> Result<(Deck, Box)> { + let (track, bpm) = match pfad { + Some(p) => { + let track = Track::decode_file(p) + .with_context(|| format!("{} nicht lesbar", p.display()))? + .resampled_to(rate); + let bpm = analysis::analyze(&track).bpm; + (track, bpm) + } + None => { + let (loop_samples, bpm) = match variante { + Variante::A => (synth::loop_a(rate), 128.0), + Variante::B => (synth::loop_b(rate), 124.0), + }; + // Der Loop ist wenige Sekunden lang; ein Deck würde ihn einmal + // abspielen und dann verstummen. Also so oft aneinanderhängen, + // dass er die Renderdauer trägt. + let samples = wiederholen(loop_samples, rate, mindestlaenge); + ( + Track { + samples, + sample_rate: rate, + stems: Vec::new(), + }, + Some(bpm), + ) + } + }; + + let state = Arc::new(DeckState::new()); + state.set_playing(true); + state.set_keylock(true); + + let voice = Voice::new(Arc::new(track), Arc::clone(&state)); + Ok((Deck { bpm, state }, Box::new(DeckSource::new(voice)))) +} + +fn wiederholen(samples: Vec, rate: u32, mindestlaenge: f64) -> Vec { + if samples.is_empty() { + return samples; + } + let noetig = (mindestlaenge * rate as f64) as usize * 2; + let male = (noetig / samples.len()).max(1) + 1; + + let mut out = Vec::with_capacity(samples.len() * male); + for _ in 0..male { + out.extend_from_slice(&samples); + } + out +} + +/// Kleine Klangerzeugung für den Fall ohne Eingabedateien. +mod synth { + use std::f32::consts::PI; + + pub fn loop_a(rate: u32) -> Vec { + bauen(rate, 128.0, 8, |bar, step, t, out| { + if step % 4 == 0 { + kick(t, out); + } + if step % 4 == 2 { + hat(t, out, 0.25); + } + if step % 2 == 0 { + let note = [55.0, 55.0, 73.42, 65.41][bar % 4]; + bass(t, note, out, 0.30); + } + }) + } + + pub fn loop_b(rate: u32) -> Vec { + bauen(rate, 124.0, 8, |bar, step, t, out| { + if step % 4 == 0 { + kick(t, out); + } + if step % 8 == 4 { + snare(t, out); + } + if step % 2 == 1 { + let note = [49.0, 61.74, 49.0, 58.27][bar % 4]; + bass(t, note, out, 0.28); + } + }) + } + + /// Baut `bars` Takte à 16 Sechzehntel und ruft je Schritt den Aufbau auf. + fn bauen( + rate: u32, + bpm: f32, + bars: usize, + mut schritt: impl FnMut(usize, usize, f32, &mut dyn FnMut(usize, f32)), + ) -> Vec { + let frames_per_beat = rate as f32 * 60.0 / bpm; + let frames_per_step = frames_per_beat / 4.0; + let total = (frames_per_step * 16.0 * bars as f32) as usize; + let mut mono = vec![0.0f32; total]; + + for bar in 0..bars { + for step in 0..16 { + let start = ((bar * 16 + step) as f32 * frames_per_step) as usize; + let laenge = (frames_per_step * 4.0) as usize; + + let mut add = |offset: usize, value: f32| { + let idx = start + offset; + if idx < total { + mono[idx] += value; + } + }; + + schritt(bar, step, laenge as f32 / rate as f32, &mut add); + let _ = laenge; + } + } + + // Auf Stereo aufziehen und Pegel bändigen. + let peak = mono.iter().fold(0.0f32, |m, v| m.max(v.abs())).max(1e-6); + let scale = 0.7 / peak; + mono.into_iter() + .flat_map(|v| { + let s = v * scale; + [s, s] + }) + .collect() + } + + fn kick(dauer: f32, add: &mut dyn FnMut(usize, f32)) { + let n = (dauer * 48_000.0).min(12_000.0) as usize; + for i in 0..n { + let t = i as f32 / 48_000.0; + let env = (-t * 22.0).exp(); + // Tonhöhe fällt — das macht den Punch aus. + let f = 110.0 * (-t * 30.0).exp() + 45.0; + add(i, (2.0 * PI * f * t).sin() * env * 0.9); + } + } + + fn snare(dauer: f32, add: &mut dyn FnMut(usize, f32)) { + let n = (dauer * 48_000.0).min(9_000.0) as usize; + let mut seed = 0x2545_F491u32; + for i in 0..n { + let t = i as f32 / 48_000.0; + let env = (-t * 30.0).exp(); + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let noise = (seed >> 8) as f32 / 8_388_608.0 - 1.0; + let ton = (2.0 * PI * 190.0 * t).sin(); + add(i, (noise * 0.6 + ton * 0.4) * env * 0.6); + } + } + + fn hat(dauer: f32, add: &mut dyn FnMut(usize, f32), gain: f32) { + let n = (dauer * 48_000.0).min(2_400.0) as usize; + let mut seed = 0x9E37_79B9u32; + for i in 0..n { + let t = i as f32 / 48_000.0; + let env = (-t * 120.0).exp(); + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let noise = (seed >> 8) as f32 / 8_388_608.0 - 1.0; + add(i, noise * env * gain); + } + } + + fn bass(dauer: f32, freq: f32, add: &mut dyn FnMut(usize, f32), gain: f32) { + let n = (dauer * 48_000.0).min(14_000.0) as usize; + for i in 0..n { + let t = i as f32 / 48_000.0; + let env = (1.0 - (-t * 120.0).exp()) * (-t * 6.0).exp(); + let saw = 2.0 * ((freq * t) % 1.0) - 1.0; + add(i, saw * env * gain); + } + } +} + +fn schreibe_wav(pfad: &Path, pcm: &[f32], rate: u32) -> std::io::Result<()> { + let data_len = (pcm.len() * 2) as u32; + let mut out = BufWriter::new(File::create(pfad)?); + + out.write_all(b"RIFF")?; + out.write_all(&(36 + data_len).to_le_bytes())?; + out.write_all(b"WAVEfmt ")?; + out.write_all(&16u32.to_le_bytes())?; + out.write_all(&1u16.to_le_bytes())?; + out.write_all(&2u16.to_le_bytes())?; + out.write_all(&rate.to_le_bytes())?; + out.write_all(&(rate * 4).to_le_bytes())?; + out.write_all(&4u16.to_le_bytes())?; + out.write_all(&16u16.to_le_bytes())?; + out.write_all(b"data")?; + out.write_all(&data_len.to_le_bytes())?; + + for sample in pcm { + let value = (sample.clamp(-1.0, 1.0) * 32_767.0) as i16; + out.write_all(&value.to_le_bytes())?; + } + + out.flush() +} + +fn parse_args() -> Result { + let mut opts = Options::default(); + let mut args = std::env::args().skip(1); + + while let Some(arg) = args.next() { + let mut wert = || args.next().context("fehlender Wert"); + match arg.as_str() { + "--out" | "-o" => opts.out = PathBuf::from(wert()?), + "--cue" => opts.cue_out = Some(PathBuf::from(wert()?)), + "--a" => opts.a = Some(PathBuf::from(wert()?)), + "--b" => opts.b = Some(PathBuf::from(wert()?)), + "--aux" => opts.aux = Some(PathBuf::from(wert()?)), + "--seconds" => opts.seconds = wert()?.parse().context("--seconds")?, + "--transition" => opts.transition = Some(wert()?.parse().context("--transition")?), + "--transition-len" => { + opts.transition_len = wert()?.parse().context("--transition-len")? + } + "--rate" => opts.sample_rate = wert()?.parse().context("--rate")?, + "-h" | "--help" => { + hilfe(); + std::process::exit(0); + } + other => bail!("unbekannte Option: {other}"), + } + } + + if opts.seconds <= 0.0 { + bail!("--seconds muss positiv sein"); + } + Ok(opts) +} + +fn hilfe() { + println!("Aufruf: musik-mix [--a ] [--b ] [--aux ] --out "); + println!(); + println!("Rendert einen Übergang zwischen zwei Decks offline in eine WAV-Datei."); + println!("Ohne --a/--b werden synthetische Loops verwendet."); + println!(); + println!(" --a Deck A"); + println!(" --b Deck B"); + println!(" --aux AUX-Zuspieler, läuft auf Thru durch"); + println!(" --out Ausgabe (Vorgabe: mix.wav)"); + println!(" --cue Kopfhörer-Bus als eigene Datei; Deck B liegt"); + println!(" darauf vor, während A auf der Summe läuft"); + println!(" --seconds Gesamtlänge (Vorgabe: 60)"); + println!(" --transition Beginn des Übergangs (Vorgabe: Mitte)"); + println!(" --transition-len Dauer des Übergangs (Vorgabe: 16)"); + println!(" --rate Samplerate (Vorgabe: 48000)"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn summe_und_kopfhoerer_kommen_aus_den_richtigen_kanaelen() { + // Zwei Frames: erst die Summe, dann der Kopfhörer. + let block = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + let mut summe = Vec::new(); + let mut kopfhoerer = Vec::new(); + aufteilen(&block, 4, &mut summe, &mut kopfhoerer); + + assert_eq!(summe, vec![1.0, 2.0, 5.0, 6.0]); + assert_eq!(kopfhoerer, vec![3.0, 4.0, 7.0, 8.0]); + } +} diff --git a/crates/musik-cli/src/bin/musik-pruefstand.rs b/crates/musik-cli/src/bin/musik-pruefstand.rs new file mode 100644 index 0000000..98bb52d --- /dev/null +++ b/crates/musik-cli/src/bin/musik-pruefstand.rs @@ -0,0 +1,711 @@ +//! Der Prüfstand: legt die Analyse neben das, was ein Mensch hört. +//! +//! **Warum es das braucht.** Über der Anlage liegen drei Schichten Messwerkzeug +//! — der Kritiker, die Mitschrift und die Gliederung. Jede hat beim Bauen +//! sofort einen Fehler gefunden, den niemand vermutet hatte. Und jede Schwelle +//! darin ist an Material geeicht, das aus eigener Hand stammt. +//! +//! Genau das war in diesem Projekt schon viermal die Vorstufe eines Fehlers: +//! Die Güteschwelle beim Tempo war an Klick-Tracks geeicht, die zwischen den +//! Klicks still sind; bei der Tonart wies eine synthetisch geeichte Schwelle +//! vier von fünf echten Aufnahmen ab; und bei der Gliederung war der Prüfstein +//! „gleichförmiges Material" ein reiner Sinus, dessen spektraler Fluss um 45 % +//! driftet. Jedes Mal sah es vorher gut aus. +//! +//! Ein Messwerkzeug, dem niemand widerspricht, ist ein Orakel. Hier +//! widerspricht ihm ein Mensch. +//! +//! # Die Wahrheitsdatei +//! +//! Eine Zeile je Track, von Hand geschrieben, Zeiten als `m:ss` oder in +//! Sekunden: +//! +//! ```text +//! # was ich höre +//! Nachtschicht.mp3 bpm 124 tonart Am intro 0:00 aufbau 0:32 drop 1:04 outro 5:12 +//! Alpenglühen.wav bpm 126 +//! ``` +//! +//! Abschnitte stehen mit ihrem **Anfang**, nicht als Bereich: Wer zuhört, +//! notiert „hier fängt das Outro an" und nicht „das Outro geht von … bis …". +//! Das Ende ist der Anfang des nächsten. Alle Angaben sind einzeln freiwillig — +//! wer nur das Tempo weiß, schreibt nur das Tempo. +//! +//! # Was er meldet und was nicht +//! +//! **Abweichungen, keine Fehler.** Eine Angabe von Hand ist eine Meinung: Zwei +//! Leute setzen den Anfang eines Drops eine Phrase auseinander, und beide haben +//! recht. Ab welchem Abstand etwas kaputt ist, entscheidet weiterhin, wer +//! zuhört — deshalb steht hier kein Urteil und keine Note, sondern eine Zahl +//! mit Vorzeichen. +//! +//! Aus demselben Grund endet er mit Rückgabewert 0, auch wenn alles daneben +//! liegt. Was nicht gelesen werden konnte, ist ein Fehler; was nicht +//! übereinstimmt, ist ein Befund. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +use analysis::{Store, analyze_cached}; +use audio_core::{Art, Tonart, Track}; + +const DEFAULT_CACHE: &str = ".musik-analyse"; + +/// Ab welchem Abstand zwei Tempi als verschieden gelten. +/// +/// Ein halbes BPM ist unter dem, was ein Mensch beim Zählen trifft, und über +/// dem, was der Detektor zwischen zwei Läufen schwankt. +const BPM_GENAU: f32 = 0.5; + +/// Wie weit eine erkannte Grenze von einer gehörten entfernt sein darf, um noch +/// dieselbe zu sein — in Phrasen. +/// +/// Eine ganze Phrase, weil die Angabe von Hand genau so grob ist: Wer den +/// Anfang eines Drops notiert, trifft die Phrase und selten den Beat. +const ZUORDNUNG_PHRASEN: f64 = 1.0; + +fn main() -> Result<()> { + let opts = argumente()?; + let wahrheiten = wahrheit_lesen(&opts.wahrheit)?; + if wahrheiten.is_empty() { + bail!("{} enthält keine Angaben", opts.wahrheit.display()); + } + + let store = Store::new(&opts.cache); + println!("Wahrheit: {}", opts.wahrheit.display()); + println!("Cache: {}", store.root().display()); + println!( + "\n{} Tracks. Angegeben ist, was ein Mensch hört; gemeldet wird der", + wahrheiten.len() + ); + println!("Abstand dazu — mit Vorzeichen, ohne Urteil.\n"); + + let mut summe = Summe::default(); + for w in &wahrheiten { + match ein_track(w, &store, &mut summe) { + Ok(()) => {} + Err(e) => { + println!("── {} ──\n nicht lesbar: {e:#}\n", w.datei.display()); + summe.unlesbar += 1; + } + } + } + + summe.bericht(); + Ok(()) +} + +// ── Die Wahrheitsdatei ─────────────────────────────────────────────────── + +/// Was ein Mensch über einen Track gesagt hat. +#[derive(Debug, Clone, PartialEq)] +struct Wahrheit { + datei: PathBuf, + bpm: Option, + tonart: Option, + /// Abschnitte mit ihrem Anfang in Sekunden, in der Reihenfolge der Zeile. + abschnitte: Vec<(Art, f64)>, +} + +/// Liest die Wahrheitsdatei. +/// +/// Pfade sind relativ zu ihr selbst zu verstehen — sie liegt neben der Musik, +/// und dann steht in ihr der bloße Dateiname. +fn wahrheit_lesen(pfad: &Path) -> Result> { + let text = std::fs::read_to_string(pfad) + .with_context(|| format!("{} ließ sich nicht lesen", pfad.display()))?; + let ordner = pfad.parent().unwrap_or(Path::new(".")); + + let mut aus = Vec::new(); + for (nummer, zeile) in text.lines().enumerate() { + let Some(mut w) = zeile_lesen(zeile) + .with_context(|| format!("{}, Zeile {}", pfad.display(), nummer + 1))? + else { + continue; + }; + if w.datei.is_relative() { + w.datei = ordner.join(&w.datei); + } + aus.push(w); + } + Ok(aus) +} + +/// Eine Zeile. `None` für Leerzeilen und Kommentare. +fn zeile_lesen(zeile: &str) -> Result> { + let zeile = zeile.trim(); + if zeile.is_empty() || zeile.starts_with('#') { + return Ok(None); + } + + let mut worte = zeile.split_whitespace(); + let datei = worte.next().expect("nicht leer"); + let mut w = Wahrheit { + datei: PathBuf::from(datei), + bpm: None, + tonart: None, + abschnitte: Vec::new(), + }; + + while let Some(schluessel) = worte.next() { + let Some(wert) = worte.next() else { + bail!("{schluessel} steht ohne Wert da"); + }; + match schluessel { + "bpm" => { + w.bpm = Some( + wert.parse() + .with_context(|| format!("{wert} ist kein Tempo"))?, + ) + } + "tonart" => { + w.tonart = Some( + Tonart::parse(wert) + .with_context(|| format!("{wert} ist keine Tonart (Am, F#, 8A)"))?, + ) + } + andere => { + let art = Art::parse(andere).with_context(|| { + format!("{andere} ist weder bpm, tonart noch ein Abschnitt") + })?; + w.abschnitte.push((art, zeit_lesen(wert)?)); + } + } + } + + // Sortiert, weil alles Weitere davon ausgeht — und weil eine Zeile, in der + // die Zeiten durcheinandergeraten sind, sonst still falsch verglichen + // würde. + w.abschnitte + .sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + Ok(Some(w)) +} + +/// `m:ss`, `m:ss.s` oder eine Sekundenzahl. +fn zeit_lesen(text: &str) -> Result { + match text.split_once(':') { + Some((min, sek)) => { + let min: f64 = min + .parse() + .with_context(|| format!("{text} ist keine Zeit"))?; + let sek: f64 = sek + .parse() + .with_context(|| format!("{text} ist keine Zeit"))?; + Ok(min * 60.0 + sek) + } + None => text + .parse() + .with_context(|| format!("{text} ist keine Zeit")), + } +} + +// ── Der Vergleich ──────────────────────────────────────────────────────── + +/// Wie eine erkannte Tonart zur gehörten steht. +/// +/// Nicht bloß richtig oder falsch: Eine Paralleltonart zu treffen ist ein +/// anderer Fehler, als danebenzugreifen — beim Mischen tut sie nicht weh. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Tonartlage { + Gleich, + /// Auf dem Camelot-Rad benachbart oder parallel — mischbar. + Verwandt, + Daneben, +} + +fn tonartlage(erkannt: Tonart, gehoert: Tonart) -> Tonartlage { + if erkannt == gehoert { + Tonartlage::Gleich + } else if erkannt.passt_zu(&gehoert) { + Tonartlage::Verwandt + } else { + Tonartlage::Daneben + } +} + +/// Wie das Tempo zum gehörten steht. +#[derive(Debug, Clone, Copy, PartialEq)] +enum Tempolage { + Genau, + /// Doppelt oder halb so schnell — der klassische Oktavfehler. + Oktave, + Daneben, +} + +fn tempolage(erkannt: f32, gehoert: f32) -> Tempolage { + if (erkannt - gehoert).abs() <= BPM_GENAU { + return Tempolage::Genau; + } + for faktor in [0.5f32, 2.0, 0.25, 4.0, 1.0 / 3.0, 3.0] { + if (erkannt - gehoert * faktor).abs() <= BPM_GENAU { + return Tempolage::Oktave; + } + } + Tempolage::Daneben +} + +#[derive(Default)] +struct Summe { + tracks: usize, + unlesbar: usize, + tempo: BTreeMap<&'static str, usize>, + tonart: BTreeMap<&'static str, usize>, + /// Abstände erkannter zu gehörten Grenzen, in Beats. + grenzen: Vec, + /// Wie oft der Name am Abschnittsmittelpunkt übereinstimmte. + namen_treffer: usize, + namen_gesamt: usize, + /// Erkannte Grenzen ohne gehörtes Gegenstück. + ueberzaehlig: usize, + /// Gehörte Grenzen ohne erkanntes Gegenstück. + fehlend: usize, +} + +impl Summe { + fn bericht(&self) { + println!("══ Zusammen ══════════════════════════════════"); + println!(" {} Tracks gelesen", self.tracks); + if self.unlesbar > 0 { + println!(" ⚠ {} nicht lesbar", self.unlesbar); + } + + if !self.tempo.is_empty() { + let zeile: Vec = self + .tempo + .iter() + .map(|(k, v)| format!("{v}× {k}")) + .collect(); + println!(" Tempo: {}", zeile.join(", ")); + } + if !self.tonart.is_empty() { + let zeile: Vec = self + .tonart + .iter() + .map(|(k, v)| format!("{v}× {k}")) + .collect(); + println!(" Tonart: {}", zeile.join(", ")); + } + + if !self.grenzen.is_empty() { + let mut s: Vec = self.grenzen.iter().map(|d| d.abs()).collect(); + s.sort_by(f64::total_cmp); + let median = s[s.len() / 2]; + let groesste = s.last().copied().unwrap_or(0.0); + println!( + " Grenzen: {} zugeordnet, Median {median:.1} Beats daneben, größter Abstand {groesste:.1}", + s.len() + ); + } + if self.fehlend > 0 || self.ueberzaehlig > 0 { + println!( + " {} gehörte Grenzen ohne Fund, {} gefundene ohne Gegenstück", + self.fehlend, self.ueberzaehlig + ); + } + if self.namen_gesamt > 0 { + println!( + " Namen: {} von {} Abschnitten stimmen überein", + self.namen_treffer, self.namen_gesamt + ); + } + + println!(); + println!(" Das sind Abweichungen, keine Fehler. Eine Angabe von Hand ist"); + println!(" eine Meinung — zwei Leute setzen den Anfang eines Drops eine"); + println!(" Phrase auseinander, und beide haben recht."); + } +} + +/// Der gemeinsame Versatz aller Grenzen, falls es einen gibt. +/// +/// **Ein konstanter Abstand ist eine Tatsache, keine sechs.** Liegen alle +/// Grenzen um dasselbe daneben, sagt das etwas über den Nullpunkt — den Anker +/// des Beatgrids gegen den Dateianfang — und nichts über die Segmentierung. +/// Streuen sie, ist es umgekehrt. Beides in einer Spalte übereinander zu +/// stapeln, verdeckt genau den Unterschied, auf den es ankommt. +/// +/// Gibt `(Versatz, größter Rest danach)` zurück, oder `None`, wenn kein +/// nennenswerter gemeinsamer Anteil da ist. +fn versatz(abweichungen: &[f64]) -> Option<(f64, f64)> { + if abweichungen.len() < 3 { + return None; + } + let mut s = abweichungen.to_vec(); + s.sort_by(f64::total_cmp); + let median = s[s.len() / 2]; + if median.abs() < 0.25 { + return None; + } + let rest = s.iter().map(|d| (d - median).abs()).fold(0.0f64, f64::max); + // Streuen sie stärker, als sie gemeinsam verschoben sind, ist es kein + // Versatz, sondern Rauschen. + (rest < median.abs()).then_some((median, rest)) +} + +fn ein_track(w: &Wahrheit, store: &Store, summe: &mut Summe) -> Result<()> { + let track = Track::decode_file(&w.datei) + .with_context(|| format!("konnte {} nicht dekodieren", w.datei.display()))?; + let (analyse, gerechnet) = analyze_cached(&track, store); + if gerechnet { + store.save(&analyse).ok(); + } + summe.tracks += 1; + + println!("── {} ──", w.datei.display()); + + // ── Tempo ── + if let Some(gehoert) = w.bpm { + match analyse.bpm { + Some(erkannt) => { + let lage = tempolage(erkannt, gehoert); + let name = match lage { + Tempolage::Genau => "genau", + Tempolage::Oktave => "Oktavfehler", + Tempolage::Daneben => "daneben", + }; + *summe.tempo.entry(name).or_default() += 1; + println!( + " Tempo {erkannt:7.2} gegen {gehoert:7.2} gehört {:+.2} {}", + erkannt - gehoert, + match lage { + Tempolage::Genau => "✓", + Tempolage::Oktave => "⚠ Oktavfehler", + Tempolage::Daneben => "⚠", + } + ); + } + None => { + *summe.tempo.entry("nicht erkannt").or_default() += 1; + println!(" Tempo nicht erkannt, gehört {gehoert:.2}"); + } + } + } + + // ── Tonart ── + if let Some(gehoert) = w.tonart { + match analyse.tonart() { + Some(erkannt) => { + let lage = tonartlage(erkannt, gehoert); + let name = match lage { + Tonartlage::Gleich => "gleich", + Tonartlage::Verwandt => "verwandt", + Tonartlage::Daneben => "daneben", + }; + *summe.tonart.entry(name).or_default() += 1; + println!( + " Tonart {:<4} ({}) gegen {} ({}) gehört {}", + erkannt.name(), + erkannt.camelot(), + gehoert.name(), + gehoert.camelot(), + match lage { + Tonartlage::Gleich => "✓", + Tonartlage::Verwandt => "~ verwandt, mischbar", + Tonartlage::Daneben => "⚠", + } + ); + } + None => { + *summe.tonart.entry("nicht erkannt").or_default() += 1; + println!(" Tonart nicht erkannt, gehört {}", gehoert.name()); + } + } + } + + if !w.abschnitte.is_empty() { + abschnitte_vergleichen(w, &analyse, summe); + } + println!(); + Ok(()) +} + +fn abschnitte_vergleichen(w: &Wahrheit, analyse: &analysis::Analysis, summe: &mut Summe) { + let Some(s) = analyse.struktur() else { + println!( + " Gliederung keine erkannt, gehört wurden {} Abschnitte", + w.abschnitte.len() + ); + summe.fehlend += w.abschnitte.len(); + summe.namen_gesamt += w.abschnitte.len(); + return; + }; + + let rate = analyse.sample_rate as f64; + let je_beat = 60.0 / analyse.bpm.unwrap_or(120.0) as f64; + let toleranz = ZUORDNUNG_PHRASEN * audio_core::PHRASE_BEATS * je_beat; + let ende = analyse.duration_secs; + + println!( + " Gliederung {} erkannt gegen {} gehört", + s.abschnitte.len(), + w.abschnitte.len() + ); + + let mut getroffen = vec![false; s.abschnitte.len()]; + let mut abweichungen: Vec = Vec::new(); + for (i, (art, beginn)) in w.abschnitte.iter().enumerate() { + // Der Name wird in der **Mitte** verglichen, nicht an der Grenze: Dort + // steht man mit einem Fuß im nächsten Abschnitt, und ein Vergleich auf + // der Kante misst die Kante statt den Abschnitt. + let bis = w + .abschnitte + .get(i + 1) + .map(|(_, t)| *t) + .unwrap_or(ende) + .max(*beginn); + let mitte = (beginn + bis) / 2.0; + let erkannt_hier = s.bei_frames((mitte * rate) as u64).map(|a| a.art); + + summe.namen_gesamt += 1; + let name_passt = erkannt_hier == Some(*art); + if name_passt { + summe.namen_treffer += 1; + } + + // Nächste erkannte Grenze zu diesem Anfang. + let naechste = s + .abschnitte + .iter() + .enumerate() + .map(|(k, a)| (k, a.von_frames as f64 / rate - beginn)) + .min_by(|a, b| a.1.abs().total_cmp(&b.1.abs())); + + match naechste.filter(|(_, ab)| ab.abs() <= toleranz) { + Some((k, ab)) => { + getroffen[k] = true; + summe.grenzen.push(ab / je_beat); + abweichungen.push(ab / je_beat); + println!( + " {:>7.1}s {:<6} Grenze {:+.1}s ({:+.1} Beats) Name {}", + beginn, + art.name(), + ab, + ab / je_beat, + if name_passt { + "✓".to_string() + } else { + format!( + "⚠ erkannt als {}", + erkannt_hier.map(|a| a.name()).unwrap_or("nichts") + ) + } + ); + } + None => { + summe.fehlend += 1; + println!( + " {:>7.1}s {:<6} ⚠ keine Grenze in der Nähe (±{:.0}s)", + beginn, + art.name(), + toleranz + ); + } + } + } + + if let Some((versatz, rest)) = versatz(&abweichungen) { + println!(" ── Davon systematisch: {versatz:+.1} Beats auf allen Grenzen ──"); + println!(" Das ist der Nullpunkt des Beatgrids, nicht die Segmentierung: Der"); + println!(" Anker liegt auf dem ersten erkannten Schlag, die Wahrheitsdatei"); + println!(" zählt ab Dateianfang. Relativ zueinander liegen die Grenzen um"); + println!(" höchstens {rest:.1} Beats daneben."); + } + + // Was gefunden wurde, ohne dass jemand dort etwas gehört hat. Der erste + // Abschnitt zählt nicht mit: Sein Anfang ist der Einstiegspunkt und keine + // Grenze zwischen zwei Teilen. + let ueber = getroffen.iter().skip(1).filter(|t| !**t).count(); + if ueber > 0 { + summe.ueberzaehlig += ueber; + println!(" {ueber} erkannte Grenzen ohne gehörtes Gegenstück"); + } +} + +// ── Aufruf ─────────────────────────────────────────────────────────────── + +struct Optionen { + wahrheit: PathBuf, + cache: PathBuf, +} + +fn argumente() -> Result { + let mut wahrheit = None; + let mut cache = PathBuf::from(DEFAULT_CACHE); + + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "-h" | "--help" => { + hilfe(); + std::process::exit(0); + } + "--cache" => { + cache = PathBuf::from(args.next().context("--cache braucht ein Verzeichnis")?) + } + "--wahrheit" => { + wahrheit = Some(PathBuf::from( + args.next().context("--wahrheit braucht eine Datei")?, + )) + } + andere => wahrheit = Some(PathBuf::from(andere)), + } + } + + let Some(wahrheit) = wahrheit else { + hilfe(); + bail!("keine Wahrheitsdatei angegeben"); + }; + if !wahrheit.exists() { + bail!("{} gibt es nicht", wahrheit.display()); + } + Ok(Optionen { wahrheit, cache }) +} + +fn hilfe() { + println!("Aufruf: musik-pruefstand [--wahrheit] [--cache ]"); + println!(); + println!("Legt die Analyse neben das, was ein Mensch gehört hat, und meldet den"); + println!("Abstand. Die Wahrheitsdatei liegt neben der Musik, eine Zeile je Track:"); + println!(); + println!(" # was ich höre"); + println!(" Nachtschicht.mp3 bpm 124 tonart Am intro 0:00 drop 1:04 outro 5:12"); + println!(" Alpenglühen.wav bpm 126"); + println!(); + println!("Abschnitte stehen mit ihrem Anfang, nicht als Bereich — das Ende ist der"); + println!("Anfang des nächsten. Jede Angabe ist einzeln freiwillig."); + println!(); + println!("Gemeldet werden Abweichungen, keine Fehler: Eine Angabe von Hand ist eine"); + println!("Meinung. Ab wann etwas kaputt ist, entscheidet, wer zuhört."); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn eine_volle_zeile_wird_gelesen() { + let w = + zeile_lesen("Nachtschicht.mp3 bpm 124 tonart Am intro 0:00 drop 1:04 outro 5:12") + .expect("lesbar") + .expect("keine Leerzeile"); + assert_eq!(w.datei, PathBuf::from("Nachtschicht.mp3")); + assert_eq!(w.bpm, Some(124.0)); + assert_eq!(w.tonart, Tonart::parse("Am")); + assert_eq!( + w.abschnitte, + vec![(Art::Intro, 0.0), (Art::Drop, 64.0), (Art::Outro, 312.0)] + ); + } + + /// Wer nur das Tempo weiß, schreibt nur das Tempo. + #[test] + fn jede_angabe_ist_einzeln_freiwillig() { + let w = zeile_lesen("a.wav bpm 126").unwrap().unwrap(); + assert_eq!(w.bpm, Some(126.0)); + assert!(w.tonart.is_none()); + assert!(w.abschnitte.is_empty()); + + let w = zeile_lesen("b.wav outro 2:00").unwrap().unwrap(); + assert!(w.bpm.is_none()); + assert_eq!(w.abschnitte, vec![(Art::Outro, 120.0)]); + } + + #[test] + fn kommentare_und_leerzeilen_zaehlen_nicht() { + assert_eq!(zeile_lesen("# was ich höre").unwrap(), None); + assert_eq!(zeile_lesen(" ").unwrap(), None); + } + + /// Zeiten in beiden Schreibweisen — und eine kaputte wird gemeldet, statt + /// still zu 0 zu werden. + #[test] + fn zeiten_gehen_als_minuten_und_als_sekunden() { + assert_eq!(zeit_lesen("1:04").unwrap(), 64.0); + assert_eq!(zeit_lesen("0:07.5").unwrap(), 7.5); + assert_eq!(zeit_lesen("64").unwrap(), 64.0); + assert_eq!(zeit_lesen("12:00").unwrap(), 720.0); + assert!(zeit_lesen("gleich").is_err()); + } + + /// Ein Tippfehler darf nicht als gültige Angabe durchgehen. + #[test] + fn unsinn_wird_gemeldet_statt_verschluckt() { + assert!(zeile_lesen("a.wav bpm").is_err(), "Wert fehlt"); + assert!(zeile_lesen("a.wav bpm schnell").is_err()); + assert!(zeile_lesen("a.wav tonart H7").is_err()); + assert!(zeile_lesen("a.wav refrain 1:00").is_err(), "kein Abschnitt"); + } + + /// Durcheinandergeratene Zeiten würden sonst still falsch verglichen. + #[test] + fn abschnitte_werden_sortiert() { + let w = zeile_lesen("a.wav outro 5:00 intro 0:00 drop 2:00") + .unwrap() + .unwrap(); + assert_eq!( + w.abschnitte, + vec![(Art::Intro, 0.0), (Art::Drop, 120.0), (Art::Outro, 300.0)] + ); + } + + /// Der Oktavfehler ist ein eigener Befund, keine große Abweichung: 87 statt + /// 174 ist etwas anderes als 87 statt 92. + #[test] + fn ein_oktavfehler_heisst_oktavfehler() { + assert_eq!(tempolage(128.0, 128.0), Tempolage::Genau); + assert_eq!(tempolage(128.2, 128.0), Tempolage::Genau); + assert_eq!(tempolage(64.0, 128.0), Tempolage::Oktave); + assert_eq!(tempolage(256.0, 128.0), Tempolage::Oktave); + assert_eq!(tempolage(85.0, 128.0), Tempolage::Daneben); + } + + /// Eine Paralleltonart zu treffen tut beim Mischen nicht weh. Das ist ein + /// anderer Befund als danebenzugreifen. + #[test] + fn eine_verwandte_tonart_ist_nicht_dasselbe_wie_daneben() { + let am = Tonart::parse("Am").unwrap(); + let c = Tonart::parse("C").unwrap(); + let fis = Tonart::parse("F#").unwrap(); + assert_eq!(tonartlage(am, am), Tonartlage::Gleich); + assert_eq!(tonartlage(c, am), Tonartlage::Verwandt); + assert_eq!(tonartlage(fis, am), Tonartlage::Daneben); + } + + /// Alle Grenzen um dasselbe daneben heißt: Der Nullpunkt sitzt anders. + /// Das ist ein Befund über den Anker und keiner über die Segmentierung. + #[test] + fn ein_gemeinsamer_versatz_wird_als_solcher_erkannt() { + let (v, rest) = versatz(&[1.0, 1.0, 1.0, 1.05, 0.95]).expect("kein Versatz gefunden"); + assert!((v - 1.0).abs() < 1e-9, "{v}"); + assert!(rest <= 0.05 + 1e-9, "{rest}"); + } + + /// Streuung ist kein Versatz. Sonst würde aus „die Segmentierung wackelt" + /// ein beruhigendes „nur der Nullpunkt". + #[test] + fn streuung_wird_nicht_zum_versatz_erklaert() { + assert_eq!(versatz(&[2.0, -2.0, 1.0, -1.5, 0.5]), None); + assert_eq!( + versatz(&[0.1, 0.0, -0.1]), + None, + "zu klein für eine Aussage" + ); + assert_eq!(versatz(&[1.0, 1.0]), None, "zwei sind kein Muster"); + } + + #[test] + fn pfade_gelten_relativ_zur_wahrheitsdatei() { + let ordner = std::env::temp_dir().join(format!("pruefstand-{}", std::process::id())); + std::fs::create_dir_all(&ordner).expect("Ordner"); + let pfad = ordner.join("wahrheit.txt"); + std::fs::write(&pfad, "# Kopf\nlied.wav bpm 120\n").expect("schreiben"); + + let w = wahrheit_lesen(&pfad).expect("lesbar"); + assert_eq!(w.len(), 1); + assert_eq!(w[0].datei, ordner.join("lied.wav")); + + std::fs::remove_dir_all(&ordner).ok(); + } +} diff --git a/crates/musik-cli/src/bin/musik-schneiden.rs b/crates/musik-cli/src/bin/musik-schneiden.rs new file mode 100644 index 0000000..a285698 --- /dev/null +++ b/crates/musik-cli/src/bin/musik-schneiden.rs @@ -0,0 +1,447 @@ +//! Eine lange Aufnahme in einzelne Lieder zerlegen. +//! +//! Wer den AUX-Eingang mitlaufen lässt, hat hinterher **eine** Datei mit einer +//! Stunde Musik darin. Die Anlage kann damit wenig anfangen: Ein Beatgrid gilt +//! je Track, eine Tonart auch, und die Gliederung sucht Intro und Outro eines +//! Stücks, nicht eines Abends. +//! +//! Also schneiden. Zwei Wege, und der erste ist der verlässliche: +//! +//! 1. **Die Lücke.** Zwischen zwei Liedern wird es kurz still. Das ist kein +//! Verfahren, sondern ein Ablesen — wo nichts ist, ist eine Grenze. +//! 2. **Der Klangwechsel.** Wo keine Lücke ist, bleibt der Vergleich der +//! Harmonik, derselbe, den auch der Kritiker benutzt. Der ist eine +//! Schätzung, und seine Grenzen stehen unten. +//! +//! # Wo das nicht trägt +//! +//! **Ein gemixtes Set lässt sich nicht sauber schneiden**, und zwar nicht aus +//! Mangel an Verfahren: Während einer Blende laufen zwei Lieder gleichzeitig. +//! Eine Grenze gibt es dort nicht, nur eine Stelle, an der man sie zieht. Der +//! Klangwechsel findet eine Blende über mehr als zwei Phrasen ohnehin nicht — +//! er vergleicht mit dem Klangbild von vor sechzehn Sekunden, und eine längere +//! Blende schließt diesen Vergleich in sich ein (siehe `docs/FAHRPLAN.md`, N3). +//! +//! Für eine Aufnahme von einer Platte, einer Playlist oder einem Stream mit +//! Pausen dazwischen trägt der erste Weg dagegen gut. + +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +use analysis::{Store, wechsel}; +use audio_core::track::{CHANNELS, Track}; + +/// Fensterlänge für den Pegel. +/// +/// Ein Zehntel reicht, um eine Lücke von einer Achtelpause zu unterscheiden, +/// und ist grob genug, dass ein einzelner Nulldurchgang nicht als Stille gilt. +const FENSTER_SEK: f64 = 0.1; + +/// Wie leise es sein muss, gemessen am **lauten Teil dieser Aufnahme**. +/// +/// Keine feste Zahl. Eine leise aufgenommene Platte wäre sonst durchgehend +/// „still", eine übersteuerte nie — und die Schwelle hätte mehr über den +/// Aufnahmepegel gesagt als über die Musik. Bezugspunkt ist das obere Zehntel +/// der Pegel; wer darunter um mehr als diesen Faktor liegt, ist still. +const STILL_ANTEIL: f32 = 0.02; + +/// Wie lange still, damit es als Lücke zählt. +/// +/// Eine halbe Sekunde hat auch mancher Break. Ab einer Sekunde ist es keine +/// Musik mehr, sondern ein Ende. +const LUECKE_SEK: f64 = 1.0; + +/// Kürzestes Stück, das als Lied durchgeht. +/// +/// Kürzeres ist Applaus, ein Jingle oder ein Fehlschnitt. Es wird nicht +/// weggeworfen, sondern gemeldet — wer eine Aufnahme voller kurzer Stücke hat, +/// soll das erfahren und nicht eine leere Ausbeute vorfinden. +const MIND_LIED_SEK: f64 = 40.0; + +struct Optionen { + aufnahme: PathBuf, + ordner: PathBuf, + /// Nur nachsehen, nichts schreiben. + probe: bool, +} + +fn main() -> Result<()> { + let opts = argumente()?; + let track = Track::decode_file(&opts.aufnahme) + .with_context(|| format!("{} ließ sich nicht lesen", opts.aufnahme.display()))?; + + println!("Aufnahme: {}", opts.aufnahme.display()); + println!( + " {}, {} Hz\n", + dauer(track.duration_secs()), + track.sample_rate + ); + + let pegel = pegelkurve(&track); + let schwelle = stilleschwelle(&pegel); + let luecken = luecken(&pegel, FENSTER_SEK, schwelle); + + let (schnitte, wie) = if !luecken.is_empty() { + println!( + "{} Lücke(n) gefunden — geschnitten wird daran.", + luecken.len() + ); + ( + luecken.iter().map(|(a, b)| (a + b) / 2.0).collect(), + "Lücke", + ) + } else { + println!("Keine Lücke gefunden. Bleibt der Klangwechsel — eine Schätzung."); + let gefunden = wechsel::finden(&track); + if gefunden.is_empty() { + println!("\nAuch kein Klangwechsel. Das ist ein Befund, keine Panne:"); + println!(" Entweder steht hier ein einziges Stück, oder die Übergänge sind"); + println!( + " länger als der Rückblick von {:.0} s und damit unsichtbar.", + wechsel::ABSTAND_SEK + ); + println!(" Geschnitten wird nichts — ein geratener Schnitt wäre schlechter"); + println!(" als keiner."); + return Ok(()); + } + println!("{} Klangwechsel gefunden.", gefunden.len()); + ( + gefunden.iter().map(|u| u.beginn).collect::>(), + "Klangwechsel", + ) + }; + + let stuecke: Vec<(f64, f64)> = stuecke(&schnitte, track.duration_secs()) + .into_iter() + .map(|(a, b)| beschneiden(&pegel, a, b, schwelle, FENSTER_SEK)) + .collect(); + let lang: Vec<&(f64, f64)> = stuecke + .iter() + .filter(|(a, b)| b - a >= MIND_LIED_SEK) + .collect(); + let kurz = stuecke.len() - lang.len(); + + println!( + "\n{} Stück(e), davon {} lang genug für ein Lied.", + stuecke.len(), + lang.len() + ); + if kurz > 0 { + println!( + " {kurz} unter {:.0} s übergangen — Applaus, Jingle oder Fehlschnitt.", + MIND_LIED_SEK + ); + } + if lang.is_empty() { + return Ok(()); + } + + if opts.probe { + println!("\n── Was geschnitten würde ({wie}) ────────────"); + for (i, (a, b)) in lang.iter().enumerate() { + println!( + " {:2} {} bis {} ({})", + i + 1, + dauer(*a), + dauer(*b), + dauer(b - a) + ); + } + println!("\nNichts geschrieben — `--probe` war gesetzt."); + return Ok(()); + } + + std::fs::create_dir_all(&opts.ordner) + .with_context(|| format!("{} ließ sich nicht anlegen", opts.ordner.display()))?; + let store = Store::new(opts.ordner.join(".analyse")); + + println!("\n── Geschnitten ({wie}) ──────────────────────"); + for (i, (a, b)) in lang.iter().enumerate() { + let ziel = opts.ordner.join(format!("{:02}.wav", i + 1)); + let von = (a * track.sample_rate as f64) as usize * CHANNELS; + let bis = ((b * track.sample_rate as f64) as usize * CHANNELS).min(track.samples.len()); + schreibe_wav(&ziel, &track.samples[von..bis], track.sample_rate) + .with_context(|| format!("{} ließ sich nicht schreiben", ziel.display()))?; + + // Gleich mit analysieren: Ein Stück ohne Grid ist für die Anlage nur + // eine Datei. Das Sidecar liegt daneben, die Sammlung findet es. + let stueck = Track::decode_file(&ziel)?; + let analyse = analysis::analyze(&stueck); + store.save(&analyse).ok(); + + println!( + " {:2} {} {} {} {}", + i + 1, + ziel.file_name().unwrap_or_default().to_string_lossy(), + dauer(b - a), + analyse + .bpm + .map(|b| format!("{b:6.2} BPM")) + .unwrap_or_else(|| " kein Grid".into()), + analyse.musical_key.as_deref().unwrap_or("—"), + ); + } + + println!("\nDie Sidecars liegen in {}.", store.root().display()); + println!("⚠ Geschnitten heißt nicht geprüft. Wo die Grenze wirklich lag, weiß"); + println!(" nur, wer die Aufnahme kennt — einmal hineinhören lohnt sich."); + Ok(()) +} + +/// Pegel in Fenstern von [`FENSTER_SEK`], als RMS. +fn pegelkurve(track: &Track) -> Vec { + let fenster = (FENSTER_SEK * track.sample_rate as f64) as usize * CHANNELS; + track + .samples + .chunks(fenster.max(1)) + .map(|t| { + if t.is_empty() { + return 0.0; + } + (t.iter().map(|x| x * x).sum::() / t.len() as f32).sqrt() + }) + .collect() +} + +/// Ab welchem Pegel es für **diese** Aufnahme still ist. +fn stilleschwelle(pegel: &[f32]) -> f32 { + let mut sortiert: Vec = pegel.iter().copied().filter(|w| w.is_finite()).collect(); + if sortiert.is_empty() { + return 0.0; + } + sortiert.sort_by(|a, b| a.total_cmp(b)); + let oben = sortiert[sortiert.len() * 9 / 10]; + oben * STILL_ANTEIL +} + +/// Zusammenhängende stille Bereiche von mindestens [`LUECKE_SEK`]. +/// +/// Am Anfang und am Ende zählt eine Stille **nicht** als Lücke: Dort trennt sie +/// nichts, sie steht nur davor oder dahinter. +fn luecken(pegel: &[f32], sek_je_wert: f64, schwelle: f32) -> Vec<(f64, f64)> { + let mindestens = (LUECKE_SEK / sek_je_wert).ceil() as usize; + let mut aus = Vec::new(); + let mut start: Option = None; + + for (i, &w) in pegel.iter().enumerate() { + if w <= schwelle { + start.get_or_insert(i); + } else if let Some(s) = start.take() + && i - s >= mindestens + && s > 0 + { + aus.push((s as f64 * sek_je_wert, i as f64 * sek_je_wert)); + } + } + aus +} + +/// Schiebt die Ränder eines Stücks über die Stille hinweg nach innen. +/// +/// Der Schnitt liegt in der Mitte der Lücke, also hängt an jedem Stück eine +/// halbe Lücke Stille. Gemessen: Jedes geschnittene Stück war genau eine +/// Sekunde länger als seine Vorlage. Das ist nicht bloß unschön — die +/// Gliederung sucht ein Intro, und eine Sekunde Stille am Anfang ist genau das, +/// wonach sie sucht. Ein Track ohne Intro bekäme hier einen angedichtet. +fn beschneiden(pegel: &[f32], von: f64, bis: f64, schwelle: f32, sek_je_wert: f64) -> (f64, f64) { + let last = pegel.len().saturating_sub(1); + let mut a = ((von / sek_je_wert) as usize).min(last); + let mut b = ((bis / sek_je_wert).ceil() as usize).min(pegel.len()); + + while a < b && pegel[a] <= schwelle { + a += 1; + } + while b > a && pegel[b - 1] <= schwelle { + b -= 1; + } + (a as f64 * sek_je_wert, b as f64 * sek_je_wert) +} + +/// Aus Schnittstellen werden Stücke: von 0 bis zum ersten Schnitt und so fort. +fn stuecke(schnitte: &[f64], ende: f64) -> Vec<(f64, f64)> { + let mut grenzen = vec![0.0]; + grenzen.extend(schnitte.iter().copied().filter(|s| *s > 0.0 && *s < ende)); + grenzen.push(ende); + grenzen.sort_by(|a, b| a.total_cmp(b)); + grenzen.windows(2).map(|w| (w[0], w[1])).collect() +} + +fn dauer(sekunden: f64) -> String { + let ganz = sekunden.max(0.0) as u64; + format!("{}:{:02}", ganz / 60, ganz % 60) +} + +fn schreibe_wav(pfad: &Path, pcm: &[f32], rate: u32) -> std::io::Result<()> { + let data_len = (pcm.len() * 2) as u32; + let mut out = BufWriter::new(File::create(pfad)?); + + out.write_all(b"RIFF")?; + out.write_all(&(36 + data_len).to_le_bytes())?; + out.write_all(b"WAVEfmt ")?; + out.write_all(&16u32.to_le_bytes())?; + out.write_all(&1u16.to_le_bytes())?; + out.write_all(&(CHANNELS as u16).to_le_bytes())?; + out.write_all(&rate.to_le_bytes())?; + out.write_all(&(rate * CHANNELS as u32 * 2).to_le_bytes())?; + out.write_all(&((CHANNELS * 2) as u16).to_le_bytes())?; + out.write_all(&16u16.to_le_bytes())?; + out.write_all(b"data")?; + out.write_all(&data_len.to_le_bytes())?; + + for sample in pcm { + out.write_all(&((sample.clamp(-1.0, 1.0) * 32_767.0) as i16).to_le_bytes())?; + } + out.flush() +} + +fn argumente() -> Result { + let mut aufnahme = None; + let mut ordner = None; + let mut probe = false; + + for arg in std::env::args().skip(1) { + match arg.as_str() { + "--help" | "-h" => { + hilfe(); + std::process::exit(0); + } + "--probe" => probe = true, + _ if aufnahme.is_none() => aufnahme = Some(PathBuf::from(arg)), + _ if ordner.is_none() => ordner = Some(PathBuf::from(arg)), + _ => bail!("zu viele Argumente: {arg}"), + } + } + + let aufnahme = aufnahme.context("Aufruf: musik-schneiden [--probe]")?; + let ordner = match ordner { + Some(o) => o, + None if probe => PathBuf::new(), + None => bail!("Aufruf: musik-schneiden [--probe]"), + }; + Ok(Optionen { + aufnahme, + ordner, + probe, + }) +} + +fn hilfe() { + println!("Aufruf: musik-schneiden [--probe]"); + println!(); + println!("Zerlegt eine lange Aufnahme in einzelne Lieder, schreibt sie in"); + println!(" und analysiert jedes gleich mit — Tempo, Tonart,"); + println!("Gliederung liegen danach als Sidecar daneben."); + println!(); + println!("Geschnitten wird an Lücken. Wo keine sind, am Klangwechsel, und"); + println!("das ist eine Schätzung: Ein gemixtes Set hat zwischen zwei Liedern"); + println!("keine Grenze, sondern eine Überlagerung."); + println!(); + println!("--probe zeigt die Schnitte, schreibt aber nichts."); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn eine_stille_mitten_drin_ist_eine_luecke() { + // Laut, still, laut — ein Schnitt in der Mitte der Stille. + let mut p = vec![0.5f32; 300]; + for w in p.iter_mut().skip(100).take(20) { + *w = 0.0; + } + let l = luecken(&p, 0.1, 0.01); + assert_eq!(l.len(), 1); + assert!((l[0].0 - 10.0).abs() < 0.01, "{:?}", l[0]); + assert!((l[0].1 - 12.0).abs() < 0.01, "{:?}", l[0]); + } + + /// **Stille am Anfang trennt nichts.** Fast jede Aufnahme fängt still an; + /// wer das als Lücke zählt, bekommt ein erstes Stück aus Nichts. + #[test] + fn stille_am_anfang_ist_keine_luecke() { + let mut p = vec![0.5f32; 300]; + for w in p.iter_mut().take(50) { + *w = 0.0; + } + assert!(luecken(&p, 0.1, 0.01).is_empty()); + } + + /// Eine kurze Pause ist Musik, kein Ende. + #[test] + fn eine_kurze_pause_ist_keine_luecke() { + let mut p = vec![0.5f32; 300]; + for w in p.iter_mut().skip(100).take(5) { + *w = 0.0; + } + assert!(luecken(&p, 0.1, 0.01).is_empty()); + } + + /// **Die Schwelle hängt an der Aufnahme, nicht an einer festen Zahl.** + /// Dieselbe Musik, einmal leise aufgenommen, muss dieselbe Lücke ergeben. + #[test] + fn eine_leise_aufnahme_hat_dieselben_luecken_wie_eine_laute() { + let mut laut = vec![0.5f32; 300]; + for w in laut.iter_mut().skip(100).take(20) { + *w = 0.0; + } + let leise: Vec = laut.iter().map(|w| w * 0.02).collect(); + + let a = luecken(&laut, 0.1, stilleschwelle(&laut)); + let b = luecken(&leise, 0.1, stilleschwelle(&leise)); + assert_eq!(a.len(), 1); + assert_eq!(a.len(), b.len()); + assert!((a[0].0 - b[0].0).abs() < 1e-6); + } + + /// **Die halbe Lücke bleibt nicht am Lied kleben.** + /// + /// Gemessen an drei aneinandergehängten Stücken mit zwei Sekunden Pause: + /// Ohne das Beschneiden war jedes Ergebnis genau eine Sekunde länger als + /// seine Vorlage — vorn und hinten die halbe Lücke. + #[test] + fn die_raender_werden_von_der_stille_befreit() { + let mut p = vec![0.5f32; 300]; + for w in p.iter_mut().take(10) { + *w = 0.0; + } + for w in p.iter_mut().skip(290) { + *w = 0.0; + } + let (a, b) = beschneiden(&p, 0.0, 30.0, 0.01, 0.1); + assert!((a - 1.0).abs() < 0.01, "vorn: {a}"); + assert!((b - 29.0).abs() < 0.01, "hinten: {b}"); + } + + /// Ein durchweg stilles Stück wird nicht zu einem negativen. + #[test] + fn ein_stilles_stueck_schrumpft_auf_nichts_und_nicht_darunter() { + let p = vec![0.0f32; 100]; + let (a, b) = beschneiden(&p, 0.0, 10.0, 0.01, 0.1); + assert!(b >= a, "{a} bis {b}"); + } + + #[test] + fn aus_schnitten_werden_stuecke() { + let s = stuecke(&[60.0, 180.0], 300.0); + assert_eq!(s.len(), 3); + assert_eq!(s[0], (0.0, 60.0)); + assert_eq!(s[1], (60.0, 180.0)); + assert_eq!(s[2], (180.0, 300.0)); + } + + /// Ohne Schnitt bleibt genau ein Stück — und nicht null. + #[test] + fn ohne_schnitt_bleibt_ein_stueck() { + assert_eq!(stuecke(&[], 300.0), vec![(0.0, 300.0)]); + } + + /// Ein Schnitt hinter dem Ende darf kein leeres Stück erzeugen. + #[test] + fn ein_schnitt_ausserhalb_wird_verworfen() { + assert_eq!(stuecke(&[400.0], 300.0), vec![(0.0, 300.0)]); + } +} diff --git a/crates/musik-cli/src/bin/musik-streuung.rs b/crates/musik-cli/src/bin/musik-streuung.rs new file mode 100644 index 0000000..d7008a0 --- /dev/null +++ b/crates/musik-cli/src/bin/musik-streuung.rs @@ -0,0 +1,615 @@ +//! N3 — die Schätzung des Kritikers gegen die Wahrheit der Anlage. +//! +//! Der Kritiker hört einen Mitschnitt und sagt, wann der Übergang begann. Die +//! Mitschrift weiß es. Einmal gemessen ergab das **3,7 Sekunden zu spät** — ein +//! Wert, aus dem sich nichts folgern lässt: Er könnte der Regelfall sein oder +//! ein Ausreißer, und ob er an der Länge der Blende hängt, am Griff oder am +//! Material, sagt eine einzelne Zahl nicht. +//! +//! Also viele Sets. Jedes wird **durch die echte Anlage gefahren** — echter +//! Mixer, echtes Pult, echtes Repertoire, echter Mitschnitt, echte Mitschrift +//! —, und dann bekommt der Kritiker die Datei, so wie er sie sonst bekommt. +//! Die Wahrheit ist der Frame, in dem der Crossfader zum ersten Mal bewegt +//! wurde; sie steht in der Mitschrift, weil die Anlage sie dort hinschreibt. +//! +//! # Was das nicht ist +//! +//! **Keine Aussage über Musik.** Das Material ist gebaut: Kick, Bass, ein +//! Akkord. Zwei Stücke unterscheiden sich in der Harmonik deutlicher, als +//! zwei Tracks einer Platte es oft tun — und der Kritiker sucht genau danach. +//! Die Streuung, die hier herauskommt, ist deshalb eher die **untere Schranke** +//! des Fehlers als sein Erwartungswert. +//! +//! Was sie trotzdem trägt: ob der Fehler ein Vorzeichen hat, ob er mit der +//! Länge des Übergangs wächst, und welcher Griff dem Kritiker Mühe macht. Das +//! sind Aussagen über das Verfahren, und dafür reicht gebautes Material. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; + +use analysis::wechsel; +use audio_core::deck::{DeckState, Voice}; +use audio_core::{Beatgrid, Track}; +use audio_engine::{Assign, DeckSource, Engine, EngineRunner, SilentSource}; +use control::mitschrift; +use control::protokoll::{Sitzung, behandle}; +use control::pult::{DeckEintrag, KanalSpiegel, Steuerpult}; +use control::zeitplan::takt; + +const RATE: u32 = 48_000; +const BLOCK: usize = 256; + +/// Wie lange Deck 1 allein läuft, bevor der Griff kommt. +/// +/// Der Kritiker vergleicht mit dem Klangbild von vor [`wechsel::ABSTAND_SEK`]. +/// Läge der Übergang früher, vergliche er ihn mit dem Anlauf statt mit dem +/// ausgehenden Track — der Fehler wäre dann einer des Aufbaus, nicht des +/// Verfahrens. +const VORLAUF_SEK: f64 = 24.0; + +/// Die Auflösung des Kritikers: ein Fenster. +/// +/// Genauer als das kann keine Schätzung sein, und eine Unschärfe unter einem +/// Fenster ist deshalb keine Aussage. +const FENSTER: f64 = wechsel::FENSTER_SEK; + +/// Wie lange nach dem Griff weitergelaufen wird. +const NACHLAUF_SEK: f64 = 20.0; + +/// Ein Fall: ein Griff über so viele Beats, bei diesem Tempo. +struct Fall { + griff: &'static str, + beats: u32, + bpm: f64, + /// Halbtöne, um die der eingehende Track verschoben ist. + /// + /// Bei 0 wäre der Kritiker blind — er sucht eine Änderung der Harmonik. + /// Sieben Halbtöne sind eine Quinte, also der harmonisch *nächste* + /// Nachbar: der schwerste Fall, den ein DJ absichtlich herstellt. + versatz: i32, +} + +/// Das Ergebnis eines Falls. +struct Messung { + fall: usize, + wahrheit: f64, + geschaetzt: Option, + unschaerfe: f64, +} + +impl Messung { + fn fehler(&self) -> Option { + self.geschaetzt.map(|g| g - self.wahrheit) + } +} + +fn faelle() -> Vec { + let mut aus = Vec::new(); + for (griff, beats) in [ + ("schnitt", 0u32), + ("bassswap", 16), + ("filter", 16), + ("blende", 16), + ("blende", 32), + ("blende", 64), + ] { + for (bpm, versatz) in [(128.0, 5), (128.0, 7), (100.0, 5), (140.0, 7)] { + aus.push(Fall { + griff, + beats, + bpm, + versatz, + }); + } + } + aus +} + +fn main() -> Result<()> { + let ordner = argumente()?; + std::fs::create_dir_all(&ordner) + .with_context(|| format!("{} ließ sich nicht anlegen", ordner.display()))?; + + let liste = faelle(); + println!( + "musik-streuung — {} Sets durch die echte Anlage\n", + liste.len() + ); + + let mut messungen = Vec::new(); + for (i, fall) in liste.iter().enumerate() { + let wav = ordner.join(format!("set{i:02}.wav")); + print!( + " {:2}/{} {} über {} Beats bei {:.0} BPM, +{} Halbtöne … ", + i + 1, + liste.len(), + fall.griff, + fall.beats, + fall.bpm, + fall.versatz + ); + use std::io::Write; + std::io::stdout().flush().ok(); + + match fahren(fall, &wav) { + Ok(wahrheit) => { + let m = messen(i, &wav, wahrheit)?; + match m.fehler() { + Some(f) => println!("Wahrheit {wahrheit:.2} s, geschätzt {:+.2} s", f), + None => println!("Wahrheit {wahrheit:.2} s, **nicht gefunden**"), + } + messungen.push(m); + } + Err(e) => println!("kein Set: {e}"), + } + } + + bericht(&liste, &messungen); + Ok(()) +} + +/// Fährt ein Set durch die echte Anlage und gibt die Wahrheit zurück. +/// +/// Die Wahrheit ist der Zeitpunkt, an dem der Crossfader **zum ersten Mal** +/// bewegt wurde — nicht der, an dem der Befehl kam. Zwischen beiden liegt bei +/// jedem Griff außer dem Schnitt eine Phrase Wartezeit, und der Kritiker kann +/// vom Befehl nichts wissen: Er hört nur, was am Regler geschah. +fn fahren(fall: &Fall, wav: &Path) -> Result { + let (mut pult, mut runner) = anlage(fall)?; + + // Erst der Mitschnitt, dann die Musik: Was vor dem Start läuft, steht in + // keiner Datei, und die Zeitrechnung der Mitschrift beginnt hier. + fuehren(&mut pult, &format!("do master.record {}", wav.display()))?; + for zeile in [ + "set channel1.fader 1", + "set channel2.fader 1", + "set master.crossfader -1", + "set deck1.play 1", + ] { + fuehren(&mut pult, zeile)?; + } + + laufen(&mut pult, &mut runner, VORLAUF_SEK); + fuehren( + &mut pult, + &format!("do master.uebergang {} {}", fall.griff, fall.beats), + )?; + + // So lange, dass auch die längste Blende samt Nachlauf durch ist. + let dauer = fall.beats as f64 * 60.0 / fall.bpm + NACHLAUF_SEK; + laufen(&mut pult, &mut runner, dauer); + // **Ohne diese Prüfung misst der ganze Prüfstand Unsinn.** Beim ersten + // Lauf rannte die Schleife hier so viel schneller als die Wiedergabe, dass + // der Ring überlief: 1,2 von 2,1 Millionen Frames verworfen, also mehr als + // die Hälfte des Mitschnitts. Die Datei war trotzdem lesbar, die Mitschrift + // passte zu ihr, und die Zahlen sahen aus wie Messwerte — sie waren keine. + let verworfen = zahl(&fuehren(&mut pult, "get master.record_dropped")?); + if verworfen > 0.0 { + bail!("{verworfen:.0} Frames verworfen — der Mitschnitt hat Lücken"); + } + fuehren(&mut pult, "do master.record_stop")?; + + // Der Schreiber-Thread hängt am Mitschnitt-Ring; ohne ein paar Blöcke + // mehr wäre die Datei noch offen, wenn wir sie gleich lesen. + laufen(&mut pult, &mut runner, 0.5); + drop(pult); + drop(runner); + warten_auf(wav)?; + + let protokoll = mitschrift::lesen(&wav.with_extension(mitschrift::ENDUNG)) + .map_err(|e| anyhow::anyhow!("Mitschrift: {e}"))?; + erste_bewegung(&protokoll).context("in der Mitschrift bewegte sich der Crossfader nie") +} + +/// Wann sich der Crossfader **wirklich bewegt hat**, in Sekunden. +/// +/// Drei Zeilen sprechen in der Mitschrift vom Crossfader, und nur eine davon +/// ist gemeint: +/// +/// ```text +/// 0 0.000 > set master.crossfader -1 ← das Einrichten +/// 1152000 24.000 > in deck1 phrase ramp master.crossfader 1 16 ← die Bestellung +/// 1440000 30.000 > ramp master.crossfader 1 16 weich deck1 ← die Bewegung +/// ``` +/// +/// Beide Irrwege sind gegangen worden. Das Einrichten ergab Sekunde 0 und +/// damit einen Abstand zum Dateianfang. Die Bestellung sah richtig aus — 24,00 +/// Sekunden, sauber, überall gleich — und war es nicht: Zwischen ihr und der +/// Bewegung liegt die Wartezeit auf die Phrase, hier sechs Sekunden. Gemessen +/// hätte man dem Kritiker vier Sekunden Verspätung angeschrieben, die in +/// Wahrheit sein Vorsprung waren. +/// +/// Die Bewegung erkennt man daran, dass die Zeile **mit dem Befehl anfängt**. +/// Was mit `in` beginnt, ist eine Bestellung; was der Plan quittiert, geht in +/// die andere Richtung. +fn erste_bewegung(p: &mitschrift::Protokoll) -> Option { + let ab = p + .ereignisse + .iter() + .position(|e| e.text.contains("uebergang"))?; + p.ereignisse + .iter() + .skip(ab) + .find(|e| { + e.text.starts_with("set master.crossfader") + || e.text.starts_with("ramp master.crossfader") + }) + .map(|e| e.sekunden(p.kopf.rate)) +} + +/// Lässt den Kritiker über den Mitschnitt gehen. +fn messen(fall: usize, wav: &Path, wahrheit: f64) -> Result { + let track = Track::decode_file(wav) + .with_context(|| format!("{} ließ sich nicht lesen", wav.display()))?; + let gefunden = wechsel::finden(&track); + + // Der nächstgelegene — nicht der erste. Ein Set kann mehr als einen + // Ausschlag haben, und der Kritiker weiß nicht, welcher gemeint ist. + let naechster = gefunden.iter().min_by(|a, b| { + (a.beginn - wahrheit) + .abs() + .total_cmp(&(b.beginn - wahrheit).abs()) + }); + + Ok(Messung { + fall, + wahrheit, + geschaetzt: naechster.map(|u| u.beginn), + unschaerfe: naechster.map(|u| u.unschaerfe).unwrap_or(0.0), + }) +} + +fn bericht(faelle: &[Fall], messungen: &[Messung]) { + let mit: Vec<&Messung> = messungen.iter().filter(|m| m.fehler().is_some()).collect(); + println!("\n── Die Verteilung ───────────────────────────"); + println!( + " {} von {} Übergängen hat der Kritiker überhaupt gefunden.", + mit.len(), + messungen.len() + ); + if mit.is_empty() { + println!(" Ohne Fund keine Verteilung."); + return; + } + + let mut fehler: Vec = mit.iter().filter_map(|m| m.fehler()).collect(); + fehler.sort_by(|a, b| a.total_cmp(b)); + let median = fehler[fehler.len() / 2]; + let mittel = fehler.iter().sum::() / fehler.len() as f64; + let betrag = fehler.iter().map(|f| f.abs()).sum::() / fehler.len() as f64; + + println!(" Median {median:+.2} s, Mittel {mittel:+.2} s, im Betrag {betrag:.2} s"); + println!( + " Spanne {:+.2} bis {:+.2} s", + fehler.first().copied().unwrap_or(0.0), + fehler.last().copied().unwrap_or(0.0) + ); + println!(" Ein Minus heißt: zu früh geschätzt, ein Plus: zu spät."); + + // Der Kritiker gibt zu jedem Fund eine Unschärfe an. Ob sie etwas wert + // ist, entscheidet sich hier: Eine Fehlerspanne, die den Fehler nicht + // deckt, ist schlimmer als keine — sie beruhigt. + let gedeckt = mit + .iter() + .filter(|m| { + m.fehler() + .is_some_and(|f| f.abs() <= m.unschaerfe.max(FENSTER)) + }) + .count(); + println!( + " Die angegebene Unschärfe deckt den Fehler in {gedeckt} von {} Fällen.", + mit.len() + ); + + let fehlend = messungen.len() - mit.len(); + if fehlend > 0 { + println!("\n {fehlend} Übergänge blieben unbemerkt. Der Kritiker vergleicht mit dem"); + println!( + " Klangbild von vor {:.0} s — dauert eine Blende länger, liegt der Vergleich", + wechsel::ABSTAND_SEK + ); + println!(" selbst schon mitten in ihr, und der Unterschied reißt die Schwelle nie."); + } + + println!("\n── Nach Länge des Übergangs ─────────────────"); + let mut laengen: Vec = faelle.iter().map(|f| f.beats).collect(); + laengen.sort_unstable(); + laengen.dedup(); + for beats in laengen { + let teil: Vec = mit + .iter() + .filter(|m| faelle[m.fall].beats == beats) + .filter_map(|m| m.fehler()) + .collect(); + if teil.is_empty() { + println!(" {beats:>2} Beats: nichts gefunden"); + continue; + } + let mittel = teil.iter().sum::() / teil.len() as f64; + let unschaerfe: f64 = mit + .iter() + .filter(|m| faelle[m.fall].beats == beats) + .map(|m| m.unschaerfe) + .sum::() + / teil.len() as f64; + println!( + " {beats:>2} Beats: {mittel:+.2} s im Mittel über {} Sets, Unschärfe {unschaerfe:.1} s", + teil.len() + ); + } + + println!("\n── Nach Griff ───────────────────────────────"); + let mut griffe: Vec<&str> = faelle.iter().map(|f| f.griff).collect(); + griffe.sort_unstable(); + griffe.dedup(); + for griff in griffe { + let teil: Vec = mit + .iter() + .filter(|m| faelle[m.fall].griff == griff) + .filter_map(|m| m.fehler()) + .collect(); + let gesamt = faelle.iter().filter(|f| f.griff == griff).count(); + if teil.is_empty() { + println!(" {griff:<9}: 0 von {gesamt} gefunden"); + continue; + } + let mittel = teil.iter().sum::() / teil.len() as f64; + println!( + " {griff:<9}: {mittel:+.2} s im Mittel, {} von {gesamt} gefunden", + teil.len() + ); + } +} + +/// Baut die Anlage: zwei Decks mit echtem Material, Mixer, Pult, Mitschnitt. +fn anlage(fall: &Fall) -> Result<(Steuerpult, EngineRunner)> { + let mut engine = Engine::new(RATE as f32); + let laenge = VORLAUF_SEK + fall.beats as f64 * 60.0 / fall.bpm + NACHLAUF_SEK + 30.0; + + let mut kanaele = Vec::new(); + let mut zustaende = Vec::new(); + for (name, assign, versatz) in [ + ("DECK A", Assign::A, 0), + ("DECK B", Assign::B, fall.versatz), + ] { + let state = Arc::new(DeckState::new()); + let track = Arc::new(material(fall.bpm, versatz, laenge)); + let voice = Voice::new(track, Arc::clone(&state)); + let kanal = engine.add_channel(name, Box::new(DeckSource::new(voice))); + engine.channel(kanal).set_assign(assign); + kanaele.push((name, kanal, assign)); + zustaende.push(state); + } + let aux = engine.add_channel("AUX", Box::new(SilentSource)); + engine.channel(aux).set_assign(Assign::Thru); + + let (tap, aufnahme) = audio_engine::mitschnitt(RATE); + engine.set_mitschnitt(tap); + + let (handle, runner) = audio_engine::engine_channel(engine, 256); + let mut pult = Steuerpult::neu(handle); + + for ((name, kanal, assign), state) in kanaele.iter().zip(zustaende) { + pult.kanal_hinzufuegen(KanalSpiegel::neu(*name, *assign)); + let mut eintrag = DeckEintrag::neu(state, *kanal, RATE); + eintrag.frames = (RATE as f64 * laenge) as u64; + eintrag.titel = name.to_string(); + pult.deck_hinzufuegen(eintrag); + } + pult.kanal_hinzufuegen(KanalSpiegel::neu("AUX", Assign::Thru)); + + // Das Grid wird gesetzt, nicht erkannt. Gemessen wird hier die Schätzung + // aus dem Klang gegen die Mitschrift; ein Grid, das um ein halbes BPM + // danebenliegt, verschöbe nur, *wann* der Griff läuft — und die Mitschrift + // schriebe es genauso mit. Es zu erkennen kostete Rechenzeit ohne Gewinn. + for eintrag in pult.decks() { + eintrag + .state + .set_grid(Some(Beatgrid::new(fall.bpm as f32, 0, 1.0))); + } + + pult.aufnahme_setzen(aufnahme); + Ok((pult, runner)) +} + +/// Kick, Bass und ein Akkord — genug für Grid und Harmonik. +/// +/// Der Akkord ist das Entscheidende: Der Kritiker vergleicht Chroma, und ohne +/// Harmonik hätte er nichts zu vergleichen. Ein Klick-Track allein wäre für +/// dieses Verfahren unsichtbar, egal wie deutlich der Wechsel klingt. +fn material(bpm: f64, halbtoene: i32, secs: f64) -> Track { + let n = (RATE as f64 * secs) as usize; + let je_beat = RATE as f64 * 60.0 / bpm; + let mut mono = vec![0.0f32; n]; + + let grund = 110.0 * 2f32.powf(halbtoene as f32 / 12.0); + // Ein Dur-Dreiklang: Grundton, große Terz, Quinte. + for (i, s) in mono.iter_mut().enumerate() { + let t = i as f32 / RATE as f32; + for (halb, pegel) in [(0.0f32, 0.22f32), (4.0, 0.16), (7.0, 0.16)] { + let f = grund * 2f32.powf(halb / 12.0) * 2.0; + *s += pegel * (std::f32::consts::TAU * f * t).sin(); + } + } + + let mut beat = 0usize; + loop { + let start = (beat as f64 * je_beat) as usize; + let laenge = RATE as usize / 5; + if start + laenge >= n { + break; + } + for i in 0..laenge { + let t = i as f32 / RATE as f32; + let f = 120.0 * (-t * 28.0).exp() + 48.0; + mono[start + i] += (std::f32::consts::TAU * f * t).sin() * (-t * 20.0).exp() * 0.8; + } + beat += 1; + } + + Track { + samples: mono.iter().flat_map(|v| [*v, *v]).collect(), + sample_rate: RATE, + stems: Vec::new(), + } +} + +/// Eine Zeile durch das Pult, wie sie über den Socket käme. +fn fuehren(pult: &mut Steuerpult, zeile: &str) -> Result { + let mut sitzung = Sitzung::neu(); + let antwort = behandle(pult, &mut sitzung, zeile); + if antwort.starts_with("fehler") { + bail!("{zeile} → {antwort}"); + } + Ok(antwort) +} + +/// Rendert und lässt dabei den Plan laufen — wie Soundkarte und Taktgeber. +/// +/// **Mit Zügel.** Der Mitschnitt hängt an einem Ring von zwei Sekunden, und +/// wer schneller hineinschreibt, als der Schreiber herausholt, verliert die +/// Differenz. Hier wird deshalb nach jeweils einer halben Sekunde Musik kurz +/// angehalten. Das ist immer noch etwa fünfzigmal schneller als in Echtzeit, +/// und der Ring bleibt halb leer. +fn laufen(pult: &mut Steuerpult, runner: &mut EngineRunner, sekunden: f64) { + let frames = (RATE as f64 * sekunden) as usize; + let zuegel = RATE as usize / 4; + let mut puffer = vec![0.0f32; BLOCK * 4]; + let mut getan = 0; + let mut seit_pause = 0; + while getan < frames { + runner.render(&mut puffer, 4); + getan += BLOCK; + seit_pause += BLOCK; + if seit_pause >= zuegel { + seit_pause = 0; + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + // Der Taktgeber läuft in der Anwendung alle 5 ms; hier alle 256 Frames, + // also gut alle 5 ms bei 48 kHz. Näher am Ernstfall geht es nicht. + let mut plan = std::mem::take(&mut pult.plan); + takt(pult, &mut plan, &mut |p, zeile| { + let mut s = Sitzung::neu(); + behandle(p, &mut s, zeile) + }); + let neu = std::mem::take(&mut pult.plan); + pult.plan = plan; + for a in neu.auftraege() { + pult.plan.uebernehmen(a.clone()); + } + } +} + +/// Wartet, bis der Schreiber-Thread die Datei **geschlossen** hat. +/// +/// „Größer als der Kopf" reicht nicht: Die Länge im RIFF-Kopf wird erst beim +/// Schließen nachgetragen, und eine Datei mit Daten, aber falschem Kopf lässt +/// sich nicht dekodieren — beim ersten Lauf brach der Durchgang genau daran +/// ab. Geprüft wird deshalb der Kopf selbst. +fn warten_auf(wav: &Path) -> Result<()> { + for _ in 0..400 { + if kopf_stimmt(wav).unwrap_or(false) { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + bail!("{} wurde nicht fertig geschrieben", wav.display()) +} + +/// Ob die Länge im RIFF-Kopf zur Datei passt. +fn kopf_stimmt(wav: &Path) -> std::io::Result { + use std::io::Read; + let laenge = std::fs::metadata(wav)?.len(); + if laenge < 44 { + return Ok(false); + } + let mut kopf = [0u8; 8]; + std::fs::File::open(wav)?.read_exact(&mut kopf)?; + let riff = u32::from_le_bytes([kopf[4], kopf[5], kopf[6], kopf[7]]) as u64; + Ok(riff + 8 == laenge) +} + +/// Die Zahl aus einer Antwort wie `value master.record_dropped 12`. +fn zahl(antwort: &str) -> f64 { + antwort + .split_whitespace() + .next_back() + .and_then(|w| w.parse().ok()) + .unwrap_or(0.0) +} + +fn argumente() -> Result { + let mut args = std::env::args().skip(1); + match args.next() { + Some(a) if a == "--help" || a == "-h" => { + println!("Aufruf: musik-streuung "); + println!(); + println!("Fährt eine Reihe Sets durch die echte Anlage, legt Mitschnitt und"); + println!("Mitschrift in ab und hält die Schätzung des Kritikers"); + println!("gegen das, was die Mitschrift festgehalten hat."); + std::process::exit(0); + } + Some(a) => Ok(PathBuf::from(a)), + None => bail!("Aufruf: musik-streuung "), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn protokoll(zeilen: &[&str]) -> mitschrift::Protokoll { + let mut text = String::from("# musik-mitschrift 1\n# mitschnitt set.wav\n# rate 48000\n"); + text.push_str(&zeilen.join("\n")); + mitschrift::aus_text(&text).expect("Mitschrift lesbar") + } + + /// **Der Anker sitzt auf der Bewegung, nicht auf der Bestellung.** + /// + /// Beide falschen Anker sind einmal gemessen worden, und beide sahen nach + /// Messwerten aus. Deshalb steht hier eine Mitschrift, die alle drei + /// Zeilen enthält — Einrichten, Bestellung, Bewegung. + #[test] + fn die_wahrheit_ist_die_bewegung_und_nicht_die_bestellung() { + let p = protokoll(&[ + "0 0.000 deck1=0.000/16~ > set master.crossfader -1", + "0 0.000 deck1=0.000/16~ < ok master.crossfader -1", + "1152000 24.000 deck1=51.200/16 > do master.uebergang blende 16", + "1152000 24.000 deck1=51.200/16 > in deck1 phrase ramp master.crossfader 1 16 weich", + "1152000 24.000 deck1=51.200/16 < ok plan 2 in 12.8 Beats: ramp master.crossfader 1 16", + "1440000 30.000 deck1=64.000/16 > ramp master.crossfader 1 16 weich deck1", + "1800192 37.504 deck1=80.009/16 < plan 4 fertig master.crossfader 1.0000", + ]); + assert_eq!(erste_bewegung(&p), Some(30.0)); + } + + /// Ohne Griff keine Wahrheit — und kein geratener Wert. + #[test] + fn ohne_uebergang_gibt_es_keine_wahrheit() { + let p = protokoll(&[ + "0 0.000 deck1=0.000/16~ > set master.crossfader -1", + "0 0.000 deck1=0.000/16~ < ok master.crossfader -1", + ]); + assert_eq!(erste_bewegung(&p), None); + } + + /// Ein bestellter Griff, der nie ausgeführt wurde, ist keine Bewegung. + #[test] + fn eine_bestellung_allein_ist_keine_bewegung() { + let p = protokoll(&[ + "1152000 24.000 deck1=51.200/16 > do master.uebergang blende 16", + "1152000 24.000 deck1=51.200/16 > in deck1 phrase ramp master.crossfader 1 16 weich", + ]); + assert_eq!(erste_bewegung(&p), None); + } + + #[test] + fn die_zahl_kommt_aus_der_antwort() { + assert_eq!(zahl("value master.record_dropped 1206680"), 1_206_680.0); + assert_eq!(zahl("value master.record_seconds 18.86"), 18.86); + assert_eq!(zahl("ok"), 0.0); + } +} diff --git a/crates/musik-cli/src/main.rs b/crates/musik-cli/src/main.rs new file mode 100644 index 0000000..b6a830b --- /dev/null +++ b/crates/musik-cli/src/main.rs @@ -0,0 +1,140 @@ +//! Phase-0-Prototyp: ein Deck an der Kommandozeile. +//! +//! Zweck ist nicht Bedienkomfort, sondern die Frage aus `docs/BAUSTEINE.md` +//! empirisch zu beantworten — trägt der native Audio-Pfad, und wie klingt die +//! Zeitstreckung bei den ±8 %, die im DJ-Betrieb üblich sind? + +use std::io::{self, BufRead, Write}; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use audio_core::{Player, Track}; + +fn main() -> Result<()> { + let path: PathBuf = match std::env::args().nth(1) { + Some(p) => PathBuf::from(p), + None => { + eprintln!("Aufruf: musik-cli "); + eprintln!("Unterstützt: mp3, flac, wav, m4a/aac, ogg"); + std::process::exit(2); + } + }; + + println!("Lade {} …", path.display()); + let track = Track::decode_file(&path) + .with_context(|| format!("konnte {} nicht dekodieren", path.display()))?; + println!( + " {} Frames, {} Hz, {}", + track.frames(), + track.sample_rate, + format_time(track.duration_secs()) + ); + + let player = Player::open(track).context("konnte das Ausgabegerät nicht öffnen")?; + println!( + "Gerät: {} @ {} Hz", + player.device_name(), + player.sample_rate() + ); + println!(); + print_help(); + + let state = player.state().clone(); + let stdin = io::stdin(); + let mut lines = stdin.lock().lines(); + + loop { + print!("> "); + io::stdout().flush().ok(); + + let Some(line) = lines.next() else { break }; + let line = line?; + let mut parts = line.split_whitespace(); + let Some(cmd) = parts.next() else { continue }; + let arg = parts.next(); + + match cmd { + "p" | "play" => { + let now = state.toggle_playing(); + println!("{}", if now { "▶ läuft" } else { "⏸ pausiert" }); + } + "k" | "keylock" => { + let next = !state.keylock(); + state.set_keylock(next); + println!("Keylock {}", if next { "an" } else { "aus" }); + } + "t" | "tempo" => match arg.map(parse_tempo) { + Some(Ok(ratio)) => { + state.set_tempo(ratio); + println!( + "Tempo {:+.2} % ({:.4}×)", + (state.tempo() - 1.0) * 100.0, + state.tempo() + ); + } + Some(Err(e)) => println!("{e}"), + None => println!("Tempo {:+.2} %", (state.tempo() - 1.0) * 100.0), + }, + "s" | "seek" => match arg.map(|a| a.parse::()) { + Some(Ok(secs)) => { + player.seek_secs(secs); + println!("Sprung auf {}", format_time(secs)); + } + _ => println!("Aufruf: s "), + }, + "i" | "info" => print_status(&player), + "h" | "help" | "?" => print_help(), + "q" | "quit" | "exit" => break, + other => println!("unbekannt: {other} — 'h' für Hilfe"), + } + + if state.is_finished() { + println!("(Track zu Ende)"); + } + } + + Ok(()) +} + +/// Nimmt entweder ein Verhältnis (`1.06`) oder eine Prozentangabe (`+6`, `-3`). +fn parse_tempo(raw: &str) -> std::result::Result { + let value: f32 = raw.parse().map_err(|_| format!("'{raw}' ist keine Zahl"))?; + + let ratio = if raw.starts_with('+') || raw.starts_with('-') { + 1.0 + value / 100.0 + } else { + value + }; + + if !(0.25..=4.0).contains(&ratio) { + return Err(format!("{ratio:.3}× liegt außerhalb von 0.25×–4.0×")); + } + Ok(ratio) +} + +fn print_status(player: &Player) { + let state = player.state(); + println!( + "{} {} Tempo {:+.2} % Keylock {}", + if state.is_playing() { "▶" } else { "⏸" }, + format_time(player.position_secs()), + (state.tempo() - 1.0) * 100.0, + if state.keylock() { "an" } else { "aus" } + ); +} + +fn print_help() { + println!(" p Play/Pause"); + println!(" t Tempo — '1.06' als Verhältnis oder '+6' in Prozent"); + println!(" k Keylock an/aus (Tonhöhe halten beim Tempowechsel)"); + println!(" s Springen"); + println!(" i Status"); + println!(" q Beenden"); +} + +fn format_time(secs: f64) -> String { + let secs = secs.max(0.0); + let m = (secs / 60.0).floor() as u64; + let s = secs - (m as f64) * 60.0; + format!("{m}:{s:05.2}") +} diff --git a/docs/AGENTEN.md b/docs/AGENTEN.md new file mode 100644 index 0000000..f00a799 --- /dev/null +++ b/docs/AGENTEN.md @@ -0,0 +1,139 @@ +# Multi-Agenten-Team: Musik aus Crowd-Reaktionen + +Fernziel, nicht Phase 1. Dieses Dokument hält die Idee fest, solange sie frisch +ist, und markiert die Stellen, an denen sie an harte Grenzen stößt. + +## Die Idee + +Ein Team von Agenten übernimmt gemeinsam, was ein DJ tut: die Menge lesen und +entscheiden, was als Nächstes kommt. Der Unterschied zum klassischen DJ-Set — +„was als Nächstes kommt" muss nicht existieren. Es kann generiert werden, +zugeschnitten auf das, was der Raum gerade macht. + +Die Crowd wird damit zum Ko-Autor: Sie schreibt keine Noten, aber ihre Reaktion +ist Eingabe in die Komposition. + +## Vorarbeit, die es schon gibt + +Das ist keine neue Idee, und das ist gut — es gibt Erfahrungswerte. + +**hpDJ (HP Labs)** ist das bekannteste Forschungssystem, das DJ-Aufgaben +vollständig automatisiert *und* dabei live auf Crowd-Reaktionen reagiert. Es +misst die Reaktion auf den laufenden Track und lenkt damit die Auswahl des +nächsten. Genau unsere Schleife — nur ohne den Generierungsschritt, weil es den +damals nicht gab. + +Heutige kommerzielle AI-DJ-Ansätze nutzen Chat-Nachrichten, Umfragen und +teilweise biometrische Signale, um Tracks, Tonfall und Ansagen zu steuern. + +## Signalquellen — von einfach nach heikel + +| Quelle | Aufwand | Latenz | Problem | +| --- | --- | --- | --- | +| Chat, Umfragen, Reaktionen | Gering | Sekunden | Nur die lauteste Minderheit antwortet | +| Voting per Handy-App | Mittel | Sekunden | Braucht Installation/QR und aktive Teilnahme | +| Raummikrofon (Pegel, Jubel) | Gering | Sofort | Verwechselt Musiklautstärke mit Publikum | +| Kamera, Bewegungsanalyse | Hoch | Sofort | **Datenschutz** | +| Wearables, Biometrie | Hoch | Sofort | **Datenschutz**, Ausstattung nötig | + +⚠️ **Kamera und Biometrie sind in der EU kein Detail.** Gesichtserkennung oder +biometrische Auswertung von Gästen fällt unter die DSGVO und braucht eine +tragfähige Rechtsgrundlage — in einem Club praktisch nur mit ausdrücklicher, +freiwilliger Einwilligung. Das ist keine Zeile Code, sondern ein Konzept mit +Beschilderung, Opt-out und Löschfristen. **Empfehlung: mit anonymen, +aggregierten Signalen starten** (Bewegungsenergie im Bild ohne Personenbezug, +Pegel, Votes). Das liefert überraschend viel und vermeidet den ganzen Komplex. + +## Erst prüfen: nicht selbst bauen + +Bevor hier ein Agenten-Framework entsteht — VibeMind hat mit Brain und OpenFang +genau diese Schicht bereits: Intent, Planung, Routing, Freigabe, Ausführung. +Die naheliegende Arbeitsteilung ist, hier nur die Werkzeuge zu liefern und die +Agenten dort laufen zu lassen. Siehe [VIBEMIND.md](VIBEMIND.md). + +Die folgenden Rollen bleiben als fachlicher Entwurf gültig, unabhängig davon, +wo sie am Ende ausgeführt werden. + +## Agentenrollen (Entwurf) + +``` + Signale ──► [Crowd-Sensor] ──► [Energie-Analyst] + │ + ▼ + [Set-Planer] ◄── Set-Historie, Uhrzeit + │ │ + ┌────────────┘ └───────────┐ + ▼ ▼ + [Track-Wähler] [Generator] + (Library) (Suno/ElevenLabs) + │ │ + └──────────────┬────────────────┘ + ▼ + [Mix-Engineer] + │ + ▼ + Audio-Engine +``` + +| Rolle | Aufgabe | +| --- | --- | +| **Crowd-Sensor** | Rohsignale einsammeln, normalisieren, anonymisieren | +| **Energie-Analyst** | Aus Signalen einen Zustand ableiten: Energie, Trend, Kipppunkte | +| **Set-Planer** | Dramaturgie über die Zeit — nicht jeder Peak ist der richtige Moment für den Peak | +| **Track-Wähler** | Passendes aus der Library, harmonisch und tempomäßig anschlussfähig | +| **Generator** | Beauftragt neue Tracks, wenn nichts Passendes existiert | +| **Mix-Engineer** | Übergang planen und ausführen: Punkt, Länge, EQ-Kurve | +| **Guardrail** | Vetorecht — verhindert Ausreißer, die den Floor leeren | + +## Die harte Grenze: Zeit + +Das ist der Punkt, an dem die Idee zuerst bricht, deshalb steht er hier und +nicht im Kleingedruckten. + +Ein Dancefloor entscheidet in **Sekunden**. Ein Übergang wird ein bis zwei +Minuten im Voraus geplant. Ein generierter Track braucht — je nach Anbieter — +**Zehner von Sekunden bis Minuten** und ist erst danach überhaupt spielbar. + +Die Schleife „Crowd reagiert → Agent generiert → Track spielt" schließt sich +also **nicht** in Echtzeit. Was funktioniert: + +1. **Vorausschauend generieren.** Der Set-Planer erzeugt permanent Kandidaten + für wahrscheinliche nächste Zustände, bevor sie eintreten. Die Crowd steuert + dann die *Auswahl* aus fertigem Material — das geht sofort. +2. **Echtzeit nur für das, was schnell ist.** EQ, Filter, Loop-Länge, + Stem-Muting, Pattern-Player-Variationen reagieren unmittelbar. Das ist die + Ebene, auf der sich „die Musik reagiert auf uns" tatsächlich anfühlt. +3. **Generierung als langsame Schicht.** Sie formt die nächste halbe Stunde, + nicht die nächsten acht Takte. + +Wer das umdreht und auf Echtzeit-Generierung wartet, baut ein System, das immer +zu spät kommt. + +## Offene Fragen + +- Wie misst man „Energie" so, dass es nicht bloß Lautstärke ist? +- Wie verhindert man Rückkopplung — das System spielt, was gefällt, wird + dadurch immer glatter und verliert jede Kante? +- Wieviel Kontrolle behält der Mensch am Pult? Vollautomat oder Assistent? +- Wo läuft das Ganze? Auf dem Laptop am Pult oder verteilt? + +Die zweite Frage ist die interessanteste. Ein reiner Zustimmungs-Optimierer +konvergiert gegen Mittelmaß. Ein guter DJ nimmt bewusst Risiko — das müsste im +Set-Planer explizit modelliert sein, nicht als Nebeneffekt entstehen. + +## Voraussetzungen + +Bevor das hier überhaupt sinnvoll wird, müssen stehen: + +- Audio-Engine mit zwei Decks und programmierbarem Mixer (Phase 1–2) +- Library mit Metadaten, BPM, Tonart (Phase 4) +- Mindestens ein funktionierender Generierungs-Adapter (siehe [APIS.md](APIS.md)) + +Das Agenten-Team ist die Schicht *über* dem DJ-Tool. Ohne das Tool darunter ist +es nichts. + +## Quellen + +- [hpDJ: An Automated DJ with Floorshow Feedback – Springer](https://link.springer.com/chapter/10.1007/1-4020-4097-0_12) +- [Real-Time Adaptive AI DJ Sets: How Audience Feedback Drives Next-Gen Streams](https://www.djcara.com/blog/real-time-adaptive-ai-dj-sets-how-audience-feedback-drives-next-gen-streams/) +- [The Algorithm of the Dancefloor: How DJs Use Data to Command the Crowd – Audiopool](https://www.audiopool.io/all-posts/the-algorithm-of-the-dancefloor-how-djs-use-data-to-command-the-crowd) diff --git a/docs/APIS.md b/docs/APIS.md new file mode 100644 index 0000000..35f1bfc --- /dev/null +++ b/docs/APIS.md @@ -0,0 +1,131 @@ +# Externe APIs und Datenquellen + +Stand: August 2026. Recherchestand, keine eigenen Tests — Preise und +Lizenzbedingungen vor jeder Integration selbst prüfen, die ändern sich schnell. + +## Kurzfassung + +**Suno hat keine öffentliche API.** Das ist der Blocker für die +Generierungs-Features. Es gibt seit Juli 2026 ein Partner-Programm mit +Bewerbungsformular, aber keine offenen Endpunkte, keine Doku, kein Preismodell, +kein Termin. + +Konsequenz für die Planung: Wir bauen den Generierungs-Teil **hinter einer +Adapter-Schnittstelle** und starten mit einem Anbieter, an den wir heute +herankommen. Suno wird später ein weiterer Adapter, kein Umbau. + +## Musikgenerierung + +### Suno — aktuell nicht verfügbar + +| | | +| --- | --- | +| Status | Keine self-serve öffentliche API | +| Partner-Programm | Seit Juli 2026, kuratierte Auswahl, Bewerbung per Formular | +| Doku/Preise | Nicht veröffentlicht | +| Lizenzlage | Ungeklärt, Verfahren zu Trainingsdaten laufen | + +Suno-CPO Jack Brody hat das Programm im Juli 2026 angekündigt und sucht Apps, +„die Erfahrungen ermöglichen, die generative Musik zum ersten Mal möglich +macht". Ein Multi-Agenten-DJ, der auf Crowd-Reaktionen komponiert, ist genau +dieses Profil — **die Bewerbung ist einen Versuch wert.** + +⚠️ **Nicht empfohlen:** Es kursieren reverse-engineerte „Suno APIs" von +Drittanbietern. Die sind inoffiziell, können jederzeit brechen und bewegen sich +verstoßen aller Wahrscheinlichkeit nach gegen Sunos Nutzungsbedingungen. Dass +das Projekt nicht kommerziell ist, ändert daran nichts — das realistische Risiko +ist ein gesperrter Account, nicht eine Lizenzklage. + +### Alternativen, die heute nutzbar sind + +| Anbieter | Stärke | Lizenzlage | Anmerkung | +| --- | --- | --- | --- | +| **ElevenLabs Music** | Vocals, Songs mit Text; klare Konditionen | Auf lizenzierten Daten trainiert, breit kommerziell freigegeben | Zusatzlizenz nötig für Werbung, Film, TV, Games, Enterprise | +| **Stable Audio** (Stability) | Instrumentale Texturen, transparente Preise | Stability-Lizenz | Drittquellen nennen ~0,20 $/Track | +| **Udio** | Von Musikern für Qualität gelobt | Ungeklärt, wie Suno | Gleiches Risikoprofil | +| **Mubert**, **Beatoven** | Hintergrundmusik, Royalty-free | Sauber strukturiert | Weniger „Track", mehr Bett | +| **Loudly** | Volle kommerzielle Rechte am API-Output | Klar | | +| **Google Lyria** | Vocals, lyric-getriebene Songs | Google-Lizenz | | + +**Empfehlung als Startadapter: ElevenLabs Music.** Es gibt einen dokumentierten +`/music/compose`-Endpunkt, die Lizenzlage ist die klarste im Feld, und der +Funktionsumfang deckt das ab, was wir zum Testen der Pipeline brauchen. + +Bekannte Parameter von `/music/compose` (laut Doku-Zusammenfassung): + +- Entweder ein Text-Prompt **oder** ein detaillierter Kompositionsplan — nicht beides +- Songlänge in Millisekunden, 3 000–600 000 (nur zusammen mit Prompt) +- Modellauswahl +- Seed für reproduzierbare Ergebnisse +- Optionales Flag für rein instrumentale Ausgabe + +Der Seed-Parameter ist für uns interessanter, als er klingt: Er macht Varianten +eines Tracks reproduzierbar — brauchbar für Übergänge zwischen zwei Fassungen +desselben Materials. + +### Was die Adapter-Schnittstelle abdecken muss + +Aus dem Vergleich oben ergeben sich die gemeinsamen Nenner: + +``` +generiere(prompt | plan, laenge, instrumental?, seed?) -> Job +status(Job) -> pending | fertig | fehler +hole(Job) -> Audio + Metadaten + Prompt-Historie +``` + +Asynchron, weil alle Anbieter Zeit brauchen. Siehe +[ARCHITEKTUR.md](ARCHITEKTUR.md) — der Audio-Pfad darf davon nie blockiert +werden. + +## Samples und Audiomaterial + +### Freesound + +APIv2 ist die praktikabelste Quelle für Samples mit sauberer Rechtelage. + +- Textsuche über die gesamte Bibliothek +- Endpunkt pro Sound: Metadaten, Tags, Content-Analyse-Features, Preview-URLs, Download +- Lizenzen: **CC0, CC BY, CC BY-NC** +- API frei für nicht-kommerzielle Anwendungen; kommerzielle Nutzung ist separat lizenzierbar +- Seit Juli 2026 gibt es eine „Generative AI"-Präferenz pro Upload + +**CC BY-NC ist nutzbar**, weil das Projekt nicht kommerziell ist — das ist der +größte praktische Gewinn dieser Entscheidung, denn NC-Material macht einen +erheblichen Teil von Freesound aus. + +⚠️ **Die Attributionspflicht bleibt.** CC BY verlangt Namensnennung auch +nicht-kommerziell. Das heißt konkret: Die Library braucht von Anfang an ein Feld +für Lizenz und Urheber pro Track. Nachträglich lässt sich das nicht +rekonstruieren, wenn erst einmal tausend Samples ohne Herkunft im Ordner liegen. + +### Weitere Quellen + +- **Free Music Archive** — ganze Tracks unter CC +- **ccMixter** — Remix-orientiert, Stems und Acapellas +- **Splice** u. ä. — kommerzielle Sample-Abos, API-Zugang meist nicht offen + +## Konkrete nächste Schritte + +- [x] Entschieden: **nicht kommerziell.** Damit sind CC-BY-NC-Samples nutzbar + und die ungeklärte Lizenzlage bei Suno/Udio ist kein Ausschlusskriterium + mehr — nur noch der fehlende API-Zugang. +- [ ] Bei Suno für das Partner-Programm bewerben (Intake-Formular, Juli 2026 veröffentlicht) +- [ ] ElevenLabs-API-Key besorgen, `/music/compose` einmal manuell gegen curl testen +- [ ] Freesound-API-Key besorgen (die API ist für nicht-kommerzielle Nutzung kostenlos) +- [ ] Lizenz- und Urheberfeld im Library-Schema vorsehen, bevor Samples + importiert werden + +Was die Entscheidung **nicht** löst: Suno hat weiterhin keine öffentliche API. +Der Blocker war nie die Lizenz, sondern der Zugang. + +## Quellen + +- [Suno Is Opening an API Partner Program – Digital Music News](https://www.digitalmusicnews.com/2026/07/03/suno-is-opening-an-api-partner-program/) +- [The Suno API Reality: A Developer's Guide – AI/ML API Blog](https://aimlapi.com/blog/the-suno-api-reality) +- [Is There a Public Suno API for Developers in 2026 – MusicGPT](https://musicgpt.com/blog/suno-api) +- [Compose music – ElevenLabs Documentation](https://elevenlabs.io/docs/api-reference/music/compose) +- [Eleven Music, now available in the API – ElevenLabs](https://elevenlabs.io/blog/eleven-music-now-available-in-the-api) +- [AI Music Generation API Comparison: The Developer's Guide 2026](https://aimusicapi.ai/en/blog/ai-music-generation-api-comparison) +- [AI Music Generation 2026: Suno, Udio, ElevenLabs Compared – Digital Applied](https://www.digitalapplied.com/blog/ai-music-generation-platforms-suno-udio-elevenlabs-2026) +- [APIv2 Overview – Freesound API documentation](https://freesound.org/docs/api/overview.html) +- [Freesound FAQ](https://freesound.org/help/faq/) diff --git a/docs/BAUSTEINE.md b/docs/BAUSTEINE.md new file mode 100644 index 0000000..e433e64 --- /dev/null +++ b/docs/BAUSTEINE.md @@ -0,0 +1,243 @@ +# Technische Bausteine + +Was es an fertigen Bibliotheken gibt, damit wir nichts nachbauen, was es schon +gibt. Recherchestand August 2026, nicht selbst getestet. + +## Lizenzen — entschärft, nicht erledigt + +**Das Projekt ist nicht kommerziell.** Damit fällt die Sperre weg, die den +Audio-Bereich sonst prägt: (A)GPL-Bibliotheken mit kommerzieller Zweitlizenz +sind hier nutzbar. + +Der Grund ist präziser als „nicht kommerziell": **Copyleft greift bei +Weitergabe, nicht bei Nutzung.** Solange das Tool auf dem eigenen Rechner läuft +und nicht verteilt wird, entstehen aus GPL-Abhängigkeiten überhaupt keine +Pflichten. + +**Aktueller Stand: reine Eigennutzung, Weitergabe nicht geplant.** Damit ist die +Bibliothekswahl formal frei — auch Rubber Band. Eine `LICENSE`-Datei braucht das +Repo in diesem Zustand nicht. + +⚠️ **Trotzdem ist Zurückhaltung geboten.** Ein Anschluss an VibeMind (MIT, +zur Veröffentlichung vorgesehen) steht im Raum, und der wäre Weitergabe — dann +gilt die Entlastung nicht mehr. Solange diese Frage offen ist, ist jede +GPL-Abhängigkeit eine Tür, die sich hinter einem schließt. Siehe +[VIBEMIND.md](VIBEMIND.md#1-die-lizenzlage-kippt-zurück). + +| Bibliothek | Lizenz | Für dieses Projekt | +| --- | --- | --- | +| Essentia | AGPL | Nutzbar — siehe AGPL-Hinweis unten | +| Rubber Band | GPL + kommerziell | Nutzbar | +| aubio | GPL | Nutzbar | +| JUCE | GPL + kommerziell | Nutzbar | +| SoundTouch | LGPL | Nutzbar | +| librosa | ISC | Frei | +| miniaudio | Public Domain / MIT-0 | Frei | +| CPAL (Rust) | MIT / Apache-2.0 | Frei | +| Symphonia (Rust) | MPL-2.0 | Frei | +| Demucs | MIT (Code) | Modellgewichte separat prüfen | +| Mixxx | GPLv2+ (Binary faktisch GPLv3+) | Nutzbar — als Referenz lesen, nicht einbetten: [MIXXX.md](MIXXX.md) | + +Zwei Punkte bleiben trotzdem stehen: + +⚠️ **Wenn das Tool je weitergegeben wird** — auch kostenlos, auch nur an +Freunde, auch als Open Source — greift die GPL: Der eigene Code müsste dann +ebenfalls unter GPL stehen. Das ist kein Problem, aber eine Entscheidung, die +dann ansteht und nicht danach. + +⚠️ **AGPL geht weiter als GPL.** Sie löst schon bei Nutzung über ein Netzwerk +aus, nicht erst bei Weitergabe. Relevant wird das, wenn die Agenten-Schicht aus +[AGENTEN.md](AGENTEN.md) je als Dienst läuft, auf den andere zugreifen — etwa +ein Crowd-Voting per Handy. Dann wäre Essentia im Stack ein Thema. `librosa` +(ISC) und `madmom` sind für Analyse die unkomplizierteren Wege. + +Bei Samples bleibt eine Pflicht unabhängig davon bestehen: **CC BY verlangt +Namensnennung auch nicht-kommerziell.** Siehe [APIS.md](APIS.md#samples-und-audiomaterial). + +## Audio-Ausgabe und Engine + +Das ist die Phase-0-Entscheidung. + +**Browser — Web Audio API** +Reicht für Decks, EQ, Filter, Gain und Routing. Kein Installationsaufwand, gut +zu debuggen, schnell zu bauen. Grenzen: Latenz ist nicht garantiert, exklusiver +Zugriff auf ein Audio-Interface (ASIO/CoreAudio, getrennter Cue-Ausgang für +Kopfhörer) ist im Browser nicht sauber möglich. **Wichtig:** `playbackRate` +ändert Tempo *und* Tonhöhe — für Pitch-unabhängiges Tempo braucht es zwingend +einen eigenen Time-Stretcher (siehe unten). + +**Nativ** +- [`miniaudio`](https://github.com/mackron/miniaudio) — Single-Header C, gemeinfrei, minimaler Ballast +- [`CPAL`](https://github.com/RustAudio/cpal) — Rust, plattformübergreifend, permissive Lizenz +- `RtAudio` — C++, etabliert +- `JUCE` — volles Framework inkl. UI, aber GPL/kommerziell + +Nativ gibt echte Low-Latency-Ausgabe und mehrere Ausgänge — die Voraussetzung +für Kopfhörer-Cue, also für ernsthaftes DJing. + +**Entscheidung: nativ, in Rust.** Ausschlaggebend war der getrennte +Cue-Ausgang, nicht die Latenz — ohne Kopfhörer-Vorhören ist ernsthaftes DJing +nicht möglich, und im Browser gibt es das nicht sauber. + +Rust statt C++, weil der Stack damit klein und permissiv bleibt: CPAL, +Symphonia, ein eigener Stretcher. Dass GPL inzwischen offensteht (nicht +kommerziell), ändert daran nichts — die Wahl war zu dem Zeitpunkt richtig und +ist es geblieben. Sie macht nur den *Zwang* weg, alles selbst zu bauen. + +## Time-Stretching und Pitch-Shifting + +Kernstück jedes DJ-Tools: Tempo ändern, ohne dass die Tonhöhe mitwandert +(Keylock). + +- **[Rubber Band Library](https://breakfastquay.com/rubberband/)** — Referenzqualität, GPL + kommerziell +- **[SoundTouch](https://www.surina.net/soundtouch/)** — LGPL, solide, weit verbreitet +- **[signalsmith-stretch](https://github.com/Signalsmith-Audio/signalsmith-stretch)** — MIT, modern +- Aktuell im Einsatz: **eigene WSOLA-Implementierung** in `crates/audio-core/src/stretch.rs` + +Da das Projekt nicht kommerziell ist, steht **Rubber Band offen** — die beste +der drei Bibliotheken. Sie ist C++ und müsste per FFI angebunden werden. + +Der Austausch lohnt aber erst, wenn die eigene WSOLA hörbar nicht reicht. Beim +DJ-typischen Bereich von ±8 % ist der Abstand klein; groß wird er bei starken +Faktoren und bei perkussivem Material. **Erst hören, dann tauschen** — die +Schnittstelle ist schmal genug, dass der Wechsel jederzeit möglich bleibt. + +## BPM, Beatgrid, Tonart + +- **[aubio](https://aubio.org/)** — Onset, Beat-Tracking, Pitch; C mit Python-Binding; GPL +- **[Essentia](https://essentia.upf.edu/)** — sehr umfangreich (Rhythmus, Tonalität, spektrale und High-Level-Deskriptoren), C++ mit Python-Wrapper, fertige Extraktoren mit JSON/YAML-Ausgabe; AGPL +- **[librosa](https://librosa.org/)** — Python, ISC, ideal zum Prototypen +- **[madmom](https://github.com/CPJKU/madmom)** — sehr gute Beat-/Downbeat-Erkennung + +Alle vier sind jetzt nutzbar. Für Phase 2 ist `librosa` oder `madmom` der +erste Griff — nicht wegen der Lizenz, sondern weil die Analyse ohnehin offline +als eigener Schritt läuft und Python dort schneller zum Ergebnis führt als eine +FFI-Anbindung an den Rust-Kern. + +Für Tonarterkennung liefert Essentia brauchbare Ergebnisse; als eigenständige +OSS-Alternative gibt es KeyFinder. Zu Essentia siehe den AGPL-Hinweis oben, +falls die Agenten-Schicht je über Netzwerk erreichbar wird. + +**Entschieden: selbst gebaut**, in `crates/analysis/src/tonart.rs`. Die beiden +verbreiteten Verfahren — libKeyFinder und der QM Key Detector, den auch Mixxx +mitlinkt — stehen unter GPL, und das schlösse den Weg zu VibeMind (MIT, zur +Veröffentlichung vorgesehen) wieder zu. Das Verfahren selbst ist Lehrbuchstoff: +Chroma über eine FFT, Vergleich gegen die Krumhansl-Kessler-Profile, +Camelot-Ausgabe. Rund 200 Zeilen. + +Was es kann und was nicht, steht ausführlich im Modul. Kurz: Auf Material mit +Akkorden trifft es; auf Bass und Drums allein sagt es **nichts**, weil dort das +Tongeschlecht nicht im Signal steht und ein geratenes Dur auf dem Camelot-Rad +zum Fehlgriff führt. + +**An echten Aufnahmen nachgeeicht.** Die erste Schwelle war an synthetischen +Akkordfolgen gemessen, die 0,92 bis 0,95 erreichen — echte Aufnahmen kommen +über 0,82 kaum hinaus, und vier von fünf Testtracks wurden abgewiesen. +Dieselbe Falle wie beim Tempo, wo eine an Klick-Tracks geeichte Schwelle +echte Musik verwarf; sie ist offenbar die Standardfalle bei allem, was hier +an Signalen gemessen wird. + +Der Grund liegt nicht, wo man ihn vermutet: Rauschen und Perkussion drücken +die Güte kaum, weil sie alle zwölf Halbtonklassen gleichmäßig anheben und eine +Korrelation gegen so einen Offset unempfindlich ist. Was sie drückt, ist +fremde Harmonik — Zwischenteile in anderen Tonarten, Töne außerhalb der +Skala, Stimmen mit gleitender Tonhöhe. + +Die Eichung beruht auf **fünf** Aufnahmen, gegengeprüft über die Stabilität in +30-Sekunden-Abschnitten. Das ist dünn. Mehr Material würde sie schärfen — und +diese Schwelle lässt sich nicht durch einen Test bewachen, weil sich der +Bereich echter Musik synthetisch nicht herstellen lässt. + +**Praktischer Hinweis:** Beatgrid-Erkennung ist bei geradem 4/4-Material fast +gelöst und bei allem anderen mühsam. Traktor löst das über manuelle +Grid-Korrektur — die brauchen wir auch, egal wie gut der Detektor ist. + +Inzwischen gebaut: `deckN.bpm_grid` und `deckN.grid_anchor` sind schreibbar, +dazu `grid_scale` für Oktavfehler und `grid_here` für die Lage der Eins. Jede +Korrektur geht in die Sammlung und überlebt den Trackwechsel. + +## Stem-Separation + +Vortrainierte Modelle: + +- **[Demucs](https://github.com/facebookresearch/demucs)** (`htdemucs_ft`) — aktueller Qualitätsmaßstab +- **[Open-Unmix](https://github.com/sigsep/open-unmix-pytorch)** (`umxhq`) +- **[Spleeter](https://github.com/deezer/spleeter)** (`spleeter:4stems`) — älter, dafür schnell + +Alle liefern die vier Stems, die auch Traktor nutzt: Drums, Bass, Vocals, Other. + +⚠️ **Nicht echtzeitfähig auf üblicher Hardware.** Traktors Echtzeit-Separation +(iZotope RX) ist genau deshalb ein Verkaufsargument. Für uns heißt das: **Stems +beim Import einmal vorberechnen** und neben dem Track ablegen. Im Deck fühlt +sich das identisch an, kostet nur Plattenplatz statt Latenz. + +## Waveform-Darstellung + +Kein fertiges Paket nötig — Peaks beim Import berechnen, mehrstufig ablegen +(Übersicht grob, Zoom fein), im Canvas/WebGL zeichnen. Die Berechnung gehört zur +Import-Analyse, nicht in den Abspielpfad. Für die Farbcodierung nach +Frequenzbändern (wie Traktor) braucht es zusätzlich eine grobe +Bandaufteilung pro Peak. + +## Traktor-Import + +- **[traktor-nml-utils](https://github.com/wolkenarchitekt/traktor-nml-utils)** — Python, liest und schreibt `collection.nml` + +Damit lassen sich Sammlung, Cue-Points, Beatgrids und Playlists übernehmen. Ein +gutes frühes Feature: viel Nutzen für wenig Aufwand. Siehe +[TRAKTOR-REFERENZ.md](TRAKTOR-REFERENZ.md#datenformat-die-collectionnml). + +## Phase 0: was gebaut ist + +Ein Deck als Rust-Workspace, steuerbar über eine CLI. + +| Crate | Inhalt | +| --- | --- | +| `crates/audio-core` | Dekodierung, Zeitstreckung, Deck-Zustand, CPAL-Ausgabe | +| `crates/musik-cli` | Kommandozeile zum Fahren des Decks | + +**Gewählte Abhängigkeiten** — bewusst wenige, alle permissiv: + +| Crate | Lizenz | Wofür | +| --- | --- | --- | +| `cpal` | Apache-2.0 | Ausgabegerät, plattformübergreifend | +| `symphonia` | MPL-2.0 | Dekodierung: MP3, FLAC, WAV, AAC/M4A, OGG | +| `thiserror` / `anyhow` | MIT/Apache-2.0 | Fehlerbehandlung | + +Zeitstreckung ist **selbst implementiert** (WSOLA, `stretch.rs`). Zum Zeitpunkt +der Umsetzung war die Lizenzfrage noch offen, und der Prototyp sollte nicht +daran hängen. Inzwischen steht Rubber Band offen — der Tausch bleibt eine +Option, sobald jemand beides gehört hat. + +**Architektur des Abspielpfads.** Der Track wird beim Laden komplett dekodiert +und einmalig auf die Samplerate des Geräts gebracht. Dadurch enthält der +Callback keine Ratenkonvertierung, keine Allokation, kein Lock — Steuerung läuft +ausschließlich über Atomics. Das sind genau die drei Dinge, die sonst Aussetzer +verursachen. + +**Was verifiziert ist.** Elf Tests, unter anderem: + +- Keylock hält die Tonhöhe bei 0.92×, 1.06× und 1.20× (gemessen über + Nulldurchgänge, Abweichung < 13 Hz bei 440 Hz) +- Ohne Keylock wandert die Tonhöhe korrekt mit dem Tempo mit +- Die Zeitachse driftet nicht: 4 s Ausgabe bei 1.06× verbrauchen 4,24 s Quelle +- Dekodierung, Mono→Stereo und Resampling halten Länge und Tonhöhe + +Nicht verifiziert ist der Klang auf einer echten Anlage — das braucht Ohren an +einer Soundkarte, nicht Tests. Ebenso offen: gemessene Latenz und das Verhalten +unter Last. + +## Was als Nächstes ansteht + +- Zweites Deck und Mixer (Phase 1) — dafür muss der Track im Callback + austauschbar werden, ohne dort zu allokieren (Send-Back-Kanal für den alten + `Arc`, damit die Freigabe außerhalb des Audio-Threads passiert) +- BPM-Analyse offline mit `librosa` oder `madmom`, Ergebnis neben dem Track ablegen +- Wenn die WSOLA-Qualität bei ±8 % nicht reicht: Rubber Band per FFI anbinden + +## Quellen + +- [ESSENTIA: an Audio Analysis Library for Music Information Retrieval](https://records.sigmm.org/2014/03/20/essentia-an-open-source-library-for-audio-analysis/) +- [stem-separation – GitHub Topics](https://github.com/topics/stem-separation) +- [Audio Development Tools (ADT) – Sammlung](https://github.com/Yuan-ManX/audio-development-tools) +- [traktor-nml-utils](https://github.com/wolkenarchitekt/traktor-nml-utils) diff --git a/docs/DJ.md b/docs/DJ.md new file mode 100644 index 0000000..a2b1a69 --- /dev/null +++ b/docs/DJ.md @@ -0,0 +1,327 @@ +# Plan: ein AI-DJ, der nicht herzlos klingt + +Der erste vollständig automatisch gefahrene Übergang funktionierte technisch +und klang trotzdem seelenlos. Dieses Dokument sagt, warum, und in welcher +Reihenfolge sich das ändern lässt. + +Es ist kein Feature-Katalog. Die Reihenfolge ist das Argument. + +## Die Diagnose + +Drei Dinge, in aufsteigender Schwere. + +**Der Übergang war eine Liste, keine Geste.** Acht Bedingungen, jede an ihrer +eigenen Schwelle, jede unabhängig. Ein DJ denkt nicht in acht Zahlen, sondern in +*einer* Bewegung mit innerem Timing. + +**Die Anlage weiß nicht, was sie spielt.** Sie kennt Tempo, Tonart und die +Wellenform. Sie kennt nicht Intro, Break, Drop und Outro — also die einzigen +Stellen, an denen ein Übergang überhaupt sitzen darf. „Blende aus, während das +Outro läuft" ist derzeit nicht ausdrückbar, weil das Wort Outro nicht existiert. + +**Und sie hört sich nie selbst.** Der Mitschnitt läuft, aber niemand liest ihn. +Damit ist jede Regel eine Behauptung, jede Verbesserung ein Gefühl, und aus +tausend gefahrenen Sets lernt nichts. + +## Die These + +Zwei Sätze tragen alles Weitere. + +**Jede Entscheidung trägt ihren Grund.** Die Notiz an einem Warteschlangen- +Eintrag ist der Keim davon: nicht „Track 7", sondern „weil der Boden seit vier +Minuten dünner wird und der hier dieselbe Stimmlage hat". Ein System, das seine +Gründe mitschreibt, lässt sich kritisieren; eines ohne sie nur abschalten. + +**Jedes Set wird hinterher gemessen.** Ohne das bleibt „klingt besser" eine +Meinung. Mit dem Kritiker wird jede Heuristik zu etwas, das man widerlegen kann +— genau die Wendung, die in diesem Projekt schon dreimal einen Fehler ans Licht +gebracht hat, den niemand vermutet hatte. + +Alles andere ist Handwerk in einer bestimmten Reihenfolge. + +## S0 — Die Phrase als Einheit + +*Klein, sofort, verbessert jeden einzelnen Mix.* + +Die Anlage kann „in 32 Beats" und „wenn noch 32 Beats übrig sind". Sie kann +nicht **„auf der nächsten Eins"** — und das ist die Einheit, in der ein Übergang +gedacht wird. `when beats_to_phrase < 1` trifft irgendwo im letzten Beat, nicht +auf den Schlag. + +Was fehlt: ein Bezugspunkt `phrase` für die vorhandenen Verben. + +```text +at phrase do deck2.sync # auf der nächsten Phrasengrenze +at phrase+16 ramp master.crossfader 1 32 # eine Phrase später +ramp channel1.eq_low 0 8 ab phrase # Bewegung beginnt auf der Eins +``` + +Damit hängt der ganze Übergang an *einem* musikalischen Zeitpunkt statt an acht +willkürlichen Zahlen. Der Rest verschiebt sich relativ dazu — das ist der +Unterschied zwischen Liste und Geste, und er kostet ein Verb. + +## S1 — Der Kritiker + +*Macht alles danach messbar. Deshalb so früh.* + +Ein Programm, das den Mitschnitt liest und danebenlegt, was beabsichtigt war +(der Plan schreibt es ohnehin mit). Es misst, was sich ohne Geschmacksurteil +messen lässt: + +| Maß | Was es findet | +| --- | --- | +| Phrasenlage jedes Übergangs | Startet er auf der Eins oder irgendwo | +| Pegelverlauf über die Blende | Das Loch in der Mitte — beim ersten Lauf 30 % | +| Beat-Drift während der Überlagerung | Ob Sync über die volle Blende hält | +| Chroma-Abstand der überlagerten Teile | Harmonischer Zusammenstoß | +| Länge und Form der Blenden | Ob immer dasselbe gefahren wird | + +Ausgabe ist keine Note, sondern ein Befund je Übergang mit Zahl und Zeitpunkt. +Damit lässt sich jede spätere Änderung gegen dieselben Sets prüfen, statt gegen +die Erinnerung. + +Das Wichtigste daran: Der Kritiker läuft **offline über den Mitschnitt**. Er +braucht keine neue Analyse im Echtzeitpfad und kann so gründlich sein, wie er +will. + +### Was der erste Kritiker gelernt hat + +`musik-kritik` steht und misst Übergänge, Dauer, Pegelverlauf und Tempotreue. +Zwei Dinge kann er **nicht**, und beide sind wichtiger als das, was er kann: + +**Den Beginn einer langen Blende findet er nicht.** Am Anfang ist der eingehende +Track per Konstruktion unhörbar — das ist, was eine Blende ausmacht. Bei einer +Blende über 16 Sekunden liegt seine Schätzung um vier Sekunden daneben, und er +gibt das als Unschärfe mit an. Aus der Summe allein ist das nicht zu lösen: Die +Information steht dort nicht drin. + +**Die Phrasenlage kann er gar nicht messen.** Der Anker des Tempodetektors ist +der erste starke Schlag im Analysefenster, nicht der Anfang einer Phrase. Eine +Angabe wie „sechs Beats neben der Eins" wäre gegen einen willkürlichen Nullpunkt +gemessen. Beim Bauen stand diese Zahl schon im Bericht, bevor auffiel, dass sie +keinen Boden hat. + +Damit verschiebt sich der Plan an einer Stelle: **Das Plan-Protokoll gehört zum +Kritiker dazu.** Was beabsichtigt war, weiß die Anlage — sie schreibt es im +Zeitplan ohnehin mit. Wer es danebenlegt, kennt den Griff an den Fader auf den +Beat genau und braucht ihn nicht aus dem Klang zu erraten. Der Mitschnitt sagt +dann, was *herauskam*; das Protokoll, was *gemeint war*. Erst zusammen ergeben +sie ein Urteil über die Phrasenlage. + +Die Strukturanalyse (S2) liefert den zweiten fehlenden Nullpunkt: Wo eine Phrase +anfängt, weiß man erst, wenn man weiß, wo der Abschnitt anfängt. + +## S1b — Die Mitschrift ✅ + +*Gebaut. Sie liegt neben dem Mitschnitt und wird nicht einzeln eingeschaltet.* + +Je Ereignis eine Zeile: der Frame im Mitschnitt, der Beat jedes Decks, ob es +lief, und die Zeile selbst — hereinkommend `>`, hinausgehend `<`. Geschrieben +wird, was verändert; `get` und `list` bleiben draußen. `musik-kritik` findet sie +von selbst und liest sie mit. + +Am ersten vollständig gefahrenen Übergang danach: + +| Frage | Aus dem Klang | Aus der Mitschrift | +| --- | --- | --- | +| Wann begann die Blende? | 27,0 s (±4) | **23,25 s** | +| Auf welchem Beat? | „nicht beurteilbar" | **48,01 — 0,01 in die Phrase** | +| Wie lang war sie wirklich? | 18 s geschätzt | **32,0 von 32 bestellten Beats** | + +Damit ist die erste Zahl da, die dieses ganze Vorhaben rechtfertigt: **Die +Schätzung aus dem Klang lag 3,7 Sekunden zu spät.** Vorher war das eine +Vermutung; `--ohne-mitschrift` misst sie jetzt bei jedem Set neu. + +Aus der einen Zahl ist inzwischen eine Verteilung geworden (`musik-streuung`, +24 Sets durch die echte Anlage): Median **+2,0 s**, und der Fehler hängt an der +Länge des Übergangs — ein Schnitt wird auf ein Fenster genau gefunden, eine +Blende über 32 Beats im Mittel 4,9 s zu spät. **Eine Blende über 64 Beats wird +gar nicht mehr gefunden**, weil der Kritiker mit dem Klangbild von vor 16 +Sekunden vergleicht und dieser Vergleich dann selbst schon mitten in der Blende +liegt. Der Einzelwert oben stammt von einer 32-Beat-Blende und sitzt genau da, +wo die Verteilung ihn erwarten lässt. + +Und der erste Befund, den vorher niemand hätte finden können: Deck 2 setzte auf +*seinem* Beat −1 ein, also fünfzehn Beats neben seiner eigenen Eins. Der +ausgehende Track war auf der Phrase, der eingehende nicht. Genau dafür braucht +es S2 — der Einstiegspunkt gehört auf einen Downbeat, nicht auf Sekunde 0. + +Zwei Grenzen bleiben, und beide gehören dazu: + +- **Die Mitschrift misst die Anlage an sich selbst.** Sie sagt, ob die Anlage + traf, was sie vorhatte — nicht, ob Beat 0 des Rasters musikalisch die Eins + ist. +- **Sie sagt nichts über Klang.** Ein Griff ohne hörbaren Wechsel bleibt ein + Griff ohne hörbaren Wechsel; der Kritiker meldet das inzwischen als Befund. + +## S2 — Struktur ✅ + +*Gebaut. Die größte Lücke in der Analyse — alles Musikalische hängt daran.* + +Je Track eine Gliederung in Abschnitte mit Zeitbereichen: Intro, Aufbau, Drop, +Break, Outro. Selbst gebaut aus einer Neuheitskurve über das vorhandene +Beatgrid — Segmentgrenzen liegen dort, wo sich der Klangcharakter über eine +Phrasengrenze hinweg ändert. MIT-verträglich, weil eigen. + +Was dadurch erst sagbar wird: + +- **Der Übergang sitzt im Outro des einen und im Intro des anderen.** Das ist + die eigentliche Regel; alles davor war Ersatz. +- **Der Einstiegspunkt des Eingehenden** ist sein erster Downbeat nach dem + Intro-Beginn, nicht Sekunde 0. Hot Cues gibt es längst — es fehlte nur das + Wissen, wohin damit. +- **Kein Übergang mitten durch einen Drop.** Der häufigste hörbare Fehler. +- Und für die Auswahl: „ein Track mit langem Intro" ist eine Anforderung, die + ein Agent stellen kann. + +### Was daraus geworden ist + +Je Phrase drei Zahlen — Pegel, Bass, Onset-Dichte —, und eine Grenze liegt dort, +wo sich das Klangbild über eine Phrasengrenze hinweg ändert. An gebautem +Material mit bekannter Gliederung sitzen alle fünf Grenzen, und die Namen +stimmen: + +| Sekunde | erkannt | Pegel | Bass | Dichte | +| --- | --- | --- | --- | --- | +| 0,5 | intro | 0,34 | 0,12 | 0,43 | +| 15,5 | aufbau | 0,53 | 0,42 | 0,72 | +| 30,5 | drop | 0,99 | 0,99 | 0,98 | +| 45,5 | break | 0,44 | 0,41 | 0,56 | +| 60,5 | drop | 0,98 | 0,98 | 0,97 | +| 75,5 | outro | 0,20 | 0,06 | 0,25 | + +Gebaut waren die Wechsel bei 15, 30, 45, 60 und 75 Sekunden. Der halbe +Sekundenversatz ist der Anker des Beatgrids: Das Phrasengitter hängt an der +ersten Eins, nicht an Frame 0 — und genau das macht `deckN.entry` zu einem +Downbeat. + +Im Steuerraum stehen `section`, `section_beats_left`, `beats_to_outro`, +`intro_beats`, `entry` und die Aktion `jump_entry`. Damit ist die eigentliche +Regel endlich sagbar: + +```text +when deck1.beats_to_outro < 0 in phrase ramp master.crossfader 1 32 +``` + +**Vier Entwürfe sind am Material gescheitert**, bevor der fünfte stand — ein +Kern über zwei Phrasen, der die zweiphrasigen Abschnitte verschmierte; eine +Schwelle aus Median und Streuung, die sich an ihrer eigenen bimodalen +Verteilung überdeckte; eine z-Wert-Normierung, die aus Rauschen Struktur machte; +und „Break heißt Bass weg", was falsch gemessen war, weil ein Kick allein schon +reichlich Energie unter 160 Hz bringt. Alle vier stehen mit ihrer Begründung im +Code. + +Dazu ein Prüfstein, der selbst untauglich war: Als „gleichförmiges Material" +stand zuerst ein reiner Sinus da, dessen spektraler Fluss über das Stück um +45 % driftet. Damit hätte der Test eine Robustheit gegen etwas verlangt, das in +Musik nicht vorkommt — dieselbe Falle wie bei der Tempo-Schwelle, die an +Klick-Tracks geeicht war. + +**Belegt sind die Grenzen, nicht die Namen.** Ob „lauteste 25 % mit viel Bass" +auf echten Produktionen der Drop ist, weiß erst, wer es an echten Produktionen +mit bekannter Gliederung nachmisst. Bis dahin stehen die drei Zahlen neben jedem +Namen — im Bericht von `musik-analyze` und im Sidecar —, damit man ihm +widersprechen kann. + +## S3 — Der Bogen ✅ + +*Gebaut. Vom Übergang zum Set — siehe [`FAHRPLAN.md`](FAHRPLAN.md#p4--der-bogen-s3-).* + +Ein einzelner guter Übergang ist Handwerk. Ein gutes Set ist Architektur: Aufbau, +Plateau, Bruch, Wiederaufbau — über eine Stunde, nicht über vier Minuten. + +Konkret: eine Ziel-Energiekurve über die Setdauer, gegen die jede Auswahl +begründet wird. Die Warteschlange trägt die Notiz dann nicht mehr nur als +Freitext, sondern als Bezug auf den Bogen: „Plateau halten", „Bruch vorbereiten", +„nach dem Bruch wieder hoch". + +Das ist auch die Stelle, an der Zurückhaltung einprogrammiert gehört. Ein System, +das jeden Übergang gleich lang und gleich weich fährt, klingt nach Automat — +auch wenn jeder einzelne davon sauber ist. + +## S4 — Geste statt Rampe ✅ + +*Gebaut. Formen für Rampen und fünf benannte Griffe — siehe [`FAHRPLAN.md`](FAHRPLAN.md#p3--geste-und-repertoire-s4-).* + +Zwei Erweiterungen, beide klein: + +**Formen für Rampen.** Derzeit linear. Ein Fader, der anfängt zu ziehen, +committet und wieder abflacht, klingt anders als eine Gerade — dieselbe Strecke, +andere Wirkung. Dazu die Crossfader-Kurve, die es schon gibt und die im ersten +Lauf ungenutzt auf weich stand. + +**Ein Repertoire statt eines Handgriffs.** Lange Blende, Bass-Swap, harter +Schnitt auf die Eins, Filter-Sweep, Loop-Ausstieg. Welcher passt, entscheidet +der Kontext: Genre, Energiewechsel, ob beide Tracks ein brauchbares Intro/Outro +haben. Ein DJ, der immer dasselbe macht, ist der langweiligste im Raum — und +genau das ist mein erster Lauf gewesen. + +## S5 — Stems + +*Teuer, aber es räumt den lautesten Fehler weg.* + +Zwei Stimmen gleichzeitig sind der hörbarste Mixfehler überhaupt, und ohne +Trennung lässt er sich nur vermeiden, indem man gar nicht überlagert. Mit Stems: +Stimme des ausgehenden Tracks wegnehmen, Instrumental stehen lassen, drüber die +neue Stimme. Das ist der Punkt, an dem Übergänge aufhören, nach Übergang zu +klingen. + +Steht in der Roadmap als Phase 8 und braucht Streaming von Platte — vier Decks +mit je vier Stems passen nicht mehr in den Speicher. + +## S6 — Der Raum schließt die Schleife + +*Erst hier, weil vorher nichts da wäre, worauf man reagieren könnte.* + +Die Signale von außen existieren, aber im ersten Lauf war der Raum Deko: Ich +habe Werte hineingegeben, und nichts hat darauf reagiert. Das Set war +durchprogrammiert. + +Die Schleife schließt sich, wenn der Trend eines Signals die **Auswahl** und den +**Bogen** verändert, nicht nur einen Regler. Und wenn das System dabei sagt, +warum — „Andrang fällt seit drei Minuten, ich nehme den ruhigeren mit dem langen +Intro statt des Peaks". + +Dann ist es kein Automixer mehr. + +## Reihenfolge und Begründung + +| | Was | Warum dort | +| --- | --- | --- | +| S0 | Phrase als Bezugspunkt | Ein Verb, wirkt auf jeden Mix, blockiert nichts | +| S1 | Kritiker über den Mitschnitt | Macht alles Folgende prüfbar statt behauptbar | +| S1b ✅ | Mitschrift neben den Mitschnitt legen | Ohne die Absicht ist die Phrasenlage nicht messbar | +| S2 ✅ | Strukturanalyse | Voraussetzung für jede musikalische Regel | +| S3 | Set-Bogen | Braucht S2, um Abschnitte zu planen | +| S4 | Gestenformen und Repertoire | Lohnt erst, wenn die Stellen stimmen | +| S5 | Stems | Teuer, braucht Streaming; größter Klanggewinn | +| S6 | Raum steuert Auswahl und Bogen | Braucht S3, sonst gibt es nichts zu lenken | + +S0 und S1 sind zusammen die kleinste Menge, nach der sich das Ergebnis +**belegen** lässt statt nur behaupten. Sie kommen zuerst, auch wenn S2 den +größeren Klanggewinn verspricht. + +S0, S1 und S1b stehen. Was jetzt gemessen werden kann, hat beim ersten Lauf +sofort einen Fehler gezeigt, den niemand vermutet hatte — der eingehende Track +kam fünfzehn Beats neben seiner eigenen Eins herein. Das ist der Auftrag für +S2. + +## Weiter als dieses Dokument + +Dieser Plan reicht bis S6. Was darüber hinaus offen ist — die ungeprüften +Behauptungen, das nie gelaufene Team, die Punkte, die an Hardware und Musik +hängen — steht in [`FAHRPLAN.md`](FAHRPLAN.md). Dort kommen zwei Schichten +**vor** S3 und S4, und zwar aus demselben Grund, aus dem hier der Kritiker vor +der Strukturanalyse steht: Ein Messwerkzeug, das nur an eigenem Material +geeicht ist, misst sich selbst. + +## Was daran nicht Software ist + +- **Material.** Drei Sinustöne und ein Kick haben nichts, was man fühlen könnte. + Ohne Musik mit Struktur ist jeder Übergang eine Rechenaufgabe. +- **Das Interface mit vier Ausgängen.** Ohne Vorhören fehlt dem System das Ohr, + das ein DJ am Kopfhörer hat — es kann den nächsten Track nicht prüfen, bevor + er im Raum ist. +- **Geschmack.** Der Kritiker misst, was messbar ist. Ob ein Set *gut* war, + entscheidet weiterhin jemand, der dabei war. Der Kritiker soll ihm die + Handwerksfehler abnehmen, nicht das Urteil. diff --git a/docs/FAHRPLAN.md b/docs/FAHRPLAN.md new file mode 100644 index 0000000..756572c --- /dev/null +++ b/docs/FAHRPLAN.md @@ -0,0 +1,651 @@ +# Fahrplan: alles, was noch aussteht + +`docs/DJ.md` plant den Weg zu einem AI-DJ, der nicht herzlos klingt. Dieses +Dokument ist weiter gefasst: Es nimmt **alles** auf, was offen ist — die +ungebauten Schichten, die ungeprüften Behauptungen und die Dinge, die an +Hardware, Musik oder Ohren hängen und deshalb nicht von hier aus zu erledigen +sind. + +Wie dort ist die Reihenfolge das Argument, nicht die Liste. + +## Wo das Projekt wirklich steht + +Neun von zehn Phasen sind gebaut, und darüber liegen inzwischen drei Schichten +Messwerkzeug: der **Kritiker**, der den Mitschnitt liest; die **Mitschrift**, die +danebenlegt, was gemeint war; und die **Gliederung**, die weiß, wo im Stück ein +Deck steht. Jede davon hat beim Bauen sofort einen Fehler gefunden, den niemand +vermutet hatte. + +Zwei Dinge stimmen daran trotzdem nicht. + +**Das Messwerkzeug ist selbst ungemessen.** Jede Schwelle in Tempo-, Tonart- und +Strukturerkennung ist an Material geeicht, das ich selbst gebaut habe. In diesem +Projekt ist genau das schon **viermal** die Vorstufe eines Fehlers gewesen: Die +Güteschwelle beim Tempo war an Klick-Tracks geeicht, die zwischen den Klicks +still sind; bei der Tonart wies eine synthetisch geeichte Schwelle vier von fünf +echten Aufnahmen ab; und bei der Gliederung war der Prüfstein „gleichförmiges +Material" ein reiner Sinus, dessen spektraler Fluss um 45 % driftet. Jedes Mal +sah es vorher gut aus. + +**Das Team hat nie existiert.** Der Zweck dieses Projekts ist ein Team von +Agenten, das die Anlage gemeinsam bedient. Gelaufen ist bisher **ein** Bediener +in mehreren Rollen. Die Schutzmechanismen dagegen, dass einer dem anderen den +Regler wegzieht, sind gebaut und einzeln geprüft — erlebt hat sie keiner. + +## Zwei Sätze, die die Reihenfolge tragen + +**Was misst, muss selbst gemessen sein.** Ein Kritiker, dem niemand widerspricht, +ist ein Orakel. Solange die Eichung nur aus eigener Hand kommt, ist jede Zahl in +diesem Repo eine Behauptung über eigenes Material und keine über Musik. + +**Was der Zweck ist, muss zuerst laufen.** Alles Weitere — Bogen, Gesten, Raum — +ist für ein Team gedacht, das es noch nicht gibt. Eine Schicht für einen +Bediener zu bauen und später auf mehrere umzustellen, heißt sie zweimal zu +bauen. + +Daraus folgt eine Reihenfolge, die zwei Dinge vorzieht, die nach außen wie +Umwege aussehen. + +## P1 — Der Prüfstand ✅ + +*Gebaut. Macht die Eichung überprüfbar, ohne neue Musik.* + +Ein Programm, das einen Ordner Tracks und eine **Angabe von Hand** nebeneinander +legt und meldet, wo die Analyse zustimmt und wo nicht: + +```text +musik-pruefstand wahrheit.txt +``` + +Die Wahrheitsdatei liegt neben der Musik, eine Zeile je Track: + +```text +# was ich höre +Nachtschicht.mp3 bpm 124 tonart Am intro 0:00 aufbau 0:32 drop 1:04 outro 5:12 +Alpenglühen.wav bpm 126 +``` + +Abschnitte stehen mit ihrem **Anfang**, nicht als Bereich: Wer zuhört, notiert +„hier fängt das Outro an" und nicht „das Outro geht von … bis …". Jede Angabe +ist einzeln freiwillig — weglassen ist besser als raten. + +Der Bericht sagt je Track und in der Summe, wie weit Tempo, Tonart und +Abschnittsgrenzen daneben liegen. Nicht als Note — als Abstand mit Vorzeichen, +wie beim Kritiker. + +**Warum das zuerst kommt.** Es ist dasselbe Argument, das den Kritiker vor die +Strukturanalyse gestellt hat, und das hat sich ausgezahlt: Der Kritiker hat +sofort gezeigt, dass meine Schätzung des Blendenbeginns vier Sekunden zu spät +lag — eine Zahl, die vorher niemand hatte. + +**Und es macht deinen Beitrag billig.** Ohne Prüfstand heißt „prüf das mal an +echter Musik nach": fünf Tracks laden, Berichte lesen, Zahlen von Hand +vergleichen, mir erzählen, was rauskam. Mit Prüfstand heißt es: Tracks +hinlegen, eine Textzeile je Track schreiben, ein Befehl. Der Aufwand +verschiebt sich vom Nachrechnen zum Hinhören, und hinhören kannst nur du. + +**Was er nicht kann.** Eine Angabe von Hand ist eine Meinung. Zwei Leute setzen +den Anfang eines Drops eine Phrase auseinander, und beide haben recht. Der +Prüfstand meldet deshalb Abweichungen, keine Fehler — und ab welcher Abweichung +etwas kaputt ist, entscheidet weiterhin ein Mensch. + +Er hat am ersten Tag einen Kunden gehabt: Das gebaute Material aus der +Strukturanalyse trägt seine Wahrheit bereits (128 BPM, A-Dur, Grenzen bei +Sekunde 15, 30, 45, 60, 75). Der erste Lauf dagegen: + +```text + Tempo 127.99 gegen 128.00 gehört -0.01 ✓ + Tonart A (11B) gegen A (11B) gehört ✓ + Gliederung 6 erkannt gegen 6 gehört + 0.0s intro Grenze +0.5s (+1.0 Beats) Name ✓ + 15.0s aufbau Grenze +0.5s (+1.0 Beats) Name ✓ + … + ── Davon systematisch: +1.0 Beats auf allen Grenzen ── +``` + +Und dabei kam gleich etwas heraus, das die reine Tabelle verschluckt hätte: +**Jede** Grenze liegt um dasselbe daneben, keine streut. Ein konstanter Abstand +ist eine Tatsache und keine sechs — er sagt etwas über den Nullpunkt (der Anker +des Beatgrids sitzt auf dem ersten erkannten Schlag, die Wahrheitsdatei zählt ab +Dateianfang) und nichts über die Segmentierung. Der Prüfstand trennt beides +seither: gemeinsamer Versatz oben, Rest darunter. Streuen die Abweichungen +stärker, als sie gemeinsam verschoben sind, wird nichts als Versatz erklärt — +sonst würde aus „die Segmentierung wackelt" ein beruhigendes „nur der +Nullpunkt". + +Eine Vorlage zum Ausfüllen liegt in +[`wahrheit-vorlage.txt`](wahrheit-vorlage.txt). + +## P2 — Das Team ✅ (Stufe 1) + +*Gebaut: zwei Verbindungen an einem Pult. Zwei unabhängige Modelle stehen aus.* + +Zwei oder mehr Bediener gleichzeitig an einem Pult, jeder mit eigener +Verbindung, und ein Aufbau, der die Zusammenstöße **absichtlich** herbeiführt: + +- Zwei greifen denselben Fader — die Rampe muss aufgeben, und zwar die richtige. +- Einer streicht den Plan des anderen. Wer merkt es, und woran? +- Zwei merken auf derselben Phrasengrenze etwas vor. Beide feuern; kommt eine + Reihenfolge heraus, die klingt? +- Zwei nehmen denselben Track aus der Warteschlange. +- Einer lädt auf ein Deck, das der andere gerade startet. + +Die Mechanismen dafür sind gebaut: Eine Rampe gibt auf, sobald der Wert nicht +mehr dem entspricht, was sie zuletzt geschrieben hat; die Warteschlange nimmt +denselben Pfad nicht zweimal; der Plan ist das gemeinsame Blatt. **Geprüft ist +jeder Mechanismus für sich, nie ihr Zusammenspiel.** + +Zwei Ausbaustufen, und die Reihenfolge ist wichtig: + +1. **Zwei Verbindungen, ein Modell.** Ein Aufbau, der zwei Sitzungen fährt und + die Fälle oben durchspielt. Reproduzierbar, im Test, ohne API-Kosten. Das + findet die Verklemmungen. +2. **Zwei unabhängige Modelle.** Erst danach, und mit dem Prüfstand daneben. + Das findet, was Menschen und Modelle unterschiedlich verstehen — und das + findet man nicht durch Nachdenken. + +**Warum vor den musikalischen Schichten.** Bogen (P4) und Raum (P5) sind +Entscheidungen darüber, *was als Nächstes kommt*. Wer die für einen Bediener +baut und danach auf ein Team umstellt, baut sie zweimal. Und ein Fehler im +Zusammenspiel fällt in einer Schicht darüber nur schwerer auf, nicht später weg. + +### Was dabei herauskam + +**Der Fund stand vor dem ersten Test.** Die Frage „was sieht ein zweiter +Bediener?" genügte: `takt_starten` gab den Rückgabewert des Taktgebers nicht +weiter, sondern verwarf ihn. Damit ging *jede* Meldung des Plans verloren — +fertig, abgebrochen und vor allem **abgelöst**. Ein Agent, dem ein anderer den +Fader wegnahm, erfuhr es nie und plante weiter auf einer Blende, die seit +zwanzig Sekunden tot war. Für einen einzelnen Bediener fiel das nie auf: Er war +derjenige, der den Regler angefasst hat. + +Jetzt liegen die Meldungen in einem Ring mit laufender Nummer, und +`sub master.events` holt sie ab. Kein einzelner Wert, weil der Taktgeber alle +5 ms läuft und der Server alle 50 ms vergleicht — neun von zehn Zeilen wären +weg, gerade wenn viel gleichzeitig geschieht. Wer zu langsam liest, bekommt +`warnung N Ereignisse verloren`; über MCP, wo ein Abo nicht hält, gibt es +`master.events` und `master.event_count` zum Fragen. + +Neun Zusammenstöße stehen als Test, dazu einer über den echten Socket mit +laufendem Taktgeber und zwei Verbindungen — dort geht es nicht um die Regeln, +sondern darum, ob sich Mutex, Taktgeber und Abo-Thread gegenseitig aushungern. +Sie tun es nicht. + +Was die vorhandenen Mechanismen angeht, hielten sie: Die richtige Rampe gibt +auf, derselbe Track wird nicht zweimal abgenommen, und zwei Aufträge auf +derselben Phrasengrenze feuern beide. + +**Stufe 2 steht aus:** zwei unabhängige Modelle. Die findet, was Menschen und +Modelle unterschiedlich verstehen, und das findet man nicht durch Nachdenken. + +## P3 — Geste und Repertoire (S4) ✅ + +*Gebaut. Klein, sofort hörbar, seit S1 messbar.* + +Zwei Erweiterungen: + +**Formen für Rampen** ✅. `ramp … weich` verteilt dieselbe Strecke als S-Kurve, +`spaet` hält den ausgehenden Track präsent, `frueh` macht den Wechsel zum +Ereignis. Deck und Form stehen hinten in beliebiger Reihenfolge — erkannt wird, +*was* sie sind. Jede Form fängt bei 0 an, kommt bei 1 an und läuft nie zurück; +das sind Wächter, keine Behauptungen. + +Offen bleibt die Crossfader-Kurve, die es schon gibt und die im ersten Lauf +ungenutzt auf weich stand — sie ist kein Code, sondern eine Frage der Benutzung. + +**Ein Repertoire statt eines Handgriffs** ✅. `do master.uebergang + [beats]`. Jeder Griff ist eine Handvoll +gewöhnlicher Protokollzeilen, die durch denselben Weg laufen wie alles andere — +also im Plan stehen, in der Mitschrift, bei den Ereignissen, und mit `cancel` +zurückgehen. Die Antwort nennt jede Zeile: Es gibt nichts, was ein Agent nicht +auch selbst hätte tippen können. + +**Die Anlage wählt dabei nicht aus.** Welcher Griff passt, hängt am Kontext — +Outro, Intro-Länge, Energieunterschied, und vor allem daran, was vorher schon +dreimal gefahren wurde. Seit S2 stehen die Zahlen dafür im Steuerraum; die +Entscheidung gehört dem, der sie begründen kann. Nähme die Anlage sie ab, +verlöre das Set genau den Teil, um den es diesem Projekt geht. + +Offen bleibt der Loop-Ausstieg — er braucht die Schleifen im Zusammenspiel mit +dem Zeitplan und ist ein eigener Schritt. + +Ein DJ, der immer dasselbe macht, ist der langweiligste im Raum — und genau das +war mein erster Lauf. Der Kritiker misst inzwischen Länge und Form jeder Blende, +also lässt sich diesmal belegen, dass sich etwas geändert hat. + +## P4 — Der Bogen (S3) ✅ + +*Gebaut. Vom Übergang zum Set.* + +Eine Ziel-Energiekurve über die Setdauer, gegen die jede Auswahl begründet wird. +Die Warteschlange trägt die Notiz dann nicht mehr als Freitext, sondern als +Bezug auf den Bogen: „Plateau halten", „Bruch vorbereiten", „danach wieder +hoch". + +Hier gehört auch **Zurückhaltung** einprogrammiert. Ein System, das jeden +Übergang gleich lang und gleich weich fährt, klingt nach Automat — auch wenn +jeder einzelne davon sauber ist. + +### Was daraus geworden ist + +`master.arc` trägt die Kurve als Text (`0 0.3, 20 0.7, 45 0.95, 60 0.5`), +`do master.arc_start` setzt die Uhr, und dann steht im Steuerraum: +`arc_target`, `arc_actual`, **`arc_gap`** und `arc_trend`. Damit ist +`when master.arc_gap > 0.3 do master.queue_next` sagbar. + +**Die Ist-Energie kommt aus der Art des Abschnitts, nicht aus dem Pegel.** Der +Pegel der Gliederung ist auf den lautesten Abschnitt *desselben* Tracks bezogen +— der Drop eines leisen Stücks steht dort genauso bei 0,99 wie der eines lauten. +Über Tracks hinweg ist das nicht vergleichbar, und ein Bogen, der solche Zahlen +addiert, rechnet mit Äpfeln. Stattdessen eine grobe Leiter über die sechs +Abschnittsarten. Sechs Stufen sind hier ehrlicher als eine Nachkommastelle, die +niemand einlösen kann. + +Ohne `arc_start` gibt es keinen Ort auf dem Bogen, und dann wird auch keiner +behauptet: leer statt null. Eine Kurve ohne Uhr ist ein Bild, kein Maßstab. + +Ein Fund nebenbei, vom Wächter über alle schreibbaren Controls: `master.arc` +nimmt Text an, aber nicht *jeden* Text. Ein Feld, das Text annimmt, ist nicht +dasselbe wie eines, das beliebigen Text annimmt — und ein unlesbarer Bogen wird +abgewiesen statt halb übernommen. Ein Set gegen eine Kurve zu fahren, die +niemand gemeint hat, wäre schlimmer als eines ohne Kurve. + +**Der fünfte Griff steht ✅.** `schleife` legt den Ausgehenden in eine Schleife +und wechselt darüber — der einzige Griff, der ihm *Zeit gibt*: Ein Track, der in +vier Beats zu Ende wäre, hält so noch eine ganze Phrase durch. + +Er hat zwei Fehler aufgedeckt, die beide erst am laufenden Programm sichtbar +wurden. Ein Deck in einer Schleife wiederholt seinen Beat, und alles, was daran +getaktet ist, wiederholt sich mit: Der Crossfader fuhr sieben Mal hin und her, +und die Zeile, die die Schleife wieder lösen sollte, kam nie an — ihr Beat lag +hinter dem Schleifenende. Seitdem bricht ein Auftrag ab und sagt es, wenn sein +Taktgeber zurückspringt, und `in deckN ` erlaubt, den Takt eines +*anderen* Decks zu nehmen. Der Griff taktet alles nach dem eingehenden. + +**Die Zurückhaltung steht ✅.** Das Repertoire aus P3 macht Abwechslung möglich, +erzwingt sie aber nicht, und ein System, das viermal hintereinander dieselbe +Blende wählt, klingt weiterhin nach Automat. `master.repeats` sagt, wie viele +der letzten Übergänge gleich waren, `master.transitions` zeigt die letzten acht. +Damit ist `when master.repeats > 2 do master.uebergang filter` sagbar. + +Die Zählregel war der ganze Aufwand: **gezählt wird der Seitenwechsel des +Crossfaders, nicht sein Wert.** Eine Rampe schreibt bei jedem Takt in denselben +Regler; auf den Wert gezählt stünde nach einer einzigen Blende eine +Wiederholung von 80 da — die Zahl, die vor Eintönigkeit warnen soll, wäre selbst +der Grund, sie zu ignorieren. Beide Wächter dafür wurden geprüft, indem der +Fehler wieder eingebaut wurde: 4 statt 1, und `schnitt` statt `weich/8`. + +Was der letzte Schritt einer Blende und ein Schnitt gemeinsam haben, ist der +geschriebene Wert; unterscheiden lassen sie sich nur über den Schreiber. Deshalb +sagt der Zeitplan dem Pult um den einen Schreibvorgang herum, dass gerade eine +Rampe fährt. Und ein angeforderter Griff wird nur *vorgemerkt* — eingetragen +wird er, wenn der Crossfader ankommt, weil ein abgelöster Griff nicht +stattgefunden hat. + +Der zweite Fund kam nicht aus einem Test, sondern aus dem laufenden Programm: +Nach `set channel1.fader 1; set master.crossfader -1; set deck1.play 1` stand +dort bereits `transitions schnitt` — niemand hatte übergeblendet, es lief ja +erst ein Deck. Seitdem gilt zusätzlich, dass **zwei Decks laufen müssen**; alle +fünf Griffe starten das eingehende Deck, bevor der Fader sich bewegt. + +Die Grenzen stehen im Modul: Wer nur mit den Kanalfadern mischt und den +Crossfader stehen lässt, taucht nicht auf; und läuft der ausgehende Track aus, +bevor die Blende ankommt, fehlt sie. Dann sagt die Zahl nichts, statt etwas +Falsches zu sagen. + +Für ein Team ist der Bogen zugleich das, worüber man sich einig sein muss: Ohne +ihn verhandeln zwei Agenten über den nächsten Track ohne gemeinsamen Maßstab. + +## P5 — Der Raum schließt die Schleife (S6) ✅ + +*Brauchte P4, sonst gäbe es nichts zu lenken.* + +Die vier Signalplätze existierten, aber im ersten Lauf war der Raum Deko: Werte +gingen hinein, und nichts hat darauf reagiert. Das Set war durchprogrammiert. + +Jetzt beugt der **Trend** eines Signals das Ziel des Bogens (`master.room`, +`master.room_bend`) und damit `arc_gap` — und `do master.search_next` sortiert +die Sammlung danach, mit einem Grund je Zeile: + +```text +weil Andrang fällt (-0.40/min), Ziel 0.70 → 0.50; es läuft 0.90, weniger gesucht (-0.40) +track 126.00 8A /musik/blaue-stunde.wav Blaue Stunde — Energie 0.48 (-0.02 zum Ziel 0.50), harmonisch +``` + +Drei Entscheidungen tragen das: + +**Der Raum verschiebt das Ziel, nicht die Kurve.** `master.arc` bleibt, was +jemand aufgeschrieben hat; `arc_curve` zeigt es unverändert. Am Ende des Abends +lässt sich so vergleichen, was geplant war und was geschah — eine Anlage, die +ihren eigenen Plan überschreibt, hat nichts mehr, woran sie sich messen ließe. + +**Gebeugt wird nach dem Trend, nicht nach dem Wert.** Die Höhe eines Signals ist +nichts Vergleichbares: Was „0,6 Andrang" bedeutet, weiß nur, wer den Sender +geschrieben hat. Eine Änderung ist dagegen eine Aussage über denselben Sender. +Das ist derselbe Fehler, der beim Ist-Wert des Bogens schon einmal auffiel — +zweimal fast hineingelaufen, zweimal an derselben Stelle. + +**Höchstens 0,25 Beugung.** Ein hängender oder falsch skalierter Sender darf das +Set nicht übernehmen; das wäre schlimmer als ein Set, das den Raum ignoriert. + +Die Energie eines Tracks kommt aus seiner Gliederung, nach Länge gewichtet, und +liegt in der Analyse-Datei neben dem Track statt in der Datenbank — sie hängt am +Inhalt, nicht am Eintrag, und ein neu analysierter Track bringt sie mit, ohne +dass jemand die Sammlung nachpflegt. **Ein nicht analysierter Track steht hinten +und sagt das.** + +**Und sie misst keine Lautstärke.** Die Abschnittsarten werden je Track gegen +dessen eigene Quantile benannt; der Drop eines leisen Stücks heißt genauso +„Drop". Was herauskommt, ist, wie viel seiner Länge ein Track auf seinem eigenen +Höhepunkt verbringt — für den Aufbau eines Sets die brauchbarere Größe, für die +Frage „welches Stück ist härter?" die falsche. Das steht so im Modul, weil genau +diese Verwechslung in diesem Projekt schon zweimal ein Fehler war. + +Offen bleibt die Quelle. Ein Mikrofonpegel, eine Umfrage auf dem Handy, ein +Mensch, der im Chat „wird voller" tippt — das ist eine Entscheidung über den +Aufbau im Raum und keine Softwarefrage. Bis dahin ist die Schleife gebaut und +läuft mit der Hand am Regler. + +## P6 — Stems (S5) ✅ ohne Streaming + +*Teuer, räumt aber den lautesten Fehler weg.* + +Zwei Stimmen gleichzeitig sind der hörbarste Mixfehler überhaupt, und ohne +Trennung lässt er sich nur vermeiden, indem man gar nicht überlagert. Jetzt +geht: `set deck1.stem3_level 0` — Stimme weg, Instrumental steht. + +Gemessen am laufenden Programm, zwei Mitschnitte desselben Stücks: Energie bei +1500 Hz von 9,8 · 10⁻⁵ auf 4,9 · 10⁻⁹, Gesamtpegel nur von 0,061 auf 0,039. +Die Stimme ist um den Faktor 20 000 weg, Drums und Bass stehen. + +**Die Spuren liegen neben der Datei**, in einem Ordner gleichen Namens mit der +Endung `.stems`. Kein neues Dateiformat, keine neue Abhängigkeit, und genau die +Form, in der die gängigen Trennwerkzeuge ihr Ergebnis ablegen. Das +Hersteller-Stem-Format wäre die Alternative gewesen — eine MP4-Datei mit fünf +AAC-Spuren, die jemandem gehört. Getrennt wird hier nicht: Das ist Arbeit für +ein neuronales Netz, ein eigenes Werkzeug und eine eigene Lizenzfrage. + +**Die schwierige Stelle war die Zeitstreckung.** Sie sucht in jedem Hop die +Stelle, an der die Wellenform am besten anschließt. Vier Spuren getrennt zu +strecken hieße vier verschiedene Entscheidungen — die Spuren liefen um +Millisekunden auseinander, und was zusammen klang, klänge verwaschen. Gesucht +wird deshalb auf der Summe, angewandt auf jede Spur; gemischt wird beim Lesen, +was denselben Wert ergibt und einen statt vier Akkumulatoren kostet. Im +Audio-Callback wird dafür nichts angelegt: Die Spurzeiger stehen auf dem Stapel. + +**Offen bleibt das Streaming von Platte.** Vier Spuren kosten das Fünffache +eines Tracks, weil die Summe daneben stehen bleibt — rund 500 MB je Deck bei +fünf Minuten. Für die zwei Decks, die es gibt, geht das; für die vier, die der +Plan vorsieht, nicht. Der Abspielpfad ist der einzige Teil mit +Echtzeitauflagen, und Platten-I/O gehört dort zuletzt hinein — es ist der +richtige Schritt, aber nicht derselbe wie dieser. + +## Was aus alldem zusammen wurde + +Die einzelnen Schichten waren je für sich geprüft. Beim ersten Versuch, sie +hintereinander laufen zu lassen, kamen vier Fehler heraus, die kein Test hätte +zeigen können — jeder davon erst beim **zweiten** Griff: + +1. Der ausgehende Track lief nach dem Griff weiter, und der nächste `uebergang` + wurde abgewiesen: „es laufen mehrere Decks". +2. Getaktet wurde nach dem ersten Deck mit Beatgrid — beim zweiten Griff also + dem gerade gestoppten. Ein stehendes Deck hält den Plan an; der Übergang + stand still, angenommen und nie gefahren. +3. Eine Rampe, die im selben Takt endet, in dem ihr Deck stehenbleibt, bekam + ihren letzten Schritt nicht mehr und blieb für immer im Plan liegen. +4. Ein frisch geladenes Deck stand auf Sekunde 0 statt auf dem ersten Downbeat — + genau der Fehler, den die Mitschrift ganz am Anfang aufgedeckt hatte. Behoben + war bis dahin nur das Werkzeug dagegen, nicht die Ursache. + +Und beim ersten Übergang, der sich **selbst** auslöste, kam der fünfte: Der +ausgehende Track lief mitten im Bass-Swap aus, und der Plan wartete danach für +immer auf einen Beat, der nicht mehr kam. + +Seitdem läuft eine Kette aus Liste, Laden und Griff ohne Handgriff dazwischen — +und was schiefgeht, steht in `master.events`, statt still zu bleiben. + +## Was an dir hängt + +Diese Punkte kann ich nicht abarbeiten. Was ich tun kann, ist sie **billig +machen** — deshalb steht der Prüfstand vorn. + +| | Was fehlt | Was ich vorbereite | +| --- | --- | --- | +| **M1** | **Musik mit Angabe, wo Intro, Drop und Outro anfangen.** Der größte Hebel überhaupt: Damit wird die Benennung der Abschnitte aus einer Vermutung ein Befund | steht: `musik-pruefstand` und [`wahrheit-vorlage.txt`](wahrheit-vorlage.txt) | +| **M2** | **Die Suno-Prompts zu den Tracks** — daraus ergibt sich die Tonart, und damit ist prüfbar, ob die Erkennung stimmt | steht: derselbe Prüfstand, Spalte `tonart` | +| **M3** | **Ein Interface mit vier Ausgängen.** Phase 1 ist bis auf einen Schritt abgenommen: ob der Treiber die Pufferkanäle 3/4 auf die Buchsen 3/4 legt. Ohne Vorhören fehlt dem System außerdem das Ohr am Kopfhörer | vierkanaliges Rendern steht (`musik-mix --cue`), gemessen ist es auch | +| **M4** | **Einmal hinhören, ob die Zeitstreckung bei ±8 % taugt.** Gemessen ist sie, gehört nie | eine Datei mit demselben Takt bei 0,92 / 1,00 / 1,08 zum Vergleichen | +| **M5** | **Eine echte `collection.nml`** — der Traktor-Import ist nie gegen eine gelaufen | der Import steht, der Testfall ist synthetisch | +| **M6** | **Material unter 70 BPM.** N1 hat die halbe Frage beantwortet: Es liegt an der Grenze, nicht am Verfahren — und darunter meldet der Detektor nicht nichts, sondern falsch. Was fehlt, ist echtes langsames Material, an dem die Oktavwahl ohne enges Fenster geprüft werden kann | N1 ✅ an gebautem Material; das gebaute reicht für die Oktavfrage nicht | + +Vier davon sind mit denselben fünf Tracks erledigt, wenn du sie noch einmal +hochlädst — und diesmal schreibe ich sie sofort ins Repo, statt sie im +Container liegen zu lassen. Drei Container-Resets haben sie inzwischen +verschluckt. + +## Nachweise, die ich allein führen kann + +Kein Feature, sondern das Einlösen offener Behauptungen. Läuft nebenher. + +**N1 — Langsames Material selbst bauen ✅.** Die Frage war, ob das Verfahren +unter `MIN_BPM` trägt. Die Antwort war ja — und der eigentliche Fund ein +anderer. + +Gemessen an gebautem Material von 38 bis 128 BPM, mit zwei unabhängigen +Gegenproben (Median der Hüllkurven-Abstände und dieselbe Grobsuche mit +tieferer Grenze): Zwischen **46 und 58 BPM findet die Autokorrelation die +richtige Periode**, deutlich und stabil — beide Gegenproben stimmen auf ein +halbes BPM überein. Das Verfahren trägt dort also. + +**Der Fund:** Eine harte Untergrenze weist unter sich nicht ab — sie meldet +falsch. Ein Stück mit 66 BPM bekam ein Grid mit **71,51 BPM**, eines mit 68 BPM +eines mit 69,88, und zwar mit hoher Deutlichkeit: Innerhalb des abgeschnittenen +Suchfensters ragt die beste Verschiebung sauber heraus, sie sitzt nur am Rand. +Ein Grid, das um 8 % danebenliegt, ist schlimmer als keines — jeder Beat +driftet, Sync zieht das andere Deck mit, und nichts sagt es. + +**Zwei Auswege wurden gebaut und beide wieder zurückgenommen.** Sie stehen +hier, weil das Zurücknehmen das Ergebnis ist: + +1. *Das Fenster weiter aufmachen und knapp über der neuen Grenze abweisen.* Das + lädt den Halbtempo-Fehler ein, und zwar sofort messbar: Bei Snare auf zwei + und vier greift die Grobsuche auf den Zweitakt-Zyklus, und der Demo-Track mit + 124 BPM bekam gar kein Grid mehr. Einen Backbeat hat fast jede Musik — der + Preis ist also nicht die Ausnahme, sondern der Normalfall. +2. *Den Rand erkennen und dann abweisen.* Geht lokal nicht. Nachgemessen sitzt + ein echtes Stück mit 71 BPM bei 0,989 · Fensterrand **näher** am Rand als das + falsche 66 bei 0,975, und „die Korrelation steigt am Rand noch" trifft 92 und + 128 BPM genauso. Die nötige Auskunft — wo die eigentliche Spitze liegt — + steht per Konstruktion außerhalb des Fensters. + +**`MIN_BPM` bleibt darum bei 70, und der Fehler bleibt bestehen.** Material +unter 70 BPM bekommt ein Grid, dem nicht zu trauen ist. Das steht jetzt an der +Konstante selbst, damit es niemand übersieht, der daran rührt; ein Test hält +fest, dass 71, 75 und 92 BPM nicht auf die Hälfte kippen, damit ein zweiter +Anlauf auf das Fenster sofort rot wird. + +Der Weg dahin führt nicht über die Schwelle, sondern über die Oktavwahl: Erst +wenn die Entscheidung zwischen Periode und halber Periode ohne enges Fenster +trägt, darf das Fenster aufgehen. Ob die 140, 150 und 174 BPM, die in der +Messung auf die Hälfte kippten, am Fenster lagen oder am Zweitakt-Zyklus des +gebauten Materials, lässt sich mit gebautem Material **nicht entscheiden** — +genau da hört es auf, und hier hilft nur M1. + +**N2 — Die Gliederung an schwierigen Fällen ✅.** Ein Track ohne Outro, einer +ohne Intro, einer mit zwei Breaks, einer mit einem Tempowechsel. `musik-material +` baut sie und legt die Wahrheitsdatei daneben; `musik-pruefstand` hält +die Analyse dagegen. + +Der erste Lauf: **13 von 23 Namen richtig** — und die Fehler waren nicht +verstreut, sondern ein Muster. Viermal wurde ein Drop zu „Teil". + +Die Ursache war eine einzige Zeile, und sie steckte tiefer, als sie aussah: Ein +Drop war, wer über dem **75. Perzentil** des Bassbands lag. Das ist rangbasiert, +und darin steckt eine Behauptung über die Musik, die niemand aufgestellt hat — +höchstens ein Viertel der Abschnitte kann je ein Drop sein. Ein Stück mit vier +Drops bekam einen; die Schwelle lag *zwischen* den Drops. + +Jetzt entscheidet der Pegel als **Anteil am lautesten Abschnitt** — eine Höhe, +kein Rang. Vier Drops dürfen vier Drops sein. Dieselbe Umstellung trennt auch +Aufbau und Break sauber, und zwar an dem, was sie musikalisch unterscheidet: +Ein Aufbau kommt von etwas Leiserem und führt in den Drop, ein Break fällt aus +einem Drop heraus. Am Pegel allein sind sie nicht zu trennen (0,48 gegen 0,40). + +Danach: **21 von 23**. Die zwei Reste liegen beim Tempowechsel, und dort steigt +schon die Segmentierung aus. + +**Die Grenze ist echt und steht jetzt im Modul: ein Tempo je Track.** Gerechnet +wird auf einem Beatgrid, und ein Beatgrid hat ein Tempo. Springt es mitten im +Stück — ein Mitschnitt zweier Tracks in einer Datei —, stimmen die +Phrasengrenzen danach nicht mehr, und die Gliederung findet ab dort nichts: +drei von sechs Abschnitten, alle drei davor. Ein zweites Grid wäre eine eigene +Schicht (Tempokurve statt Tempo), und die braucht man erst, wenn solche Dateien +im Ordner liegen. + +Das ist bereits der **sechste** Entwurf einer Schwelle in dieser Schicht, der am +Material gescheitert ist. Gebautes Material findet solche Fehler; es ersetzt +aber keine echte Musik mit gehörter Wahrheit (M1) — die bleibt der Prüfstein. + +**N3 — Der Kritiker gegen sich selbst ✅.** Aus dem einen gemessenen Wert +(3,7 s zu spät) ist eine Verteilung geworden. `musik-streuung ` fährt +24 Sets durch die **echte Anlage** — echter Mixer, echtes Pult, echtes +Repertoire, echter Mitschnitt, echte Mitschrift — und hält danach die Schätzung +des Kritikers gegen das, was die Mitschrift festgehalten hat. + +**Die Verteilung:** Median **+2,0 s**, im Betrag 2,4 s, Spanne −1,4 bis +7,6 s +über 20 gefundene Übergänge. Der ursprüngliche Einzelwert liegt darin: Er +stammt von einer 32-Beat-Blende, und die haben hier im Mittel +4,9 s. + +**Der Fehler hängt an der Länge des Übergangs, nicht am Zufall:** + +| Übergang | Fehler im Mittel | Unschärfe, die der Kritiker angibt | +| --- | --- | --- | +| Schnitt (0 Beats) | **−1,1 s** | 0,2 s | +| 16 Beats | +1,5 s | 4,5 s | +| 32 Beats | +4,9 s | 4,2 s | +| 64 Beats | +7,6 s | 9,0 s | + +Ein harter Schnitt wird also auf ein Fenster genau gefunden — und eher zu +früh als zu spät. Je länger die Blende, desto später die Schätzung, ungefähr +ein Viertel der Blendendauer. Das ist kein Zufallsfehler, sondern der Preis +dafür, dass der Anfang einer Blende per Konstruktion unhörbar ist. + +**Die angegebene Unschärfe trägt.** In 16 von 20 Fällen deckt sie den +tatsächlichen Fehler. Eine Fehlerspanne, die den Fehler nicht deckt, wäre +schlimmer als keine — sie beruhigt. + +**Der Fund: eine lange Blende wird gar nicht bemerkt.** Vier von 24 Übergängen +hat der Kritiker übersehen, drei davon Blenden über 64 Beats. Der Grund steht +in seiner eigenen Konstante: Er vergleicht das Klangbild mit dem von vor +`ABSTAND_SEK` = 16 s. Dauert die Blende länger als das, liegt der Vergleich +selbst schon mitten in ihr, und der Unterschied reißt die Schwelle nie. Bei +128 BPM sind 64 Beats 30 Sekunden — **eine Blende über zwei Phrasen ist für +den Kritiker unsichtbar.** Das ist nicht mit einer größeren Zahl behoben: Ein +weiterer Rückblick verwechselt zwei aufeinanderfolgende Tracks. + +**Zwei Anker waren falsch, bevor einer stimmte**, und beide gaben Zahlen aus, +die nach Messwerten aussahen. Der erste nahm den ersten Crossfader-Eintrag +überhaupt — das war das Einrichten, Sekunde 0, und gemessen wurde danach der +Abstand zum Dateianfang. Der zweite nahm die *Bestellung* des Griffs: 24,00 s, +sauber, in jedem Set gleich. Dazwischen liegt aber die Wartezeit auf die +Phrase, hier sechs Sekunden — dem Kritiker wären vier Sekunden Verspätung +angeschrieben worden, die in Wahrheit sein Vorsprung waren. Drei Wächter +halten den Anker jetzt fest. + +**Und der Prüfstand hat zuerst sich selbst gemessen.** Der erste Durchgang lief +so viel schneller als die Wiedergabe, dass der Ring des Mitschnitts überlief: +1,2 von 2,1 Millionen Frames verworfen, mehr als die Hälfte. Die Datei war +lesbar, die Mitschrift passte zu ihr, und die Zahlen sahen aus wie Messwerte. +Seitdem läuft die Schleife am Zügel, und ein Set mit auch nur einem verworfenen +Frame wird nicht gemessen, sondern gemeldet. + +**Was das nicht ist:** eine Aussage über Musik. Das Material ist gebaut — Kick, +Bass, ein Dur-Dreiklang —, und zwei solche Stücke unterscheiden sich harmonisch +deutlicher als zwei Tracks einer Platte. Die Streuung hier ist deshalb eher die +**untere Schranke** des Fehlers als sein Erwartungswert. Was sie trägt, ist das +Vorzeichen, der Zusammenhang mit der Länge und der blinde Fleck bei 16 s. + +**N4 — Die ganze Kette einmal durch ✅.** Nicht der Kritiker gegen ein +gebautes Set, sondern ein Agent gegen die laufende Anlage: Mix-Engineer → MCP → +Gatter → Protokoll → Mixer → Mitschnitt → Kritiker. Der Agent liegt in +`spaces/musik/` in [Flissel/Vibemind_V1][vm], der Lauf ebenfalls; die Zahlen +stehen dort in `docs/ABNAHME.md`. + +Zweimal gefahren, gleiches Ergebnis: gewählt wurde `blende` über 32 Beats mit +genanntem Grund, gesetzt 0,04 Beats in die Phrase, gefahren 32,0 von 32 Beats +in 15,0 s, Tempo 127,99 → 127,96 BPM, kein Frame verworfen. Der Kritiker fand +den Übergang wieder und ordnete ihn dem Griff zu. + +**Der Fund war ein Fehler im Kritiker.** Sein Pegelbericht rechnet einen Wert +je Sekunde, suchte den passenden aber mit zwei Sekunden je Wert — und beschrieb +damit systematisch eine Stelle bei halber Zeit. Konkret: gemeldet wurden „64 % +Einbruch in der Mitte" der Blende samt Rat zur Crossfader-Kurve; gemeint war +der Break des *ausgehenden* Tracks, fünfzehn Sekunden vor dem Griff, an dem +kein Regler bewegt worden war. Nichts daran sah nach einem Fehler aus: Der +Bericht war vollständig und die Zahlen plausibel. Behoben über `PEGEL_SEK` und +`pegel_um`, letzteres eigens herausgezogen, damit eine Prüfung die Umrechnung +überhaupt sehen kann. + +Mit dem richtigen Fenster sind es 79 % — und die Ursache ist eine andere: Die +Ränder sind verschieden laut (0,381 davor, 0,200 danach), ein Drop mit Kick und +Bass ging in ein reines Akkord-Intro. Der Kritiker trennt die beiden Fälle +jetzt und nennt beide Ränder, statt in beiden „das ist die Kurve" zu sagen. + +**Vier Anläufe davor haben nichts gemessen**, und drei davon waren die Anlage, +die sich weigerte, etwas zu behaupten: eine Freigabe im falschen Format, ein +Mitschnitt bei geschlossenen Kanalzügen („der Mitschnitt ist still"), zwei +Stücke in derselben Tonart (der Kritiker vergleicht Chroma — für ihn ein +Stück), und ein Mitschnitt, der kürzer war als der Rückblick von 16 s. + +[vm]: https://github.com/Flissel/Vibemind_V1 + +## Der Anschluss an VibeMind + +Der Plan dafür steht in [VIBEMIND.md](VIBEMIND.md) und nicht hier, weil er zwei +Repos betrifft. Kurz: Die Voraussetzungen sind nachgemessen erfüllt — kein +Copyleft ohne permissiven Zweig über alle 474 Pakete, und die Steuerung ist seit +jeher Schnittstelle statt UI-Innenleben. Freigabe-Gatter, Umzug der Brücke, +Space und Mix-Engineer stehen (siehe N4); es fehlt der Eintrag im Manifest, und +der wartet auf den Merge dieses Zweigs. + +**Und der Generator ist nicht angefangen** — keine Zeile in `crates/`. Suno hat +weiterhin keine öffentliche API; erster Adapter wäre ElevenLabs Music. Das ist +die größte Lücke zwischen dem, was das Projekt tut, und dem, wofür es gebaut +wird. + +## Kleine Schulden + +Nichts davon blockiert etwas; sie stehen hier, damit sie nicht vergessen werden. +Pitch Bend; Reverb als fünfter Effekt; FLAC statt WAV für den Mitschnitt; mehr +als zwei Decks (heute fest verdrahtet); MIDI-Controller (Phase 10). + +## Reihenfolge und Begründung + +| | Was | Warum dort | Hängt an | +| --- | --- | --- | --- | +| P1 ✅ | Prüfstand | Macht jede spätere Zahl prüfbar und deinen Beitrag billig | — | +| P2 ◐ | Das Team | Der Zweck des Projekts; alles darüber wäre sonst zweimal gebaut | Stufe 2: zwei Modelle | +| P3 ✅ | Geste und Repertoire | Sofort hörbar, seit S1 messbar | — | +| P4 ✅ | Der Bogen | Braucht S2; für ein Team der gemeinsame Maßstab | P2 | +| P5 ✅ | Der Raum | Braucht den Bogen, sonst gibt es nichts zu lenken | P4, Quelle | +| P6 ✅ | Stems | Größter Klanggewinn; Streaming von Platte steht noch aus | — | +| M1–M6 | Musik, Gerät, Ohren | Nicht von hier aus zu erledigen | dir | +| N1–N4 | Nachweise | Läuft nebenher | P1 | + +**P1 und P2 sind zusammen die kleinste Menge, nach der das Projekt das prüfen +kann, wofür es gebaut ist.** Sie kommen zuerst, obwohl P3 schneller klingt — und +P2 hat den Aufwand schon beim Hinsehen gerechtfertigt. + +## Was hier bewusst nicht steht + +**Eine Note für ein Set.** Der Kritiker misst Handwerk. Ob ein Set gut war, +entscheidet weiterhin jemand, der dabei war — und das soll so bleiben. + +**Eine Generierungsschicht.** Suno hat keine öffentliche API (Stand August +2026), und der Adapter dafür steht in `docs/APIS.md`. Er hängt an Zugang, nicht +an Arbeit hier. + +**Termine.** Jede Schicht in diesem Projekt hat unterwegs mindestens einen +Entwurf verworfen, weil das Material widersprochen hat — die Strukturanalyse +allein vier. Eine Schätzung, die das nicht einpreist, wäre gelogen; eine, die es +einpreist, wäre keine Schätzung mehr. diff --git a/docs/FREIGABE.md b/docs/FREIGABE.md new file mode 100644 index 0000000..fcf24fb --- /dev/null +++ b/docs/FREIGABE.md @@ -0,0 +1,230 @@ +# Freigabe: was ein Agent von sich aus darf + +Spezifikation, kein Bericht. Sie beschreibt, was gebaut werden soll, und ist so +geschrieben, dass sich hinterher prüfen lässt, ob es gebaut wurde. + +Stand: **gebaut**, September 2026. Was hier steht, steht auch im Code — +`mcp/musik/freigabe.py`, `mcp/musik/tests/test_freigabe.py`, `note` in +`crates/control/src/protokoll.rs`. + +Am laufenden Programm nachgefahren: ohne Freigabe abgewiesen, mit einer für +`mischen` durchgelassen, `datei` daneben weiterhin zu; zwei Freigabe-Fenster +ergaben zwei Notizen in der Mitschrift, fünf Aufrufe unter einem Fenster genau +eine; und die Datei mitten im Betrieb gelöscht wirkte beim nächsten Aufruf, +ohne Neustart. + +## Warum überhaupt + +VibeMind lässt lesende MCP-Werkzeuge frei laufen und schickt schreibende durch +ein Gatter (`mcp/docker/approval.py`, Default-Deny, Erlaubnisliste in der +Umgebung). Die Musik-Brücke kennt das nicht: Alle sechzehn Werkzeuge fahren +sofort. Als eigenständiges Werkzeug hinter einem Unix-Socket ist das richtig — +wer den Socket öffnen darf, darf auch auflegen. Als Space wäre es ein Bruch mit +dem Sicherheitsmodell der Umgebung. + +Der naheliegende Weg — jeder Schreibzugriff einzeln — ist für ein Pult +unbrauchbar. Sechzehn Takte sind bei 128 BPM dreißig Sekunden, und ein Übergang +besteht aus sieben Zeilen, die auf Phrasengrenzen fallen müssen. Wer dazwischen +auf eine Bestätigung wartet, hat keinen Übergang, sondern einen Abbruch. + +Die Auflösung ist keine Ausnahme vom Modell, sondern eine andere **Körnung**: +Freigegeben wird ein Bereich für die Dauer eines Sets, nicht ein Aufruf. + +## Wo das Gatter sitzt — und wogegen es nicht hilft + +Es sitzt in der **MCP-Brücke**, nicht im Steuerraum. + +Der Steuerraum ist die Schnittstelle für alles, was bedient: Oberfläche, +`nc`, Agenten. Ein Gatter dort träfe die Hand am Regler genauso wie das Modell, +und das wäre falsch — wer den Socket öffnen darf, ist bereits autorisiert. + +Daraus folgt eine Grenze, die dazugehört: + +> **Das Gatter schützt davor, dass ein Agent seine Werkzeuge missbraucht. Es +> schützt nicht vor etwas, das bereits Socket-Zugang hat.** Ein Agent mit einem +> Shell-Werkzeug oder einem freien Dateizugriff umgeht es. In VibeMind sind +> Werkzeuge einzeln freigeschaltet; dass diese beiden nicht dabei sind, ist +> Aufgabe dessen, der den Space einrichtet, und steht deshalb in `AGENTS.md` +> als harte Regel. + +Eine Spezifikation, die diese Grenze verschweigt, verspricht Sicherheit, die +sie nicht hat. + +## Vier Klassen + +Klassifiziert wird nach dem, was ein Aufruf **berührt**, nicht nach seinem +Namen. Es gibt über zweihundert Controls; eine Liste von Namen wäre nach dem +nächsten Feature falsch. + +| Klasse | Was hineinfällt | Voreinstellung | +| --- | --- | --- | +| `lesen` | `musik_status`, `musik_list_controls`, `musik_get`, `musik_search`, `musik_next`, `musik_queue` | **frei** | +| `mischen` | Fader, Trim, EQ, Filter, Crossfader, Cue, Summenlautstärke, Stem-Pegel | Set-Freigabe | +| `spielen` | `play`, `cue`, `jump_*`, `sync`, `tempo`, `keylock`, Loops, Hot Cues | Set-Freigabe | +| `zeit` | `musik_ramp`, `musik_schedule`, `musik_when`, `musik_uebergang`, `musik_cancel` | Set-Freigabe | +| `datei` | `master.record`, `record_stop`, `deckN.load`, `musik_queue_add` | **einzeln** | +| `dramaturgie` | `master.arc`, `arc_start`, `master.room`, `musik_signal`, `queue_note` | **einzeln** | +| `erzeugen` | Generator-Aufrufe (siehe [GENERATOR.md](GENERATOR.md)) | **nie ohne Mensch** | + +Sieben Klassen, vier Voreinstellungen. Die Trennung von `mischen` und `spielen` +sieht nach Haarspalterei aus und ist keine: Ein Agent, der nur mischen darf, +kann einen laufenden Übergang zu Ende fahren, aber keinen neuen Track starten. +Das ist eine sinnvolle Zwischenstufe für einen Assistenten am Pult. + +**`datei` und `dramaturgie` sind bewusst nicht in der Set-Freigabe.** Ein +Ladevorgang nimmt einen Pfad entgegen und berührt das Dateisystem; der Bogen +legt fest, was das Set überhaupt vorhat. Beides ist kein Handgriff, der in +dreißig Sekunden geschehen muss. + +## Die Set-Freigabe + +### Sie kommt von außen, nicht über MCP + +Ein Werkzeug `musik_freigabe_erteilen` wäre sinnlos: Ein Agent, der sich selbst +freigeben kann, ist nicht eingeschränkt. Die Freigabe kommt deshalb aus einer +**Datei, die der Agent nicht anfassen kann** — ihr Pfad steht in +`MUSIK_FREIGABE_DATEI`. + +Eine Datei und keine Umgebungsvariable, aus einem einzigen Grund: **Widerrufen +muss ohne Neustart gehen.** Eine Variable steht fest, sobald der Prozess läuft. +Wer mitten im Set merkt, dass der Agent Unsinn macht, löscht die Datei, und der +nächste Aufruf ist abgewiesen. + +### Format + +```text +# Freigabe für den Freitagabend +klassen mischen spielen zeit +bis 2026-09-03T23:30:00Z +von felix +``` + +- `klassen` — durch Leerzeichen getrennt, aus der Tabelle oben. +- `bis` — RFC 3339, **mit Zeitzone**. Ohne Zeitzone wird die Zeile abgewiesen, + nicht als Ortszeit geraten. +- `von` — freier Text, landet in der Meldung und in der Mitschrift. +- Zeilen mit `#` und Leerzeilen werden übergangen. + +Gelesen wird **bei jedem Aufruf**. Das kostet einen `stat` und einen kurzen +Read; gegen einen Werkzeugaufruf, der ohnehin über eine Prozessgrenze geht, ist +das nichts. Zwischenspeichern wäre genau die Optimierung, die das Widerrufen +wieder kaputt macht. + +### Wann sie nicht gilt + +Fail-closed, in jedem dieser Fälle: + +- `MUSIK_FREIGABE_DATEI` ist nicht gesetzt +- die Datei fehlt oder ist nicht lesbar +- `bis` fehlt, ist unparsbar oder liegt in der Vergangenheit +- `bis` liegt mehr als **zwölf Stunden** in der Zukunft +- `klassen` fehlt oder enthält ein unbekanntes Wort + +Die Zwölf-Stunden-Grenze ist kein Sicherheitsmerkmal, sondern eine Bremse gegen +den bequemsten Fehler: eine Freigabe „bis 2099" einmal schreiben und nie wieder +daran denken. Wer länger braucht, schreibt die Datei neu. + +**Ein unbekanntes Wort in `klassen` verwirft die ganze Datei**, statt den Rest +gelten zu lassen. Ein Tippfehler in `mischn` soll auffallen und nicht dazu +führen, dass drei von vier Klassen still funktionieren. + +## Was bei einer Ablehnung zurückkommt + +Nach dem Vorbild von `mcp/docker/approval.py`: Die Antwort sagt **genau, was +gelaufen wäre**, und wie man es erlauben würde. + +```text +freigabe verweigert für: set channel1.fader 0.8 + Klasse: mischen + Grund: keine gültige Freigabe (MUSIK_FREIGABE_DATEI nicht gesetzt) + Erlaubt: klassen mit mischen in MUSIK_FREIGABE_DATEI eintragen +``` + +Drei Regeln dazu: + +1. **Kein Ausführen und dann Melden.** Geprüft wird vor dem Verbindungsaufbau + zum Pult. +2. **Die Ablehnung verrät nichts über den Zustand der Anlage.** Kein „Deck 1 + läuft ohnehin nicht" — wer nicht schreiben darf, erfährt auch nichts. +3. **Lesende Werkzeuge werden nie abgelehnt.** Sie sind frei; ein Gatter davor + wäre Verwaltung ohne Gewinn. + +## Was in die Mitschrift gehört + +Die Mitschrift hält heute jede Zeile fest, die durch das Pult geht — ein +freigegebener Aufruf steht also von selbst darin. Was fehlt, ist die **Freigabe +selbst**: Wer sie erteilt hat und bis wann. + +Dafür braucht das Protokoll einen Befehl, den es noch nicht gibt: + +```text +note freigabe mischen spielen zeit bis 23:30 von felix +``` + +`note ` schreibt eine Zeile in die Mitschrift und tut sonst nichts. Er +gehört in die Klasse `lesen` — er ändert nichts an der Anlage. Die Brücke +schickt ihn **einmal je Freigabe-Fenster**, beim ersten Aufruf, der unter dieser +Freigabe läuft; nicht bei jedem, sonst steht die Mitschrift voll. + +Ohne diesen Befehl ist hinterher nicht rekonstruierbar, unter welcher Vollmacht +ein Set gefahren wurde. Das ist genau die Frage, die man stellt, wenn etwas +schiefging. + +## Was gebaut wird + +| Datei | Inhalt | +| --- | --- | +| `mcp/musik/freigabe.py` | Klassen, Datei lesen, `pruefen(...)`, `pruefen_control(...)`, `verweigert(...)` | +| `mcp/musik/controls.txt` | alle 194 Controls, erzeugt aus `list`; ein Test hält dagegen, dass jedes eine Klasse hat | +| `mcp/musik/server.py` | je Werkzeug eine Klasse; Aufruf des Gatters vor `sprich(...)` | +| `crates/control/src/protokoll.rs` | `note ` | +| `crates/control/src/katalog.rs` | `note` im Katalog, damit `list` ihn kennt | +| `mcp/musik/tests/test_freigabe.py` | die Tests unten | + +## Tests, die dafür stehen + +Ohne diese Liste wäre die Spezifikation eine Absichtserklärung. Sie laufen in +der CI (`mcp/musik/tests/test_freigabe.py`, 18 Stück — die zwölf unten, die Ablehnung +selbst und fünf über die Zuordnung der Controls). + +**Fail-closed:** + +1. Ohne `MUSIK_FREIGABE_DATEI` wird jede schreibende Klasse abgewiesen. +2. Fehlende Datei → abgewiesen. +3. Abgelaufenes `bis` → abgewiesen, und die Meldung nennt den Ablauf. +4. `bis` ohne Zeitzone → abgewiesen, nicht als Ortszeit geraten. +5. `bis` mehr als zwölf Stunden voraus → abgewiesen. +6. Unbekanntes Wort in `klassen` → **die ganze Datei** ungültig, nicht nur das Wort. + +**Was durchgeht:** + +7. Gültige Datei mit `mischen` → `set channel1.fader` läuft, `deck1.load` nicht. +8. Lesende Werkzeuge laufen in allen sechs Fällen oben. +9. `erzeugen` läuft **nie**, auch wenn es in `klassen` steht — die Klasse ist + über die Datei nicht erreichbar. + +**Widerruf:** + +10. Datei löschen, während der Prozess läuft → der nächste Aufruf ist + abgewiesen, ohne Neustart. + +**Mitschrift:** + +11. Unter einer Freigabe steht genau **eine** `note`-Zeile, auch nach zehn + Aufrufen. +12. Eine neue Freigabe-Datei (anderes `bis`) erzeugt eine neue `note`-Zeile. + +Punkt 9 ist der wichtigste: `erzeugen` gehört nicht in die Freigabe-Datei, +sondern hinter eine Bestätigung je Aufruf. Ein Test, der das festhält, +verhindert, dass die Klasse später „aus Bequemlichkeit" dazukommt. + +## Was diese Spezifikation nicht regelt + +- **Wer die Datei schreiben darf.** Das sind Dateisystemrechte, und die gehören + dem Betriebssystem, nicht dieser Brücke. +- **Mehrere Agenten mit verschiedenen Rechten.** Heute gilt eine Freigabe für + die Brücke, nicht je Sitzung. Zwei Modelle mit verschiedenen Vollmachten wären + eine eigene Aufgabe — und sie hängt an P2 Stufe 2, die noch aussteht. +- **Widerruf mitten in einer laufenden Rampe.** Eine Rampe, die schon im Plan + steht, läuft zu Ende; die Freigabe wirkt auf neue Aufrufe. Wer sie sofort + stoppen will, nimmt `cancel` — von Hand, über den Socket. diff --git a/docs/GENERATOR.md b/docs/GENERATOR.md new file mode 100644 index 0000000..8e11cc1 --- /dev/null +++ b/docs/GENERATOR.md @@ -0,0 +1,178 @@ +# Generator: wenn nichts Passendes existiert + +Spezifikation. Sie beschreibt den Teil, um dessentwillen das Projekt „Musik" +heißt und der als einziger noch gar nicht angefangen ist. + +Stand: Entwurf, September 2026. In `crates/` steht dazu keine Zeile. + +## Die Lage, kurz + +**Suno hat keine öffentliche API.** Kein Endpunkt, keine Doku, kein Preismodell, +kein Termin — siehe [APIS.md](APIS.md). Es gibt seit Juli 2026 ein +Partner-Programm; die Bewerbung ist einen Versuch wert, aber darauf lässt sich +nicht planen. Reverse-engineerte Dritt-„APIs" scheiden aus: Das realistische +Risiko ist ein gesperrter Account. + +Daraus folgt die ganze Bauform: **ein Adapter, mehrere Anbieter, Suno später +einer von ihnen.** Erster Adapter ist ElevenLabs Music — dokumentierter +`/music/compose`, die klarste Lizenzlage im Feld. + +## Die harte Grenze, noch einmal + +Aus [AGENTEN.md](AGENTEN.md), weil sie hier alles bestimmt: + +> Ein Dancefloor entscheidet in **Sekunden**. Ein generierter Track braucht +> **Zehner von Sekunden bis Minuten**. + +Die Schleife „Crowd reagiert → Agent generiert → Track spielt" schließt sich +nicht in Echtzeit, und keine Optimierung ändert das. Was trägt: + +1. **Vorausschauend erzeugen.** Kandidaten für wahrscheinliche nächste Zustände, + bevor sie eintreten. Die Crowd steuert dann die *Auswahl* aus Fertigem. +2. **Echtzeit nur für das, was schnell ist** — EQ, Filter, Loops, Stem-Muting. +3. **Erzeugen ist die langsame Schicht.** Sie formt die nächste halbe Stunde. + +**Ein Track wird erst deckfähig, wenn er vollständig vorliegt.** Kein Streamen +in ein laufendes Deck, keine halben Dateien. Das ist keine Bequemlichkeit: Die +Analyse braucht den ganzen Track für Grid, Tonart und Gliederung, und ein Deck +ohne Grid kann nicht synchronisiert werden. + +## Die Schnittstelle + +Ein Trait in `crates/generator/`, damit der Rest der Anlage nichts über +Anbieter weiß. + +```rust +/// Ein Anbieter, der aus einer Beschreibung Musik macht. +pub trait Erzeuger: Send + Sync { + fn name(&self) -> &str; + /// Beauftragt ein Stück. Kehrt sofort zurück — nichts hier wartet. + fn beauftragen(&self, auftrag: &Auftrag) -> Result; + /// Fragt nach. Kein Warten, kein Blockieren. + fn nachsehen(&self, nummer: &AuftragsNummer) -> Result; + /// Was ein Auftrag kostet, soweit der Anbieter es sagt. + fn kosten(&self, auftrag: &Auftrag) -> Option; +} + +pub enum Stand { + Laeuft { seit: Duration }, + Fertig { pfad: PathBuf }, + Gescheitert { grund: String }, +} +``` + +`beauftragen` und `nachsehen` sind getrennt, weil jeder Anbieter asynchron ist +und keiner dieselbe Wartesemantik hat. Ein `erzeuge_und_warte` gäbe es nur, um +es an genau einer Stelle wieder aufzubrechen. + +## Was ein Auftrag mitbringt + +```rust +pub struct Auftrag { + /// Was gewünscht ist, in Worten. + pub beschreibung: String, + /// Zieltempo. Ohne das ist der Track für ein Set unbrauchbar. + pub bpm: Option, + /// Zieltonart, für harmonisches Mischen. + pub tonart: Option, + pub sekunden: Option, + /// Für Wiederholbarkeit — derselbe Seed, dasselbe Stück. + pub seed: Option, + /// Nur Instrumental. + pub ohne_stimme: bool, +} +``` + +**BPM und Tonart gehören in den Auftrag, nicht in die Beschreibung.** Ein +Anbieter, der sie als Parameter kennt, trifft sie besser als einer, der sie aus +Prosa herauslesen soll — und wo er sie nicht kennt, hängt der Adapter sie an den +Text an. Der Unterschied ist prüfbar: Die eigene Analyse misst hinterher nach, +was tatsächlich herauskam. + +## Wo das Ergebnis landet + +Neben der Datei liegt, **wie es entstanden ist**. Sonst weiß hinterher niemand, +warum ein Stück klingt, wie es klingt. + +```text +/musik/erzeugt/2026-09-03T21-14-07-elevenlabs-a3f9/ + stueck.wav + auftrag.json Beschreibung, BPM, Tonart, Seed, Anbieter, Modell + antwort.json Was der Anbieter zurückgab, roh + gemessen.json Was die eigene Analyse daraus liest +``` + +`gemessen.json` ist der interessante Teil: **Bestellt ist nicht geliefert.** Wer +128 BPM in A-Moll bestellt und 126,4 BPM in F-Dur bekommt, muss das sehen — und +zwar bevor der Track im Set liegt. Über viele Aufträge wird daraus eine +Trefferquote je Anbieter, und das ist die Zahl, nach der man einen aussucht. + +Das Verzeichnis ist inhaltsadressiert genug, um zweimal dasselbe zu erkennen, +und lesbar genug, um von Hand nachzusehen. Ein Datenbankeintrag käme dazu, wenn +die Sammlung ihn braucht — die Datei bleibt die Wahrheit. + +## Geld + +Das Einzige an diesem Baustein, das echten Schaden anrichten kann. + +- **Jeder Aufruf kostet.** Die Klasse `erzeugen` steht deshalb in + [FREIGABE.md](FREIGABE.md) auf *nie ohne Mensch* und ist über die + Freigabe-Datei **nicht erreichbar**. Ein Test hält das fest. +- **Eine Obergrenze je Abend**, in Aufträgen und in Geld, in der Konfiguration. + Erreicht heißt: kein weiterer Auftrag, mit Meldung. +- **Sichtbar im Steuerraum**, wie alles andere: + +| Control | Art | Bedeutung | +| --- | --- | --- | +| `master.erzeugt_laufend` | Zahl, r | Aufträge, die gerade laufen | +| `master.erzeugt_heute` | Zahl, r | Fertige Aufträge seit Mitternacht | +| `master.erzeugt_kosten` | Zahl, r | Was das gekostet hat, soweit bekannt | +| `master.erzeugt_grenze` | Zahl, rw | Obergrenze; 0 heißt aus | + +**Ein Agent, der unbemerkt Geld ausgibt, ist ein Fehler mit Rechnung.** Deshalb +sind das Controls und keine Logzeilen: Sie stehen im Pult, in der Oberfläche und +in `musik_status`, und ein Mensch sieht sie, ohne zu suchen. + +## Der Weg hinein + +Ein fertiger Track ist eine Datei wie jede andere: + +```text +Auftrag → Anbieter → Datei → analysieren → Sidecar → Sammlung → Deck +``` + +Kein Sonderweg. Ein erzeugter Track bekommt dasselbe Beatgrid, dieselbe Tonart +und dieselbe Gliederung wie einer von der Platte, und derselbe `musik-schneiden` +könnte ihn zerlegen, wenn er zu lang wäre. Das ist die Zusage aus +[ARCHITEKTUR.md](ARCHITEKTUR.md) — *„ein Deck interessiert sich nicht dafür, ob +die Datei von der Platte kam oder gerade generiert wurde"* — und sie wird +eingehalten, indem der Generator nichts als Dateien produziert. + +## Was gebaut wird + +| Schritt | Inhalt | Fertig, wenn | +| --- | --- | --- | +| 1 | `crates/generator/` mit Trait, `Auftrag`, `Stand`, Ablage | Tests gegen einen Attrappen-Erzeuger laufen, der nach N Aufrufen „fertig" meldet | +| 2 | Warteschlange, die nichts blockiert | ein laufender Auftrag den Audio-Pfad nachweislich nicht berührt | +| 3 | ElevenLabs-Adapter | ein bestellter Track ankommt und `gemessen.json` danebenliegt | +| 4 | Controls und Grenze | `master.erzeugt_grenze 1` den zweiten Auftrag abweist | +| 5 | Werkzeug `musik_erzeugen` mit Bestätigung je Aufruf | es ohne Bestätigung nichts tut | + +**Schritt 1 und 2 brauchen kein Konto und kein Geld.** Ein Attrappen-Erzeuger, +der nach ein paar Sekunden eine still erzeugte Datei zurückgibt, prüft die ganze +Mechanik: Warteschlange, Zustände, Ablage, Analyse, Weg in die Sammlung. Erst +Schritt 3 kostet etwas — und erst dort ist die Anbieterfrage zu entscheiden. + +Das ist die Reihenfolge, in der ich es bauen würde: die Mechanik zuerst gegen +eine Attrappe, den Anbieter zuletzt. Andersherum hängt die Architektur an +Eigenheiten eines Anbieters, den es vielleicht nicht bleibt. + +## Was diese Spezifikation nicht regelt + +- **Welcher Anbieter am Ende genommen wird.** ElevenLabs ist die Empfehlung von + heute; die Lizenzlage im Feld ändert sich schnell, und vor jeder Integration + ist sie neu zu prüfen. +- **Wie ein Agent zu einer guten Beschreibung kommt.** Das ist Arbeit des + Set-Planers und gehört in den Space, nicht in die Engine. +- **Rechte am Ergebnis.** Was mit einem erzeugten Stück geschehen darf, hängt am + Anbieter und ist vor der ersten Veröffentlichung zu klären — nicht danach. diff --git a/docs/MIXXX.md b/docs/MIXXX.md new file mode 100644 index 0000000..1ccbc6f --- /dev/null +++ b/docs/MIXXX.md @@ -0,0 +1,179 @@ +# Mixxx als Referenz + +[Mixxx](https://github.com/mixxxdj/mixxx) ist die ausgereifteste freie +DJ-Software, die es gibt — seit rund zwanzig Jahren in Entwicklung, C++ mit Qt, +vier Decks, Sampler, Effekte, DVS/Timecode, Broadcasting, Mitschnitt, +Controller-Mappings für über hundert Geräte. Alles, was hier in Phasen 7 bis 10 +noch aussteht, hat Mixxx längst. + +Die naheliegende Frage lautet deshalb: warum nicht einfach Mixxx? + +Dieses Dokument beantwortet beides — was wir übernehmen können und was nicht, +und unter welchen Umständen „einfach Mixxx nehmen" die richtige Antwort wäre. + +## Die Lizenz, genau + +Zwei Zahlen, die auseinanderlaufen: + +| Ort | Lizenz | +| --- | --- | +| `LICENSE` im Repo | GPL **v2** or later | +| Ein gebautes Binary | faktisch GPL **v3** or later | + +Der Unterschied kommt von [libKeyFinder](https://github.com/mixxxdj/libkeyfinder) +(Tonarterkennung, GPLv3), das seit Mixxx 2.3 mitgelinkt wird — GPLv3 ist mit +GPLv2-only unverträglich, die „or later"-Klausel hebt das Ganze also auf v3. + +Für uns macht das keinen Unterschied: **Copyleft ist Copyleft.** Mixxx fällt in +dieselbe Schublade wie Rubber Band, aubio und Essentia in +[BAUSTEINE.md](BAUSTEINE.md#lizenzen--entschärft-nicht-erledigt) — nutzbar, +solange nichts weitergegeben wird, und ausgeschlossen, sobald der Anschluss an +[VibeMind](VIBEMIND.md) (MIT, zur Veröffentlichung vorgesehen) ernst wird. + +## Lesen ist nicht kopieren + +Der Punkt, an dem viele zu vorsichtig sind: **Urheberrecht schützt die +Ausdrucksform, nicht die Idee.** Ein Algorithmus, eine Architektur, die +Erkenntnis „der Cue-Bus muss pre-fader und post-EQ abgegriffen werden" — das +sind keine geschützten Werke. Mixxx zu lesen, um zu verstehen *wie* etwas gelöst +ist, und es danach selbst zu schreiben, ist zulässig und üblich. + +Was nicht geht: Code übernehmen, Dateien vendorn, Zeile für Zeile übersetzen. +Eine Rust-Portierung einer C++-Funktion ist eine Bearbeitung, keine Neuschöpfung. + +**Praktische Regel für dieses Projekt:** zuerst ins +[Wiki](https://github.com/mixxxdj/mixxx/wiki) und ins +[Handbuch](https://manual.mixxx.org/), nicht in den Quelltext. Beide beschreiben +die Architektur ausführlich genug, und wer die Lösung aus der Prosa +rekonstruiert statt aus dem Code, hat das Problem der versehentlichen Nähe gar +nicht erst. + +## Was uns wirklich weiterhilft + +Nach absteigendem Wert: + +### 1. Die Controller-Mappings als Hardware-Dokumentation + +`res/controllers/` enthält über hundert Mappings als `.midi.xml`, `.hid.xml` +und `.js` — Pioneer, Denon, Numark, Hercules, Traktor Kontrol, Allen & Heath +und so weiter, teils vom Team zertifiziert, teils aus der Community. + +Der Inhalt ist zweierlei, und die Trennung ist wichtig: + +- **Welche MIDI-CC-Nummer das Jogwheel eines DDJ-400 sendet**, ist eine + *Tatsache über die Hardware*. Tatsachen sind nicht urheberrechtlich + geschützt. Als Nachschlagewerk dafür, wie die Geräte-Landschaft aussieht, ist + das Verzeichnis Gold wert — genau das, was Phase 10 sonst mühsam durch + Ausprobieren am Gerät ermitteln müsste. +- **Die Mapping-Dateien selbst** sind Werke unter GPL. Nicht kopieren, nicht + mitliefern, nicht konvertieren. + +Also: als Doku lesen, eigenes Format schreiben. + +### 2. Das Control-System als Vorbild für die MCP-Schnittstelle ✅ gebaut + +Mixxx hat eine interne API, über die **alles** läuft — Tastatur, MIDI, HID und +die grafische Oberfläche greifen auf dieselben benannten Controls zu, adressiert +über einen zweiteiligen `ConfigKey` aus Gruppe und Element, etwa +`[Channel1],play`. + +Das ist genau die Form, die unsere Anbindung an VibeMind braucht: ein flacher, +benannter Steuerraum, den ein Agent adressieren kann, ohne die Oberfläche zu +kennen. Vorher lag die Bedienung bei uns an drei Stellen — Transport in den +Atomics des `DeckState`, der Mixer in der Kommandoschlange, die +Reglerstellungen als Spiegel in der Oberfläche — ohne gemeinsame Namensgebung. + +Architektonische Idee, frei übernehmbar — **inzwischen gebaut**, als Crate +`control`, und in drei Punkten weitergedreht: Der Steuerraum beschreibt sich +zur Laufzeit selbst, die Werte sind typisiert statt durchweg `double`, und er +ist von außen erreichbar. Siehe [STEUERUNG.md](STEUERUNG.md). + +### 3. Zwei Beatgrid-Stufen statt einer + +Mixxx bietet die Wahl zwischen zwei Analysatoren, und der Unterschied ist genau +der, den wir noch vor uns haben: + +| Verfahren | Ergebnis | +| --- | --- | +| SoundTouch | eine mittlere BPM-Zahl → **konstantes** Grid | +| Queen Mary | einzelne Beat-Positionen → **variables** Grid | + +Unser Modell (Anker + konstante BPM, siehe `crates/analysis/src/tempo.rs`) ist +die erste Stufe. Das ist für produzierte elektronische Musik richtig und +billig. Für live eingespieltes Material — Bands, ältere Aufnahmen, alles ohne +Klick — schwankt das Tempo, und ein konstantes Grid läuft über vier Minuten +sichtbar weg. + +Damit ist der Aufrüstpfad benannt: eine Beat-Liste statt Anker+BPM, und +`Beatgrid` müsste beides können. Kein akutes Problem, aber ein Grund, die +Schnittstelle nicht auf „eine Zahl" festzunageln. + +### 4. Streaming von Platte + +Mixxx dekodiert Tracks nicht vollständig in den Speicher, sondern liest mit +einem Vorpuffer nach. Genau die offene Entscheidung aus +[PLAN.md](PLAN.md#offene-entscheidungen), die spätestens bei Stems fällig wird — +vier Decks à vier Stems sprengen den Vollentschlüsselungs-Ansatz. + +Das Wiki beschreibt den Aufbau. Lesen lohnt, bevor wir das selbst entwerfen. + +### 5. Library-Importe, die wir nicht haben + +Wir lesen Traktors `collection.nml`. Mixxx liest zusätzlich Rekordbox und +Serato, beides durch Rückentwicklung erarbeitet. Wenn diese Formate je +gebraucht werden, ist dort dokumentiert, wie sie aussehen — wieder: +Formatwissen ist Tatsache, der Parser ist Werk. + +## Was Mixxx *nicht* löst + +**Steuerung von außen.** Mixxx spricht OSC nur in eine Richtung: es *sendet* +Zustand (Titel, Position, Lautstärke, Play-Status) an einen OSC-Server. +Das Empfangen von Befehlen ist ein seit Jahren offener Wunsch mit +Community-Patches, aber kein offizieller Bestandteil. Das Control-System selbst +ist prozessintern. + +Für einen Agenten, der auflegen soll, ist Mixxx damit keine fertige Basis. Genau +diese Lücke ist das, was dieses Projekt eigentlich beiträgt — nicht der +zwanzigste Mixer, sondern ein DJ-Werkzeug, das von Anfang an eine +Steuerschnittstelle nach außen hat. **Diese Lücke ist inzwischen geschlossen:** +[STEUERUNG.md](STEUERUNG.md). + +**Die Lizenz für den VibeMind-Weg.** Einbetten geht nicht, und ein Fork wäre +GPL. Beides schließt die Veröffentlichung als MIT aus. + +## Und wenn wir doch Mixxx nehmen? + +Der Vollständigkeit halber, weil es die ehrliche Alternative ist: + +> **Solange dies ein persönliches Werkzeug bleibt und nicht weitergegeben wird, +> entstehen aus Mixxx' GPL null Pflichten.** Es ist fertige, ausgereifte +> Software, die alles kann, was hier noch drei Phasen entfernt ist. Wer heute +> Abend auflegen will, nimmt Mixxx. + +Die Gründe, trotzdem selbst zu bauen, sind genau zwei: + +1. **Der Anschluss an VibeMind** — der braucht MIT und Veröffentlichung. +2. **Die Steuerung durch Agenten** — die es in Mixxx nicht gibt und die sich + nicht nachrüsten lässt, ohne den Kern anzufassen (und damit zu forken, und + damit unter GPL zu landen). + +Fallen beide Ziele weg, ist der Eigenbau schwer zu rechtfertigen. Bleiben sie, +ist er der einzige Weg. Das ist keine technische, sondern eine Zielfrage — und +sie sollte bewusst beantwortet und nicht durch Weiterbauen umgangen werden. + +## Quellen + +- [mixxxdj/mixxx auf GitHub](https://github.com/mixxxdj/mixxx) — + [LICENSE](https://github.com/mixxxdj/mixxx/blob/main/LICENSE) (GPLv2+), + [`res/controllers/`](https://github.com/mixxxdj/mixxx/tree/main/res/controllers) +- [PR #3476 — Lizenzhebung auf GPLv3 wegen libKeyFinder](https://github.com/mixxxdj/mixxx/pull/3476) +- [mixxxdj/libkeyfinder](https://github.com/mixxxdj/libkeyfinder) — GPLv3 +- [Developer Guide: Control](https://github.com/mixxxdj/mixxx/wiki/Developer-Guide-Control) — das ConfigKey-System +- [Developer Guide: Analysers](https://github.com/mixxxdj/mixxx/wiki/Developer-Guide-Analysers) — SoundTouch gegen Queen Mary +- [Handbuch: Beat Detection](https://manual.mixxx.org/2.3/id/chapters/preferences/beat_detection) +- [Wiki: OSC Client](https://github.com/mixxxdj/mixxx/wiki/osc-client) und + [OSC Backend](https://github.com/mixxxdj/mixxx/wiki/Osc-Backend) +- [Wiki: Contributing Mappings](https://github.com/mixxxdj/mixxx/wiki/Contributing-Mappings) + +Stand: August 2026. Nicht selbst nachgebaut — alles aus Dokumentation und +Repository-Metadaten, bewusst nicht aus dem Quelltext. diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..d3a7624 --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,329 @@ +# Plan: vollständige DJ-Software + +Der Weg von einem Deck an der Kommandozeile (Phase 0, steht) zu einem Werkzeug, +mit dem man tatsächlich auflegen kann. + +Ergänzt [TRAKTOR-REFERENZ.md](TRAKTOR-REFERENZ.md) (was das Ziel kann) um das +Wie. Aufwandsangaben sind relativ (S/M/L/XL), keine Kalenderzeit. + +## Was „vollständig" heißt + +**Dazu gehört:** vier Decks, Mixer mit EQ und Filter, getrennter +Kopfhörer-Cue, Beatgrid und Sync, Hot Cues und Loops, Waveform-Anzeige, +Library mit Suche und Playlists, Effekte, vorberechnete Stems, Mitschnitt, +MIDI-Controller. + +**Dazu gehört nicht:** DVS/Timecode-Vinyl, Video, Karaoke, +Streaming-Dienst-Integration. Alles vier ist viel Arbeit für wenig Ertrag in +diesem Projekt. + +Generierung und Agenten sind eine Schicht *darüber* und stehen in +[APIS.md](APIS.md) und [AGENTEN.md](AGENTEN.md). Dieser Plan bringt das +Fundament, auf dem sie aufsetzen. + +## Die drei Entscheidungen, die alles andere bestimmen + +Der Rest des Plans ist Fleißarbeit. Diese drei nicht — sie prägen die +Architektur, und sie nachträglich zu ändern ist teuer. + +### 1. Der Cue-Ausgang zwingt zu einem Gerät mit vier Ausgängen + +Vorhören auf dem Kopfhörer war der Grund für die native Entscheidung. Praktisch +heißt das: **Master L/R und Cue L/R müssen über dasselbe Audiogerät laufen.** + +Zwei getrennte Geräte haben zwei Quarze. Die laufen minimal unterschiedlich +schnell, und der Versatz summiert sich — nach ein paar Minuten ist der +Kopfhörer hörbar gegen den Master verschoben. Das ließe sich nur mit +asynchroner Ratenkonvertierung im Abspielpfad auffangen, also genau der +Rechenstufe, die wir dort bewusst nicht haben. + +Konsequenz für die Hardware: ein Interface mit mindestens vier Ausgängen, oder +ein DJ-Controller mit eingebauter Soundkarte. Das ist eine +Anschaffungsentscheidung, keine Softwarefrage — und sie steht vor Phase 1, weil +sich der Cue-Bus sonst nicht testen lässt. + +Plattformseitig: unter Linux ALSA oder PipeWire, unter macOS CoreAudio, unter +Windows ASIO (CPAL hat dafür ein Feature-Flag). Aggregate Devices unter macOS +lösen das Taktproblem übrigens *nicht* — sie bündeln zwei physische Uhren. + +### 2. Atomics reichen nicht mehr — es braucht eine Kommandoschlange + +Phase 0 steuert das Deck über Atomics. Das trägt für Skalare wie Tempo und +Play-Zustand, aber nicht für „lade diesen Track" oder „setze acht Cue-Points". + +Nötig ist eine **lock-freie SPSC-Schlange** vom Steuer- zum Audio-Thread +([`rtrb`](https://crates.io/crates/rtrb), MIT/Apache-2.0). Und eine zweite in +die Gegenrichtung, deren Zweck man leicht übersieht: + +> Wird ein Track auf einem Deck ausgetauscht, darf der alte `Arc` **nicht +> im Audio-Thread fallen gelassen werden.** Der letzte Drop gibt hundert +> Megabyte frei — ein Syscall mitten im Callback, also genau der Aussetzer, den +> die ganze Architektur vermeiden soll. Der alte Arc geht deshalb über eine +> Rückschlange hinaus und stirbt außerhalb. + +``` +Steuer-Thread ──── Kommandos (rtrb) ────► Audio-Thread + ◄─── Altlasten (rtrb) ───── + ◄─── Zustand (Atomics) ──── +``` + +### 3. Beatgrid ist mehr als BPM + +Für Sync reicht eine Zahl nicht. Gebraucht wird: + +- **Anker** — Position des ersten Downbeats in Frames +- **BPM** +- optional eine **Liste von Grid-Markern** für Material mit schwankendem Tempo + (Live-Aufnahmen, alte Platten) + +Und Sync braucht zwei Größen, nicht eine: **Tempo** *und* **Phase**. Zwei Decks +mit identischem BPM, aber verschobener Phase, klingen genauso falsch wie +unterschiedliches Tempo. Die Engine braucht dafür eine globale Beat-Uhr, an der +ein Deck als Tempo-Master hängt. + +## Architektur + +### Crates + +``` +crates/ + audio-core/ steht — Dekodierung, WSOLA, Deck, Beatgrid, Cues, Loops + audio-engine/ steht — Mixer, Kanalzüge, Cue-Bus, AUX, Sync, Begrenzer + analysis/ steht — BPM, Beatgrid, Waveform-Peaks, Sidecar-Cache + library/ steht — SQLite, Suche, Playlists, Traktor-Import + musik-cli/ steht — Deck, Analyse, Offline-Mix, Sammlung + control/ steht — benannter Steuerraum, Fernsteuerung über Socket + musik-app/ steht — Oberfläche (egui/eframe): Decks, Mixer, Plattenkiste + Effekte, Stems und MIDI kommen in audio-engine bzw. analysis dazu. +``` + +`audio-core` behält seine Rolle als Deck-Innenleben; `audio-engine` ist alles +darüber. Die Trennung lohnt sich, weil `audio-engine` die Echtzeitregeln +durchsetzt und `analysis`/`library` sie bewusst nicht kennen müssen. + +### Signalkette pro Kanal + +``` +Quelle ─► Trim ─► EQ (3-Band) ─► Filter (HP/LP) ─┬─► Kanalfader ─► Crossfader ─► Summe ─► Limiter ─► Ausgang 1/2 + │ + └─► Cue-Bus ────────────────────────────────────► Ausgang 3/4 +``` + +**Quelle, nicht Deck.** Der Mixer kennt keine Decks, nur Quellen, die Stereo +liefern. Ein AUX-Eingang ist damit ein Kanal wie jeder andere — mit Trim, EQ, +Filter, Fader und Cue —, und die Generierung kann sich später an derselben +Stelle einhängen. + +Drei Details, die man leicht falsch baut: + +- **Mixer-FX liegen post-fader.** Zieht man den Fader zu, klingt der Effekt aus + statt abzureißen. Das ist der Unterschied zwischen einem weichen und einem + ruckeligen Übergang. +- **Der Cue-Abgriff liegt vor dem *Fader*** — nicht bloß vor dem Crossfader. + Man bereitet einen Track im Kopfhörer vor, während sein Fader unten ist; läge + der Abgriff dahinter, hörte man genau dann nichts. +- **Der Cue-Abgriff liegt hinter EQ und Filter**, weil man seine Klangeingriffe + kontrollieren muss, bevor sie auf die Anlage gehen. + +### AUX + +Mikrofon, Drum-Machine, ein zweiter Rechner. Zwei Dinge unterscheiden AUX von +einem Deck: + +- **Zuweisung Thru.** Ein Mikrofon darf nicht verschwinden, nur weil jemand den + Crossfader bewegt. +- **Ringpuffer statt direktem Zugriff.** Aufnahme und Wiedergabe laufen in + getrennten Callbacks mit getrennten Uhren. Ein lock-freier Ringpuffer + entkoppelt beide; bleibt die Aufnahme zurück, gibt es Stille und einen + gezählten Unterlauf statt eines Aussetzers. + +**Der Zuspieler kam später.** Der Kanalzug stand lange, der Ring auch — nur hat +niemand hineingeschrieben; im Programm stand wörtlich „AUX bleibt ohne Zuspieler +still". `musik-app --aux-in` legt den Kanal jetzt auf ein Aufnahmegerät. + +Zwei Dinge werden dabei nicht geraten. Die **Samplerate** muss genau die der +Engine sein — der Ring trägt Samples, keine Zeit, und 44,1 statt 48 kHz heißt +8 % zu langsam, ohne dass etwas es meldet. Und das **Sample-Format** wird nicht +der Reihe nach genommen, sondern nach Auflösung gewählt: Das `null`-Gerät von +ALSA bietet `i8` zuerst an, also acht Bit für Material, das analysiert werden +soll. + +### Threads + +| Thread | Aufgabe | Regeln | +| --- | --- | --- | +| Audio-Callback | Decks lesen, mischen, Effekte, Ausgabe | Keine Allokation, kein Lock, kein Syscall | +| UI | Darstellung ~60 fps, Eingaben → Kommandos | Liest Zustand über Atomics | +| Lader | Datei dekodieren, resamplen → `Arc` | Darf blockieren | +| Analyse | BPM, Grid, Peaks, später Stems | Parallel, im Hintergrund | + +## Datenhaltung + +**Analyse-Ergebnisse als Sidecar, adressiert über den Inhalts-Hash.** + +Nicht über den Dateipfad — sonst ist die Arbeit weg, sobald jemand einen Ordner +umbenennt. Der Hash wird über die dekodierten Audiodaten gebildet, nicht über +die Datei, damit ein geänderter ID3-Tag die Analyse nicht entwertet. + +Inhalt: BPM, Grid-Anker und -Marker, Tonart, Loudness, Waveform-Peaks in +mehreren Auflösungen (grob für die Übersicht, fein für den Zoom). + +**Library als SQLite** ([`rusqlite`](https://crates.io/crates/rusqlite), MIT). + +Tabellen: `tracks`, `cues`, `loops`, `playlists`, `playlist_items`, `analysis`. + +⚠️ **`tracks` braucht von Anfang an `license` und `attribution`.** CC BY +verlangt Namensnennung auch bei nicht-kommerzieller Nutzung, und das lässt sich +nicht rekonstruieren, wenn erst tausend Freesound-Samples ohne Herkunft im +Ordner liegen. Siehe [APIS.md](APIS.md#samples-und-audiomaterial). + +## Phasen + +| # | Ziel | Abnahmekriterium | Aufwand | Braucht | +| --- | --- | --- | --- | --- | +| 1 | Mehrdeck-Engine, Mixer, Cue-Bus, AUX | Code steht ✅ · Abnahme offen: Cue muss hörbar getrennt auf 3/4 liegen | M | 4-Kanal-Interface | +| ~~2~~ | ~~Analyse-Pipeline~~ ✅ | BPM, Grid und Tonart reproduzierbar als Sidecar, Peaks vorhanden | M | — | +| ~~3~~ | ~~UI-Grundgerüst~~ ✅ | Zwei Decks mit Wellenform und Grid, Mixer bedienbar, Sammlung durchsuchbar · Offen: Bildrate auf echter Hardware messen | L | 1, 2 | +| ~~4~~ | ~~Sync und Beatmatching~~ ✅ | Zwei Tracks bleiben über 5 Minuten im Takt — als Test abgesichert | M | 2 | +| ~~5~~ | ~~Hot Cues und Loops~~ ✅ | 8 Cues pro Deck, Loop beat-quantisiert, in der Oberfläche bedienbar, überleben den Trackwechsel; Beatgrid von Hand korrigierbar | M | 4 | +| ~~6~~ | ~~Library~~ ✅ | Import, Suche nach Text, Tempo und Tonart, Playlists, Traktor-`collection.nml` | L | 2 | +| ~~7~~ | ~~Effekte~~ ✅ | Vier Effekte post-fader, tempo-synchron · Offen: Reverb, und ob vier reichen | M | 1 | +| 8 | Stems | Beim Import vorberechnet, Stem-Deck-Modus | L | 6 | +| ~~9~~ | ~~Mitschnitt~~ ✅ | Summe als WAV, hinter dem Begrenzer, verlorene Frames werden gezählt · Offen: FLAC | S | 1 | +| 10 | MIDI-Controller | Generic-MIDI-Mapping, speicherbar | M | 3 | + +Für Phase 10 ist Mixxx' Mapping-Verzeichnis die beste verfügbare Dokumentation +der Geräte-Landschaft — als Nachschlagewerk, nicht als Vorlage zum Kopieren. +Warum das ein Unterschied ist, steht in [MIXXX.md](MIXXX.md). + +Phase 10 ist seit dem Steuerraum deutlich kleiner geworden: Ein Mapping ist +jetzt eine Tabelle von MIDI-Nachricht auf Control-Namen, und den normierten +Schreibweg (`setn`, 0..1) gibt es bereits. Siehe +[STEUERUNG.md](STEUERUNG.md). + +Phase 1 und 2 sind unabhängig und können parallel laufen — die Analyse braucht +die Engine nicht. + +Ab Phase 6 ist die Software als DJ-Werkzeug benutzbar. 7 bis 10 machen sie gut. + +**Wenige gute Effekte statt vierzig.** Traktor hat über 40; die meisten davon +benutzt niemand. Gebaut sind Delay, Gater, Flanger und Crusher — der Filter +sitzt ohnehin schon fest im Kanalzug. **Reverb fehlt bewusst:** Ein schlechter +Hall ist schlimmer als keiner, und ein guter ist ein eigenes Stück Arbeit. + +Alle vier liegen **hinter dem Fader**. Das ist der Punkt, an dem Mixer-FX sich +von Insert-Effekten unterscheiden: Zieht man den Fader zu, während ein Delay +klingt, soll die Fahne ausklingen statt abzureißen. Der Mixer muss dafür einen +stummen Kanal weiterrechnen, solange sein Effekt noch klingt — sonst wäre die +Anordnung nur auf dem Papier richtig. + +## Was inzwischen steht + +Neun von zehn Phasen sind gebaut. Die Software lässt sich bedienen: Tracks +aus der Sammlung auf die Decks legen, Wellenform und Beatgrid sehen, nach +Tempo und Tonart aussuchen, mischen. Was fehlt, sind Stems und MIDI. + +Die Tonarterkennung ist selbst gebaut — libKeyFinder und der QM Key Detector +stehen unter GPL und hätten den Weg zu VibeMind zugemacht. Sie sagt bewusst +**nichts**, wenn ein Track nur aus Bass und Drums besteht: Dort steht das +Tongeschlecht nicht im Signal, und ein geratenes Dur führt auf dem Camelot-Rad +zum Fehlgriff. + +Ihre Schwelle ist an **fünf** echten Aufnahmen nachgeeicht, nachdem die erste, +an synthetischem Material gemessene, vier davon abgewiesen hatte. Fünf ist +dünn; die Schwelle gehört nachgemessen, sobald mehr Material da ist. Beim +Tempo steht dieselbe Aufgabe noch aus — dort liegen die Konfidenzen echter +Tracks bei 0,05 bis 0,46 und damit durchweg im unteren Bereich. + +| Crate | Inhalt | +| --- | --- | +| `audio-core` | Deck: Dekodierung, WSOLA, Beatgrid, Hot Cues, Loops | +| `audio-engine` | Mixer: Kanalzüge, EQ, Filter, Effekte, Crossfader, Cue-Bus, Begrenzer, AUX, Sync, Mitschnitt, Kommandoschlange, Geräteausgabe | +| `analysis` | Tempo, Beatgrid, Tonart, Wellenform-Spitzen, Sidecar-Cache | +| `library` | SQLite-Sammlung, Suche nach Text, Tempo und Tonart, Playlists, Hot Cues, Traktor-Import | +| `musik-cli` | Deck fahren, analysieren, Mix rendern, Sammlung verwalten | +| `control` | Steuerraum: benannte Controls, Katalog, Zeilenprotokoll, Socket, Zeitplan auf dem Beatgrid | +| `musik-app` | Oberfläche: zwei Decks, Mixer mit AUX, Plattenkiste | + +## Offene Entscheidungen + +**Streaming von Platte statt Vollentschlüsselung** (spätestens Phase 8). Vier +Decks à 100 MB gehen noch; vier Decks mit je vier Stems nicht mehr. Dann wird +aus dem Laden ein Vorpuffer plus Nachladen im Hintergrund. Mixxx hat genau das +gelöst und beschreibt es im Wiki — lesen, bevor wir es selbst entwerfen, siehe +[MIXXX.md](MIXXX.md#4-streaming-von-platte). + +**Kalibrierung der Tempo-Schwelle.** Die Grenze, ab der ein erkanntes Tempo als +Aussage gilt statt als Rauschen, ist inzwischen ein z-Wert statt eines +Verhältnisses zum Mittelwert — das alte Maß hat dichtes Material +fälschlich abgewiesen, weil dessen durchgehende Energie den Sockel der +Autokorrelation anhebt (Details in `crates/analysis/src/tempo.rs`). Sie trennt +jetzt Klick-Track, dichten Loop und Dauerton sauber. + +**Nachgemessen an fünf echten Aufnahmen** (August 2026, generierte Stücke +zwischen 71 und 146 BPM): Alle fünf Tempi sind richtig — geprüft auf zwei +Wegen, die beide nicht der Detektor selbst sind. Erstens die Oktavlage: Auf den +Positionen zwischen den gemeldeten Beats liegt jeweils rund die halbe +Onset-Stärke, also sitzt dort ein Off-Beat und kein übersehener Schlag. +Zweitens eine Feinsuche über die Wellenform-Hüllkurve, die in allen fünf Fällen +bis auf 0,09 BPM denselben Wert fand — und zwar getrennt für jedes Drittel des +Stücks, das Raster hält also über die volle Länge. + +Zwei Dinge stehen damit fest, die vorher Vermutung waren: + +- Die Schwelle passt. Echte Aufnahmen liegen bei 3,0 bis 4,3, keine wurde + abgewiesen. Der Abstand nach unten ist aber dünn. +- **Die Konfidenz sagt nichts über die Richtigkeit.** 0,12 war genauso richtig + wie 0,46. Sie misst, wie deutlich die Spitze aus dem Feld ragt, nicht ob sie + an der richtigen Stelle sitzt — wer sie als Gütesiegel liest, liest etwas + hinein, das nicht drinsteht. + +Offen bleibt der Bereich unter `MIN_BPM` (70). Bei den fünf Aufnahmen kam er +nicht vor, die langsamste lag bei 71 — nachgemessen wurde er später an gebautem +Material (N1), und das Ergebnis ist schlechter als erwartet: Ein Stück bei +65 BPM wird nicht abgewiesen, sondern **mit rund 71 BPM gemeldet**, deutlich und +falsch. Die Begründung, warum die Grenze trotzdem steht, hängt an der Konstante +selbst. + +## Entschiedene Punkte + +**UI-Framework: egui/eframe** (MIT/Apache-2.0, GPU-beschleunigt über wgpu). + +Eine DJ-Oberfläche zeichnet ohnehin jedes Bild neu — laufende Waveforms, bewegte +Fader, Pegelanzeigen. Immediate-Mode ist dafür eher Vorteil als Nachteil, und +die Waveform wird selbst gemalt statt aus Widgets zusammengesetzt. + +Zwei Alternativen wurden verworfen: + +- **Tauri** wäre näher an vorhandener TypeScript-Erfahrung, aber die + Zustandskopplung zwischen UI und Audio ist eng und hochfrequent. Das über IPC + zu führen ist Aufwand, den egui nicht hat. Dazu hängt das Rendering an der + System-Webview, und unter Linux ist WebKitGTK bei canvas-lastigen Oberflächen + die schwächste der Engines. +- **Electron** bringt gebündeltes Chromium und damit identisches Rendering + überall, verlangt aber ein natives N-API-Addon zum Rust-Kern samt Prebuilds + für drei Plattformen — bei ~200 MB Bundle. Cross-Plattform ist dabei kein + Argument: egui und Tauri bauen genauso für Windows, macOS und Linux. + +**Streaming von Platte statt Vollentschlüsselung** (spätestens Phase 8). Vier +Decks à 100 MB gehen noch; vier Decks mit je vier Stems nicht mehr. Dann wird +aus dem Laden ein Vorpuffer plus Nachladen im Hintergrund. Mixxx hat genau das +gelöst und beschreibt es im Wiki — lesen, bevor wir es selbst entwerfen, siehe +[MIXXX.md](MIXXX.md#4-streaming-von-platte). + +## Risiken + +| Risiko | Auswirkung | Umgang | +| --- | --- | --- | +| Cue-Ausgang braucht bestimmte Hardware | Phase 1 nicht abnehmbar | Vor Phase 1 klären, nicht währenddessen | +| Eigene WSOLA klingt bei ±8 % nicht gut genug | Kernfunktion unbrauchbar | Rubber Band per FFI — steht offen, siehe [BAUSTEINE.md](BAUSTEINE.md) | +| Beatgrid-Erkennung bei nicht-geradem Material | Sync unbrauchbar für Teile der Sammlung | Manuelle Grid-Korrektur einplanen, nicht als Notlösung, sondern als Feature | +| Stem-Separation zu langsam | Phase 8 zieht sich | Beim Import vorberechnen, Fortschritt anzeigen, nie im Abspielpfad | +| UI schafft keine 60 fps mit Waveforms | Fühlt sich träge an | Peaks vorberechnet halten, nie im Zeichenpfad rechnen | + +## Nächster konkreter Schritt + +Vor Phase 1 steht eine Hörprobe: die vorhandene WSOLA mit einem echten Track bei +±8 % beurteilen. Fällt sie durch, ändert das die Reihenfolge — dann kommt der +Stretcher-Tausch vor dem Mehrdeck-Umbau, weil sonst auf einem wackligen +Fundament weitergebaut wird. diff --git a/docs/SPACE.md b/docs/SPACE.md new file mode 100644 index 0000000..e2a9790 --- /dev/null +++ b/docs/SPACE.md @@ -0,0 +1,215 @@ +# Der Space `musik` in VibeMind + +Spezifikation. Sie beschreibt, was in `Flissel/Vibemind_V1` angelegt werden +soll, damit Agenten diese Anlage bedienen können. + +Stand: September 2026. Die Schritte 1, 2, 3 und 5 stehen; 4 wartet auf den +Merge. Was Schritt 5 gemessen hat, steht in `spaces/musik/docs/ABNAHME.md` +in [Flissel/Vibemind_V1](https://github.com/Flissel/Vibemind_V1). + +Voraussetzung ist [FREIGABE.md](FREIGABE.md) — ohne das Gatter gehört die +Brücke nicht in einen Space. + +## Was dort schon steht, und was daraus folgt + +Gelesen in `Vibemind_V1`, nicht aus zweiter Hand: + +- **Genau ein Space:** `spaces/marketing/`. Er ist die Vorlage. +- **`mcp/docker/`** ist die Vorlage für einen Server: `server.py`, + `approval.py`, `pyproject.toml`, `tests/`, `README.md`, `__init__.py`. +- Server importieren `from mcp.server.fastmcp import FastMCP` — das SDK, nicht + das eigenständige `fastmcp`-Paket. Die Musik-Brücke nutzt heute das andere. +- **`repo-manifest.yaml`** führt größere Komponenten als Submodule mit + festgehaltenem Pin, Rolle und `migration_state`. +- Der Marketing-Space liegt bewusst **im Repo-Wurzelverzeichnis** und nicht + unter `vibemind-os/spaces/` — letzteres ist ein Submodul und wird bei + Submodul-Pulls zurückgesetzt. Eine frühere Fassung ging so verloren. + +Daraus folgt der Ablageort ohne weitere Diskussion: `spaces/musik/`, im +Wurzelverzeichnis. + +## Die Beweis-Disziplin + +Der Marketing-Space trennt streng zwischen **historischem Schnappschuss**, +**konfiguriert/statisch** und **verified_live** und nennt nichts das Dritte +ohne frische, operationsspezifische Evidenz. Diese Disziplin ist zu +übernehmen — sie passt ohnehin zu diesem Projekt, in dem seit dem ersten Tag +gilt, dass eine Zahl ohne Messung keine Zahl ist. + +Konkret für `STATUS.md`: + +- Jede Behauptung trägt ein Datum und ihre Art. +- „Phase 1 abgenommen" darf erst dastehen, wenn ein Gerät mit vier Ausgängen + daran hing. Bis dahin: *Code steht, Abnahme offen.* +- Die Testzahl ist statisch (`cargo test --workspace`), nicht live. +- Was nie an echter Musik lief, heißt „an gebautem Material geprüft" und nicht + „geprüft". + +## Aufbau + +```text +spaces/musik/ + README.md Was der Space ist, wie man ihn startet, was er nicht kann + AGENTS.md Einstieg für Agenten; Hard Rules zuerst + STATUS.md Schnappschuss mit Datum und Evidenzart + __init__.py + agents/ + mix_engineer.py Übergänge planen und fahren + track_waehler.py Auswahl aus der Sammlung + set_planer.py Dramaturgie über die Zeit + guardrail.py Vetorecht + api/ + __init__.py Dünne Hülle über die MCP-Werkzeuge + docs/ + ABNAHME.md Was geprüft ist, womit, und was fehlt +``` + +Die Rollen stammen aus [AGENTEN.md](AGENTEN.md). **Crowd-Sensor und +Energie-Analyst fehlen bewusst**: Die Signale haben noch keine Quelle, und ein +Agent, der eine erfindet, ist schlimmer als keiner. Wenn VibeMind einen Kanal +liefert, ist das ein `musik_signal`-Aufruf wie jeder andere. + +## Hard Rules für `AGENTS.md` + +Nach dem Muster des Marketing-Space: nummeriert, „niemals", und jede mit einem +Test hinterlegt, der sonst rot wird. + +1. **Niemals ein Shell- oder Dateiwerkzeug in diesem Space freischalten.** Das + Freigabe-Gatter sitzt in der Brücke; wer daneben eine Shell hat, umgeht es. + Diese Regel ist die Voraussetzung dafür, dass die anderen etwas bedeuten. +2. **Niemals die Klasse `erzeugen` in die Freigabe-Datei schreiben.** Generieren + kostet je Aufruf Geld und gehört hinter eine Bestätigung. +3. **Niemals `musik_set` auf ein Control anwenden, das ein `ramp` besser kann.** + Ein Sprung am Crossfader ist hörbar; das Repertoire ist dafür da. +4. **Niemals einen Übergang starten, während zwei Decks laufen.** Die Anlage + weist es ab (`err es laufen mehrere Decks`), und das ist kein Fehler, + sondern die Antwort. +5. **Niemals `master.arc` mitten im Set neu setzen.** Der Bogen ist das, was + jemand vorhatte; ihn nachträglich zu ändern macht den Vergleich zwischen + Plan und Abend wertlos. +6. **Niemals eine Zahl aus `section` oder `energie` als geprüft behandeln.** Die + Analyse ist nie gegen echte Musik mit gehörter Wahrheit gelaufen. +7. **Niemals länger als eine Phrase auf eine Antwort warten.** Wer nicht + rechtzeitig fertig ist, lässt den laufenden Track laufen — ein verpasster + Übergang ist besser als einer im Nichts. + +## Der Server: `mcp/musik/` + +Umzug der heutigen Brücke, in die dortige Form: + +**Erledigt** — die Brücke liegt seit `mcp/musik/` in dieser Form. + +| Vorher | Jetzt | +| --- | --- | +| `mcp/musik_mcp.py` | `mcp/musik/server.py` | +| — | `mcp/musik/freigabe.py` (aus [FREIGABE.md](FREIGABE.md)) | +| `mcp/test_musik_mcp.py` | `mcp/musik/tests/test_server.py` | +| `mcp/requirements.txt` | `mcp/musik/pyproject.toml` | +| `from fastmcp import FastMCP` | eine Weiche über beide SDK-Generationen | + +**Warum eine Weiche und nicht der Pfad aus dem Vorbild.** Im MCP-SDK heißt die +Klasse bis 1.x `FastMCP` unter `mcp.server.fastmcp`, ab 2.x `MCPServer` unter +`mcp.server.mcpserver`. VibeMinds eigener `mcp/docker/` importiert den alten +Pfad und deklariert `dependencies = ["mcp"]` — **ungepinnt**. Bei der nächsten +frischen Installation zieht das 2.x und bricht. Nachgemessen an 1.29.1 und +2.1.1: `tool()`, Konstruktor und `run()` sind gleich geblieben, nur Name und +Modulpfad nicht; sechs Zeilen `try/except` nehmen darum beides, und der Server +ist unter beiden geprüft. + +Das ist zugleich ein Fund für VibeMind: `mcp/docker/pyproject.toml` sollte +`mcp<2` pinnen oder dieselbe Weiche bekommen. + +Die sechzehn Werkzeuge bleiben, was sie sind. **Kein neues Werkzeug für den +Space** — wer für VibeMind etwas hinzufügt, das über den Socket nicht geht, baut +zwei Wege zum selben Ziel, und das ist die Sorte Fehler, die dieses Projekt +bisher vermieden hat. + +Zwei Dinge kommen aus der Umgebung: + +```json +{ + "env": { + "MUSIK_SOCKET": "/run/user/1000/musik.sock", + "MUSIK_FREIGABE_DATEI": "/run/user/1000/musik.freigabe" + } +} +``` + +Auf Windows stattdessen `MUSIK_TCP` und `MUSIK_SCHLUESSEL` — VibeMind zielt auf +Windows 11, das ist also der Normalfall und nicht die Ausnahme. + +## Der Eintrag im Manifest + +Dieses Repo als Submodul, nach dem Muster der bestehenden Einträge: + +```yaml + - id: musik + parent: vibemind-v1 + path: spaces/musik/engine + kind: submodule + remote: https://github.com/Flissel/Musik.git + remote_role: canonical-origin + owner: musik + required: false +``` + +`required: false`, weil ein VibeMind ohne DJ-Anlage vollständig ist. + +**Der Eintrag wartet auf den Merge, und das ist kein Versäumnis.** Beim Versuch, +ihn anzulegen, hat `tools.v1_governance manifest validate` ihn abgelehnt: +`invalid-evidence-state`. Das Manifest kennt für ein Kind des Superprojekts nur +zwei Evidenzformen — `local-git`/`local` mit echtem Gitlink, oder +`pinned-remote-tree`/`pinned-not-live` mit dem Pin des Elternteils. „Angemeldet, +noch nicht gepinnt" gibt es nicht. + +Und ein richtiger Pin ginge auch nicht: `main` dieses Repos steht bei +`3f4faf7`, dem Stand **vor** PR #1 — eine Anlage ohne Brücke, ohne Gatter, ohne +Repertoire. Den PR-Branch zu pinnen hieße, einen Commit festzuhalten, den ein +Squash-Merge verwaist. Der Eintrag gehört also in den Commit nach dem Merge, und +`submodule_policy: pin_change_requires_separate_commit` sagt genau das. + +## Reihenfolge und Abnahme + +| | Schritt | Fertig, wenn | Stand | +| --- | --- | --- | --- | +| 1 | Freigabe-Gatter | die zwölf Tests aus [FREIGABE.md](FREIGABE.md) grün sind | **steht** | +| 2 | Umzug nach `mcp/musik/` | `tools/list` über einen echten Client dieselben sechzehn Werkzeuge zeigt | **steht** | +| 3 | `spaces/musik/` mit den drei Dokumenten | `AGENTS.md` die sieben Hard Rules trägt | **steht** | +| 4 | Manifest-Eintrag | `python -m tools.v1_governance` nicht meckert | wartet auf den Merge | +| 5 | Mix-Engineer | er einen Übergang fährt, den `musik-kritik` hinterher wiederfindet | **steht** | + +**Schritt 5 ist der eigentliche Nachweis.** Alles davor ist Verkabelung. Erst +wenn ein Agent einen Übergang gefahren hat und der Kritiker ihn im Mitschnitt +findet — mit Dauer, Phrasenlage und Pegelverlauf —, ist gezeigt, dass die Kette +trägt. Das Werkzeug dafür steht seit S1 und ist an 24 Sets vermessen: Ein +harter Schnitt wird auf ein Fenster genau gefunden, eine Blende über 32 Beats +im Mittel 4,9 s zu spät ([FAHRPLAN.md](FAHRPLAN.md), N3). + +**Gefahren, zweimal.** Der Agent wählte `blende` über 32 Beats, nannte seinen +Grund und benannte die beiden Zahlen, auf die er sich stützte und die niemand +geprüft hat. Die Anlage setzte ihn 0,04 Beats in die Phrase, fuhr 32,0 von 32 +Beats in 15,0 s, das Tempo hielt (127,99 → 127,96 BPM), kein Frame verworfen — +und der Kritiker fand ihn wieder und ordnete ihn dem Griff zu. Auf gebautem +Material, kopflos, ohne dass jemand zugehört hat; das ist ein echtes Ergebnis +und ein schmales. + +**Und der Lauf hat einen Fehler gefunden, den keine Testsuite hatte.** Der +Pegelbericht des Kritikers las eine Sekunde als zwei und beschrieb damit eine +Stelle bei halber Zeit — „64 % Einbruch in der Mitte" meinte den Break des +ausgehenden Tracks, fünfzehn Sekunden vor dem Griff, an dem kein Regler bewegt +worden war. Behoben (`PEGEL_SEK`, `pegel_um`), und die Warnung darüber trennt +jetzt zwei Ursachen, die gleich aussehen: eine Kurve, die Leistung verliert, +und zwei Tracks, die verschieden laut sind. + +## Offene Punkte + +- **Läuft die Anlage auf demselben Rechner wie VibeMind?** Der Socket setzt das + voraus. Über Netz ginge nur der Weg über die Rückschleife, und der endet an + der Maschinengrenze. Ein Agent auf einem anderen Rechner bräuchte einen + dritten Transport — das ist eine eigene Aufgabe. +- **Wer erteilt die Set-Freigabe im Betrieb?** Ein Mensch am Pult, ein + Startskript, oder Brain? Die Spezifikation lässt es offen; der Space muss es + festlegen, sonst legt es sich von selbst fest. +- **Was passiert, wenn der Agent stumm bleibt?** Heute läuft der Track weiter + und niemand merkt es. Ein Wächter, der nach N Beats ohne Plan meldet, wäre + klein und sinnvoll — er gehört in den Guardrail. diff --git a/docs/STEUERUNG.md b/docs/STEUERUNG.md new file mode 100644 index 0000000..a450495 --- /dev/null +++ b/docs/STEUERUNG.md @@ -0,0 +1,1285 @@ +# Steuerung von außen + +Die Anlage lässt sich fernsteuern, während sie läuft. Ein Socket, ein +Zeilenprotokoll, keine Bibliothek nötig: + +```sh +$ nc -U "$XDG_RUNTIME_DIR/musik.sock" +set deck1.play 1 +ok deck1.play 1 +``` + +Das ist der Punkt, an dem sich dieses Projekt von den fertigen Alternativen +unterscheidet. Mixxx hat ein sehr ähnliches Control-System im Inneren — dort +laufen Tastatur, MIDI, HID und Oberfläche über dieselben benannten Werte — +aber es ist prozessintern. Nach außen spricht Mixxx OSC nur *ausgehend*: +Es sendet Zustand, es nimmt keine Befehle entgegen. Siehe +[MIXXX.md](MIXXX.md#was-mixxx-nicht-löst). + +Ohne diese Schnittstelle gäbe es die Agenten-Schicht aus +[AGENTEN.md](AGENTEN.md) nicht, und der Anschluss an +[VibeMind](VIBEMIND.md) hätte nichts, woran er andocken könnte. + +## Der Steuerraum + +Jedes Control heißt `gruppe.element`: + +| Gruppe | Was | +| --- | --- | +| `deck1`, `deck2`, … | Abspieler: Transport, Tempo, Hot Cues, Schleifen | +| `channel1`, `channel2`, … | Züge am Mischpult: Trim, EQ, Filter, Fader, Cue | +| `master` | Crossfader, Summe, Kopfhörer | + +Deck und Kanal sind absichtlich getrennt. Bei Mixxx fallen sie in `[Channel1]` +zusammen, was so lange gutgeht, bis ein Kanal etwas führt, das kein Deck ist — +bei uns der AUX-Eingang, der einen Zug hat und kein Deck. + +## Befehle + +| Befehl | Wirkung | +| --- | --- | +| `list [praefix]` | Controls aufzählen, gefiltert nach Namensanfang | +| `get ` | Wert lesen | +| `set ` | Wert setzen; `-` leert (Hot Cues) | +| `setn <0..1>` | normiert setzen — für MIDI | +| `do [arg]` | Aktion auslösen | +| `sub …` | Änderungen melden lassen, statt zu fragen | +| `unsub [control]…` | abbestellen; ohne Argument alles | +| `help` | Übersicht | + +Antworten beginnen mit `control`, `value`, `ok` oder `err`. Ein `err` ist immer +als solches erkennbar; nichts scheitert stumm. + +## Aktionen sind keine Werte + +`sync`, `load`, einen Hot Cue anspringen — das sind Auslöser, keine +Reglerstellungen. Mixxx modelliert so etwas als Control, in das man 1.0 +schreibt (`[Channel1],cue_gotoandplay`). Das funktioniert, ist aber dieselbe +Schwäche wie beim `double`: Ein Auslöser hat keinen Zustand, den man lesen +könnte, und `get` darauf müsste etwas erfinden. + +Hier haben sie ein eigenes Verb: + +```text +do deck2.sync → sync deck2 auf deck1 tempo 1.01570 phase -0.1178 +do deck1.load /musik/a.wav → load deck1 angenommen +do deck2.jump_cue 1 +do deck1.beatjump -4 +do master.search techno +do master.search_mixable 128 +do master.search_harmonic 8A +get deck1.sync → err deck1.sync ist eine Aktion — mit 'do' auslösen +set deck1.sync 1 → err deck1.sync ist eine Aktion — mit 'do' auslösen +do deck1.play → err deck1.play ist keine Aktion — mit 'set' setzen +``` + +**`sync` ist Tempo *und* Phase**, in einem Befehl. Ohne das müsste ein Agent +beide BPM lesen, den Quotienten bilden, das Tempo setzen und die Phase selbst +ausrechnen — und die Phase vergisst man dabei, weil das Ergebnis auch ohne sie +plausibel aussieht. + +**`load` arbeitet im Hintergrund.** Dekodieren und Analysieren dauern Sekunden, +und das Pult liegt währenddessen unter einem Mutex, an dem die Oberfläche +hängt. Der Befehl meldet deshalb *Annahme*, nicht Erledigung; der Fortschritt +steht in `deckN.load_status`. Ein Auftrag, der abgelehnt wird — falscher Pfad +etwa — lässt den Status unberührt, statt ein Laden vorzutäuschen, das nie +begonnen hat. + +## Zuhören statt fragen + +```text +sub deck1.finished deck1.position +ok sub 2 neu, 2 gesamt +value deck1.finished 0 +value deck1.position 12.354000 +value deck1.position 12.404000 +… +value deck1.finished 1 +``` + +Das erste, was ein Abo meldet, ist der Ist-Zustand — sonst müsste man nach dem +Abonnieren noch einmal `get` sagen. Danach kommt nur, was sich geändert hat. + +Ehrlich gesagt: Das Pult meldet nichts von sich aus, der Server vergleicht alle +50 ms. Für Werte, die im Audio-Thread laufen, wäre eine echte Benachrichtigung +auch sinnlos — die Position ändert sich mit jedem Sample, die will niemand +vollständig. Der Gewinn liegt darin, dass der Bediener das nicht selbst bauen +muss, und dass `deckN.finished` ohne Dauerabfrage ankommt. + +## Der Steuerraum beschreibt sich selbst + +Das ist der zweite Unterschied. `list` liefert nicht bloß Namen, sondern +Typ, Bereich, Einheit, Schreibbarkeit und eine Bedeutung: + +```text +$ echo 'list deck1.tempo' | nc -U -q1 "$XDG_RUNTIME_DIR/musik.sock" +control deck1.tempo zahl 0.92..1.08 faktor rw Tempo-Regler; 1.0 ist die Originalgeschwindigkeit +ok 1 Controls +``` + +Wer das gelesen hat, braucht kein Handbuch: Er weiß, dass es eine Zahl ist, +zwischen 0,92 und 1,08 liegt, ein Faktor ist, sich schreiben lässt und was sie +bedeutet. Bei Mixxx steht all das in der Dokumentation, und ein laufendes +Programm lässt sich nicht fragen. + +Der praktische Gewinn ist doppelt: + +- **Für Agenten.** Die MCP-Werkzeugbeschreibungen lassen sich aus dem Katalog + erzeugen, statt sie von Hand doppelt zu pflegen — zwei Beschreibungen, die + auseinanderlaufen, gibt es dann nicht. +- **Für die eigene Oberfläche.** Die Schieberegler im Mixer holen sich Bereich + und Tooltip aus demselben Katalog. Ein neues Control ist ohne eine Zeile in + der Oberfläche bedienbar, und der Hilfetext, den ein Agent sieht, ist + derselbe, der unter der Maus erscheint. + +## Werte sind typisiert + +Bei Mixxx ist jedes Control ein `double`; `play` ist 0.0 oder 1.0, eine +Zuweisung eine durchnummerierte Zahl. Das ist einfach zu bauen und schwer zu +benutzen — ein Tippfehler wird zu einem gültigen Wert statt zu einem Fehler. + +Hier gibt es Schalter, Zahlen mit Bereich, Auswahlen mit Namen und Text: + +```text +set channel1.assign thru → ok channel1.assign thru +set channel1.assign mitte → err channel1.assign kennt nur: a, b, thru +set deck1.duration 5 → err deck1.duration lässt sich nur lesen +``` + +Zwei bewusste Ausnahmen von der Strenge: + +- **Werte außerhalb des Bereichs werden begrenzt, nicht abgelehnt.** Ein + MIDI-Regler auf Anschlag meint das Maximum, keinen Fehler. +- **Die Bestätigung nennt den Wert, der wirklich angekommen ist** — nicht den + gesendeten. `set channel1.fader 9` antwortet `ok channel1.fader 1`. + +## Ein vollständiger Übergang, der wirklich lief + +Kein Mausklick, keine Kenntnis der Oberfläche — nur Control-Namen: + +```sh +do master.search Alpen # was gibt es? +do deck1.load "…/Alpenglühen.wav" # auflegen +do master.search_mixable 128 # was passt vom Tempo? +get deck1.key_camelot # → 8A +do master.search_harmonic 8A # was passt von der Tonart? +do deck2.load "…/Nachtschicht.wav" +set deck1.play 1 +set channel1.fader 0.9 +do deck2.sync # Tempo UND Phase, in einem Befehl +set deck2.cue1 8.0 +do deck2.jump_cue 1 +set deck2.play 1 +setn channel2.eq_low 0 # Bass raus … +set channel2.fader 0.9 +set master.crossfader 0.25 +setn channel1.eq_low 0 # … und drüben rein: Bass-Swap +setn channel2.eq_low 0.5 +do deck1.beatjump -4 +sub deck1.finished deck1.position # aufs Ende horchen +``` + +Die Antwort auf `do deck2.sync` war + +```text +sync deck2 auf deck1 tempo 1.01570 phase -0.1178 +``` + +und danach standen beide Decks auf **127,986 BPM** — 126,008 × 1,0157. Die +Oberfläche zeigte das Ergebnis unmittelbar: geladene Titel, Tempo +1,57 %, +die Hot-Cue-Marke in der Wellenform, gekillten Bass, Crossfader auf +0,25. + +![Ein Übergang, komplett von außen](bilder/agent-uebergang.png) + +Für eine ältere, einfachere Aufnahme ohne Laden und Sync siehe +[`bilder/fernsteuerung.png`](bilder/fernsteuerung.png). + +## Warum ein Unix-Socket und kein TCP-Port + +Eine Sicherheitsentscheidung, keine Bequemlichkeit. Wer hier hineinschreibt, +steuert die Anlage. Ein offener TCP-Port täte das für jeden im selben Netz — +auf einer Bühne mit fremdem WLAN ist das keine theoretische Sorge. Ein +Unix-Socket erbt die Rechte des Dateisystems: Wer die Datei nicht öffnen darf, +kommt nicht hinein. + +Der Standardpfad liegt aus demselben Grund in `$XDG_RUNTIME_DIR` und nicht in +`/tmp` — dort könnte jeder andere Benutzer des Rechners mitsteuern. Mit +`--socket ` lässt er sich verlegen. + +Über den Socket gibt es **keine Authentifizierung**. Sie wäre bei +Dateisystemrechten verdoppelt. + +### Wo es keinen Socket gibt + +Auf Windows gibt es keine Unix-Sockets, und dort lief die Anlage bis vor Kurzem +**ohne jede Steuerung** — Oberfläche, Decks und Mixer ja, Fernsteuerung nein, +und damit auch kein MCP und keine Agenten. Also genau der Teil, um +dessentwillen das Projekt gebaut wird. + +`musik-app --tcp` lauscht deshalb auf der Rückschleife. Der Absatz oben hatte +vorausgesagt, wo dann nachzurüsten ist, und genau das ist geschehen: + +```text +$ musik-app --tcp +Steuerung: 127.0.0.1:7657 +Schlüssel: 50270126063f57dd96e73b655ce56d5a + +$ nc 127.0.0.1 7657 +auth 50270126063f57dd96e73b655ce56d5a +ok angemeldet +set deck1.play 1 +``` + +Zwei Dinge sind dabei enger gebaut als beim Socket, weil TCP von sich aus +weiter offen steht: + +- **Nur die Rückschleife.** Gebunden wird ausschließlich an `127.0.0.1` oder + `::1`; eine andere Adresse wird abgelehnt, nicht stillschweigend übernommen. + Ein Tippfehler soll die Anlage nicht ins WLAN stellen. Geprüft wird **vor** + dem Binden — danach läge zwischen Öffnen und Schließen genau das Fenster, das + man nicht haben will. +- **Ein Schlüssel je Start.** Die erste Zeile muss `auth ` sein, + sonst wird nichts ausgeführt, auch nichts Lesendes: Wer nicht hereindarf, soll + nicht erfahren, was gerade läuft. Der Schlüssel steht nur im Fenster der + Anwendung — in eine Datei geschrieben läge er dort, wo ihn jeder findet. + +**Der Schlüssel ist kein Zierrat.** Die Rückschleife ist nicht privat: Jedes +Programm auf demselben Rechner erreicht sie, eine Webseite im Browser +eingeschlossen. Ein `fetch` an `http://127.0.0.1:7657` schickt Zeilen, die +dieses Protokoll liest. Nachgestellt mit einer echten HTTP-Anfrage samt Rumpf +`set master.crossfader 1`: Jede Zeile — Anfragezeile, Kopfzeilen und Rumpf — +bekam `err nicht angemeldet`, und der Crossfader stand hinterher unverändert +auf −0,5. + +Wo es einen Unix-Socket gibt, bleibt er die erste Wahl: Dort erledigt das +Dateisystem, wofür hier ein Schlüssel nötig ist. + +## Echtzeit bleibt unangetastet + +Das Pult schreibt nie in den Audio-Callback. Es schickt Kommandos in dieselbe +lock-freie Schlange, die auch die Oberfläche benutzt, und liest aus Atomics +und aus dem eigenen Spiegel. Der Mutex um das Pult wird nur von Bedienern +genommen — Oberfläche, Socket, später MIDI. Der Audio-Thread sieht ihn nie. + +## Die Probe aufs Exempel: die Effekte + +Als die Effekte dazukamen, bekam die Oberfläche **keine einzige Zeile** für +sie. Der Kanalzug holt sich seine Regler aus dem Katalog — Reihenfolge, +Bereich, Beschriftung, Hilfetext. Vier neue Einträge im Katalog, und das +FX-Feld stand da, samt Auswahlfeld mit den richtigen Namen: + +```text +$ echo 'list channel1.fx' | nc -U -q1 "$XDG_RUNTIME_DIR/musik.sock" +control channel1.fx auswahl off|delay|gater|flanger|crush - rw Effekt hinter dem Fader; … +``` + +Ein Nebeneffekt davon ist, dass die Katalogreihenfolge jetzt die Anordnung am +Gerät ist: Höhen oben, Bässe unten. Beim ersten Versuch stand der EQ +verkehrtherum, weil im Katalog `eq_low` vor `eq_high` stand — im Zweifel greift +man beim Auflegen daneben, und deshalb ist die Reihenfolge dort jetzt +festgehalten und begründet. + +![Effekte, von außen gesetzt](bilder/effekte.png) + +## Mitschnitt + +```text +do master.record ~/sets/2026-08-13.wav +record läuft nach ~/sets/2026-08-13.wav +mitschrift ~/sets/2026-08-13.mitschrift +get master.record_seconds → value master.record_seconds 6.784000 +get master.record_dropped → value master.record_dropped 0 +do master.record_stop → record ~/sets/2026-08-13.wav · 6.8 s + mitschrift ~/sets/2026-08-13.mitschrift +``` + +Aufgenommen wird die Summe **hinter dem Begrenzer** — also das, was auf die +Anlage geht, und nicht das, was vorher da war. + +`record_dropped` ist kein Beiwerk. Der Audio-Thread darf nicht auf die Platte +schreiben; er legt in einen Ringpuffer, ein eigener Thread leert ihn. Kommt der +nicht hinterher, gehen Frames verloren — blockieren wäre schlimmer. Verloren +heißt hier aber nicht verschwiegen: Der Zähler ist abfragbar, und `record_stop` +sagt es von sich aus dazu. Ein Mitschnitt mit Lücken, der aussieht wie einer +ohne, wäre das schlechteste Ergebnis von allen. + +## Die Mitschrift + +Neben dem Mitschnitt entsteht eine zweite Datei, gleicher Name, Endung +`.mitschrift`. Sie wird nicht einzeln eingeschaltet — der Klang ohne die +Absicht ist die Hälfte der Geschichte. + +```text +# musik-mitschrift 1 +# mitschnitt /pfad/set.wav +# rate 48000 +962560 20.053 deck1=41.187/16 deck2=-0.998/16~ > in phrase set deck2.play 1 +1116160 23.253 deck1=48.012/16 deck2=-0.998/16~ > ramp master.crossfader 1 32 +1837056 38.272 deck1=80.049/16 deck2=31.039/16 < plan 3 fertig master.crossfader 1.0000 +``` + +Je Zeile: **Frame im Mitschnitt**, Sekunden zur Bequemlichkeit, dann für jedes +Deck mit Beatgrid `deck=/` — ein angehängtes `~` heißt, das +Deck stand. `>` ist hereingekommen, `<` ist hinausgegangen. + +Drinsteht, was **verändert**: `set`, `setn`, `do`, `ramp`, `in`, `when`, +`cancel`, dazu die Meldungen des Zeitplans. `get`, `list`, `plan` und `help` +fragen nur und stünden sonst in jeder zweiten Zeile. + +**Wozu.** Der Griff an den Fader steht nicht im Klang. Am Anfang einer langen +Blende ist der eingehende Track per Konstruktion unhörbar; wer den Beginn +nachträglich aus dem Mitschnitt schätzt, liegt an einem gemessenen Beispiel +3,7 Sekunden daneben — über 24 gefahrene Sets im Median 2,0 Sekunden, bei +langen Blenden bis 7,6, und über 64 Beats findet er den Übergang gar nicht +mehr. Dieselbe Lücke bei der Phrasenlage: Der Anker eines +nachträglich geschätzten Rasters ist irgendein starker Schlag, nicht die Eins. +Beides weiß die Anlage im Moment des Geschehens genau. + +Gelesen wird sie von `musik-kritik`, der sie von selbst neben dem Mitschnitt +findet. Maßgeblich ist immer der **Frame**; die Sekunden in der Zeile sind für +Menschen da und werden beim Lesen aus der Rate neu gerechnet. + +## Antworten lesen + +Wer einen eigenen Bediener schreibt, braucht eine Abbruchregel. Eine Antwort +endet mit einer Zeile, die mit `ok`, `err` oder `value` beginnt: + +| Befehl | Antwort | +| --- | --- | +| `get` | genau eine `value`-Zeile | +| `list` | eine `control`-Zeile je Treffer, dann `ok N Controls` | +| `set`, `setn` | eine `ok`- oder `err`-Zeile | +| `do` | keine bis mehrere Infozeilen, dann `ok` oder `err` | +| `sub` | `ok`, danach `value`-Zeilen, wann immer sich etwas ändert | + +Ein Abo ist der einzige Fall, in dem ungefragt etwas hereinkommt. Wer +abonniert, sollte deshalb getrennt lesen und schreiben. + +## Anschluss für Agenten: MCP + +Für Agenten liegt eine Brücke bei — [`mcp/musik/`](../mcp/musik/README.md), ein +MCP-Server, der dieses Protokoll in Werkzeuge übersetzt. Vierzehn Werkzeuge statt +zweihundert: `musik_set` und `musik_do` erreichen alles, `musik_status` und +`musik_search` sparen die häufigsten Wege, `musik_ramp`, `musik_schedule` und +`musik_cancel` reichen den Zeitplan durch, `musik_queue`, `musik_queue_add` und +`musik_queue_next` die Liste. + +Der Gewinn aus dem selbstbeschreibenden Katalog wird dort eingelöst: Die +Beschreibungen von `musik_set` und `musik_do` **erzeugt der Server beim Start +aus dem laufenden Programm**. Was ein Agent liest, ist dasselbe, was im Katalog +steht und in der Oberfläche als Tooltip erscheint. + +Beim Bauen fiel dabei auf, dass `list` das Argument einer Aktion nicht +mitgab — es stand im Katalog, verließ aber den Prozess nie. Jetzt steht es in +der Bereichsspalte, und `deck1.load` sagt von sich aus, dass es einen Pfad +will. + +## Zeit: Übergänge statt Reglerstellungen + +Der Teil, der aus dem Steuerraum ein Werkzeug für ein **Team von Agenten** +macht. Ein Übergang ist keine Folge von Reglerstellungen, sondern eine Bewegung +über Takte. Wer das von außen nachbaut, müsste in einer engen Schleife +`beat_phase` pollen und dazwischen schlafen — über eine Leitung, deren Timing +dem Scheduler des Betriebssystems ausgeliefert ist. Das eiert hörbar, und der +Agent ist die ganze Zeit blockiert. + +```text +set channel2.fader 0.0 +ramp channel1.eq_low 0.0 8 # Bass raus über 8 Beats +in 8 ramp channel2.fader 0.9 16 # danach Deck B rein über 16 +in 8 ramp master.crossfader 1.0 16 +plan # was vorgemerkt ist +cancel 2 # einen Auftrag zurücknehmen +``` + +Drei Zeilen, dann kann der Agent auflegen. Gelaufen ist es so: + +| nach | `channel1.eq_low` | `channel2.fader` | `master.crossfader` | +| --- | --- | --- | --- | +| 2 s | 0,47 | 0 | 0 | +| 5 s | 0 | 0,13 | 0,15 | +| 9 s | 0 | 0,90 | 1,00 | + +**Gerechnet wird in Beats, nicht in Sekunden.** Ein Plan in Sekunden geht +schief, sobald jemand am Tempo dreht; einer in Beats bleibt musikalisch +richtig. Steht das Deck, steht auch der Plan — was gestoppt ist, hat keine +Takte. Welches Deck den Takt vorgibt, ergibt sich von selbst: Ein Kanalzug erbt +ihn von dem Deck, das auf ihm liegt, ein Deck ist sein eigener Taktgeber, und +die Summe nimmt das erste Deck mit Grid. Mit einem vierten Wort lässt sich das +überschreiben (`ramp master.crossfader 1.0 16 deck2`). + +### Der Mensch gewinnt + +**Eine Rampe gibt auf, sobald jemand anders denselben Regler anfasst.** + +```text +> ramp channel1.fader 0.0 64 +< ok plan 5 ramp channel1.fader nach 0 über 64 Beats + … nach 3 s steht der Fader bei 0.909699 und wandert +> set channel1.fader 0.75 + … nach 3 s steht er immer noch bei 0.75, und der Plan ist leer +``` + +Ohne das wäre die Automatik stärker als der Griff daneben: Man zieht den Fader +zu, und einen Wimpernschlag später steht er wieder offen. Geprüft wird nicht +über eine Kennung, sondern über den Wert selbst — steht dort etwas anderes, als +die Rampe zuletzt geschrieben hat, war jemand anders am Werk. Das gilt für +einen Menschen an der Oberfläche genauso wie für einen zweiten Agenten. + +Damit ist `plan` zugleich das **gemeinsame Blatt**: Wer mitliest, sieht, was die +anderen vorhaben, statt es aus Reglerbewegungen zu erraten. Und weil der Mensch +an der Oberfläche derselbe Mitbediener ist, steht es dort auch — in der +Regie-Spalte rechts, mit Fortschrittsbalken je Rampe. Ohne sie gewönne er jeden +Griff blind: Er sähe einen Fader wandern und wüsste nicht, ob ihn jemand zieht +oder ob er selbst hängengeblieben ist. + +![Die Regie-Spalte](bilder/regie.png) + +Der Takt liegt bei 5 ms — bei 128 BPM etwa ein Prozent eines Beats. Für eine +Blende unhörbar, für einen harten Schnitt auf die Eins gerade noch vertretbar. +**Sample-genau ist das ausdrücklich nicht**; dafür müssten die Befehle im +Audio-Callback liegen, und dorthin gehört keine Reglerlogik. + +### Auf der nächsten Eins + +`in` nimmt statt einer Zahl auch `phrase` — die nächste Phrasengrenze des ersten +Decks mit Beatgrid. + +```text +in phrase do deck2.sync # auf der nächsten Eins +in phrase+16 ramp master.crossfader 1 32 # eine Phrase später, dann blenden +``` + +**Das ist fast immer das Richtige.** Ein Übergang beginnt auf der Eins einer +Phrase, nicht nach einer runden Zahl Beats. Wer ihn mit `in 32` nachbaut, trifft +irgendwo hin — und der Mix klingt danach, egal wie sauber alles andere sitzt. + +`ramp` muss dafür nichts von Phrasen wissen: `in phrase ramp …` setzt die +Bewegung auf die Grenze, weil `in` eine beliebige Protokollzeile trägt. Ein +zweiter Phrasenbegriff im Rampen-Verb wäre eine zweite Stelle zum +Auseinanderlaufen. + +Ohne Deck mit Beatgrid hat `phrase` keinen Bezugspunkt und wird abgewiesen, +statt stillschweigend auf null zu fallen. + +### Wessen Takte gemeint sind + +```text +in deck2 phrase+16 set deck1.loop_active 0 +``` + +Ohne Angabe nimmt `in` das erste Deck mit Beatgrid. Steht ein `deckN` direkt +hinter `in`, ist dessen Takt gemeint — auch wenn der Befehl auf ein anderes Deck +wirkt. + +**Gebraucht wird das, sobald ein Deck in einer Schleife läuft.** Dessen Beat +wiederholt sich, und alles, was daran hängt, wiederholt sich mit: Eine Rampe +fängt bei jedem Durchlauf von vorn an, und ein Vorgemerktes hinter dem +Schleifenende kommt nie an — auch nicht die Zeile, die die Schleife wieder +lösen soll. + +**Läuft der Track eines Taktgebers aus, bricht sein Plan ab und sagt das:** + +```text +plan 17 abgebrochen — deck1 ist durchgelaufen +``` + +Am laufenden Programm gefunden, beim ersten Übergang, der sich selbst +auslöste: Der ausgehende Track lief mitten im Bass-Swap aus, und danach stand +der Crossfader für immer in der Mitte, mit fünf toten Aufträgen im Plan. Ein +Bediener sieht dort einen Übergang, der läuft, und hört einen, der steht. Genau +gegen diesen Fall gibt es den Griff `schleife`. + +Ein bloß **angehaltenes** Deck hält den Plan dagegen nur an — dort wartet er +absichtlich weiter. Wer pausiert, will da weitermachen, wo er aufgehört hat. + +**Springt ein Taktgeber zurück, bricht sein Plan ab und sagt das:** + +```text +plan 3 abgebrochen — deck1 ist zurückgesprungen (Schleife oder Sprung) +``` + +Das ist der Fall bei jeder Schleife und bei jedem Sprung im Track. Ihn still +weiterlaufen zu lassen wäre das Schlimmste von allem: Der Bediener sieht einen +Plan, der läuft, und hört etwas anderes. Am laufenden Programm sah man den +Crossfader sieben Mal hin und her fahren, bevor das hier stand. + +### Sobald es so weit ist + +`in` wartet auf Takte. Die Frage, die beim Auflegen wirklich gestellt wird, +lautet aber anders: **wann ist der Track fast durch?** Dafür gibt es `when`. + +```text +when deck1.beats_left < 32 do master.queue_next # nächsten auflegen +when deck1.beats_left < 16 do deck2.sync +when deck1.beats_left < 16 ramp channel2.fader 0.9 16 +plan + plan 4 wenn deck1.beats_left < 32: do master.queue_next (steht bei 47.20) +``` + +Und damit das überhaupt fragbar ist, tragen die Decks die Größen, die man sonst +selbst ausrechnet: + +| Control | Was | +| --- | --- | +| `deckN.beat` | Der wievielte Beat gerade läuft, vom Grid-Anker gezählt | +| `deckN.beats_left` | Beats bis zum Ende — danach richtet sich der Übergang | +| `deckN.beats_to_phrase` | Beats bis zur nächsten Phrasengrenze | +| `deckN.phrase_beats` | Wie lang eine Phrase ist; 16 als Vorgabe, schreibbar | + +**Alles in Grid-Beats, nicht in Sekunden.** Ein Beat ist eine feste Zahl +Quell-Frames; schneller abgespielt vergeht er in weniger Zeit, aber es werden +davon nicht mehr. Genauso rechnen `in` und `ramp` — „noch 32 Beats" und „in 32 +Beats" meinen dieselbe Strecke. Zwei Zeitrechnungen im selben Steuerraum wären +eine Falle. + +Die Phrasenlänge steht je Deck und nicht global: Sie ist eine Eigenschaft der +Musik, und ein Stück in Achtergruppen kann gleichzeitig mit einem in Sechzehnern +auf den Decks liegen. + +Drei Dinge sind an `when` bewusst so: + +- **Trifft die Bedingung schon zu, läuft der Befehl sofort.** `when` heißt + „sobald es so weit ist", nicht „beim nächsten Überschreiten". Wer auf eine + Flanke warten will, prüft vorher selbst. +- **Ein Control, das keine Zahl ist, wird beim Vormerken abgewiesen.** Ein + Auftrag auf `deck1.play < 1` würde stumm für immer warten, und das ist + schlimmer als eine Absage. +- **Ein `when` braucht keinen Taktgeber.** Es hängt an einem Wert, nicht an + Takten, und läuft deshalb auch dann, wenn gerade kein Deck ein Grid hat. + +Ohne dieses Verb müsste ein Bediener `beats_left` abonnieren und bekäme zwanzig +Zahlen je Sekunde, aus denen er die eine Schwelle selbst heraussucht. Über eine +Chat-Schnittstelle ist das nicht nur unbequem, sondern unbezahlbar. + +### Was von außen hereinkommt + +Ein DJ liest die Fläche. Ein Agent kann das nicht sehen — also muss es jemand +hereingeben: ein Mikrofonpegel, eine Umfrage auf dem Handy, ein Mensch, der im +Chat „wird voller" tippt. + +```text +set master.signal1_name Energie auf der Flaeche +set master.signal1 0.2 +… zwei Minuten später … +set master.signal1 0.7 +get master.signal1_trend → value master.signal1_trend 1.280000 +when master.signal1_trend < -0.3 do master.queue_next +``` + +**Ein einzelner Wert nützt fast nichts.** „Energie 0,7" beantwortet keine Frage; +„0,7 und seit zwei Minuten fallend" beantwortet sie. Deshalb merkt sich ein +Signal seine jüngste Vergangenheit und rechnet daraus den Trend — dieselbe +Überlegung wie bei `beats_left`: Was ein Bediener sonst bei jedem Blick selbst +ausrechnet, rechnet er irgendwann falsch. + +| Control | Was | +| --- | --- | +| `master.signalN` | Der Messwert, −1 bis 1 | +| `master.signalN_name` | Wofür er steht; leer heißt ungenutzt | +| `master.signalN_trend` | Änderung je Minute, aus einer Ausgleichsgeraden | +| `master.signalN_age` | Sekunden seit der letzten Meldung | + +Vier feste Plätze mit beschriftbarem Namen, wie ein Kanalzug, den man mit +Klebeband beschriftet. Beliebige Namen bräuchten zur Laufzeit geleakte +Zeichenketten, und dieselbe Entscheidung ist schon bei den Hot Cues so gefallen. + +Drei Dinge sind bewusst so: + +- **Die Steigung kommt aus einer Ausgleichsgeraden**, nicht aus „letzter minus + erster". Eine einzelne Fehlmessung am Rand würde daraus sonst eine Behauptung + machen, die die übrigen Proben nicht hergeben. +- **Aus einer Probe wird kein Trend** — dort steht `-`, nicht 0. Eine Null wäre + die Aussage „ändert sich nicht", und die hat niemand getroffen. Genauso ist + ein ungenutztes Signal leer und nicht null. +- **Der Wert bleibt stehen, auch wenn lange nichts kam.** Er ist die letzte + bekannte Lage, und die verschwindet nicht dadurch, dass niemand mehr misst. + Wie alt sie ist, sagt `_age`; der Trend fällt nach zwei Minuten von selbst weg. + +Damit ist ein Signal ein Control wie jedes andere — `sub`, `when` und `ramp` +greifen darauf, ohne dass dafür eine Zeile geschrieben werden musste. Das ist +der ganze Gewinn eines benannten Steuerraums. + +### Über MCP + +Dieselben vier Verben, für einen Agenten, der kein Zeilenprotokoll spricht: + +| Protokoll | MCP | +| --- | --- | +| `ramp [deck]` | `musik_ramp` | +| `in ramp …` | `musik_ramp` mit `in_beats` | +| `in ` | `musik_schedule` | +| `in phrase[+n] ` | `musik_schedule` mit `ab_phrase`, `musik_ramp` ebenso | +| `when < ` | `musik_when` | +| `set master.signalN …` | `musik_signal` (sucht den Platz selbst) | +| `plan` | Feld `plan` in `musik_status` | +| `cancel [id]` | `musik_cancel` | + +Der Plan hängt bewusst an `musik_status` statt an einem eigenen Werkzeug: Wer +eine Momentaufnahme nimmt, bevor er zugreift, soll die Absichten der anderen +sehen, ohne sie extra abfragen zu müssen. Und `musik_cancel` verlangt für +„alles" einen eigenen Schalter — eine vergessene Nummer soll nicht die Arbeit +der anderen leeren. + +## Was als Nächstes kommt + +Der Zeitplan sagt, **wann** etwas geschieht. Die Liste sagt, **was** als +Nächstes kommt — die zweite Hälfte der Koordination, sobald mehr als einer +auswählt. + +```text +do master.queue_add /musik/track.mp3 → queue 1 angehaengt /musik/track.mp3 +do master.queue_note 1 mehr Druck nach dem Break +do master.queue → queue 1 /musik/track.mp3 mehr Druck nach dem Break +do master.queue_bump 1 → der als Nächstes +do master.queue_next → load deck2 angenommen + queue 1 abgenommen /musik/track.mp3 + notiz mehr Druck nach dem Break +``` + +Sie liegt im Pult, hinter demselben Mutex wie alles andere. Wer abnimmt, nimmt +den Eintrag heraus — ein zweiter, der im selben Moment abnimmt, bekommt den +nächsten und nicht denselben. Zwei Agenten, die ihre Liste je für sich halten, +legen irgendwann beide auf dasselbe Deck. + +**Jeder Eintrag trägt eine Notiz**, und das ist der Unterschied zu einer +Playlist. Wer vormerkt, weiß warum; ohne die Notiz muss der Nächste den Grund +aus BPM und Tonart erraten, und bei einem Team von Agenten heißt erraten: +erfinden. + +Drei Entscheidungen, die aus dem Mehrbedienerfall kommen: + +- **Derselbe Pfad wird nicht zweimal angenommen** — die Antwort nennt die + Nummer, unter der er schon steht. Zwei, die unabhängig nach 128 BPM in 8A + suchen, finden denselben Track. +- **Nummern bleiben stehen**, auch wenn davor etwas herausgenommen wird. Sonst + spräche ein Agent, der sich Nummer 3 gemerkt hat, plötzlich über einen + anderen Track. +- **`queue_next` legt ohne Deckangabe nur auf ein Deck, das nicht läuft.** + Laufen alle, wird gefragt statt geraten; mit `queue_next deck1` geht es + trotzdem, wenn es wirklich so gemeint ist. Und scheitert das Laden, kommt der + Eintrag zurück nach vorn, statt still zu verschwinden. + +Auch die Liste steht in der Regie-Spalte, mit `vor`, `weg` und `auflegen`; in +der Plattenkiste merkt ein `+` einen Track vor. Sonst wäre sie von der +Oberfläche aus nur lesbar und füllen könnte sie allein ein Agent. + +## Was am Deck eingestellt wird, bleibt + +Hot Cues liegen zur Laufzeit in den Atomics des Decks — dort sind sie richtig +aufgehoben, denn der Audio-Thread liest sie pro Block. Nur endete es dort auch: +Acht Cues gesetzt, Track neu geladen, weg. Und die Sammlung hatte die Tabelle +die ganze Zeit; der Traktor-Import füllte sie sogar, aber nichts las sie je. + +```text +set deck1.cue1 4.5 → ok deck1.cue1 4.500000 (steht sofort in der Sammlung) +set deck1.cue1 - → ok deck1.cue1 - (Löschen wird genauso gespeichert) +``` + +**Sofort, nicht beim Beenden.** Ein Cue, der nur im Speicher steht, ist nach +einem Absturz weg, und abgestürzt wird beim Auflegen. Es sind acht Zeilen in +einer SQLite-Datei — das darf den Aufrufer kurz aufhalten, anders als das +Dekodieren beim Laden. + +**Geschrieben werden nur die acht Tasten.** In derselben Tabelle liegen der +Grid-Anker aus dem Traktor-Import und Fade-Marker; ein Deck kennt die nicht und +darf sie deshalb nicht mit ersetzen (`Library::replace_hot_cues` statt +`replace_cues`). + +### Das Beatgrid von Hand + +Der Detektor liegt bei sperrigem Material daneben — bei einem Testtrack kam +180 BPM heraus, mit einer Konfidenz von 0,05. Ohne Korrektur wäre so ein Track +unbrauchbar: + +```text +do deck1.grid_scale 0.5 → grid deck1 128.02 → 64.01 BPM +set deck1.position 2.5 +do deck1.grid_here → grid deck1 Anker auf 2.500 s +set deck1.bpm_grid 90 (geht auch direkt) +set deck1.grid_anchor 4.2 +``` + +**`grid_scale` ist für den Oktavfehler da**, den mit Abstand häufigsten Fall; +der Anker bleibt dabei stehen, denn bei einer Halbierung lag jede zweite Eins +ohnehin richtig. **`grid_here` legt den Anker auf die Abspielposition** — der +klassische Handgriff, wenn man hört, wo die Eins liegt. + +Genauer: auf die *Ziel*-Position. Ein `set position` setzt nur einen Wunsch, +den der Audio-Thread im nächsten Block ausführt; wer springt und sofort „hier" +sagt, meint die Stelle, auf die er gesprungen ist, nicht die, an der die +Anzeige noch steht. + +Jede Korrektur landet sofort in der Sammlung, genau wie ein Cue. Ohne Grid +lässt sich kein Anker setzen und nichts skalieren — das wird gemeldet statt +geraten. Nur `bpm_grid` nimmt auch aus dem Nichts einen Wert an, sonst käme man +aus einem fehlenden Grid nie wieder heraus. + +Beim Laden geht es rückwärts: Die gespeicherten Cues kommen aufs Deck, und ein +**Beatgrid aus der Sammlung schlägt die frische Analyse**. Was dort steht, kann +aus Traktor stammen oder von Hand korrigiert sein, und beides weiß mehr als ein +Detektor. Scheitert das Speichern — keine Sammlung geöffnet, Track nicht darin +—, steht der Cue trotzdem am Deck und der Grund in `deckN.load_status`. + +## Wo im Track das Deck steht + +Tempo, Tonart und Restbeats sagen, *wann* etwas geht. Sie sagen nicht, *wo* im +Stück man ist — und das ist die Frage, an der ein Übergang hängt. Deshalb kennt +jedes Deck seine Gliederung: + +```text +get deck1.section → value deck1.section drop +get deck1.section_beats_left → value deck1.section_beats_left 28.97 +get deck1.beats_to_outro → value deck1.beats_to_outro 92.79 +get deck1.intro_beats → value deck1.intro_beats 32.00 +get deck1.entry → value deck1.entry 0.466417 +do deck1.jump_entry → jump_entry deck1 auf 0.466s +``` + +`section` ist eines von `intro`, `aufbau`, `drop`, `break`, `outro` oder `teil`. +Erkannt wird das offline beim Analysieren, auf dem Beatgrid — wie, steht in +`crates/analysis/src/struktur.rs`. + +**`beats_to_outro` ist die Zahl, für die es das gibt.** Sie zählt herunter und +wird negativ, sobald das Outro läuft, und damit wird die eigentliche Regel +sagbar: + +```text +when deck1.beats_to_outro < 0 in phrase ramp master.crossfader 1 32 +``` + +Vorher hieß dieselbe Absicht „wenn noch 40 Beats übrig sind" — eine Zahl, die +man je Track neu schätzt und meistens falsch. + +**`entry` ist der Einstiegspunkt: der erste Downbeat, nicht Sekunde 0.** Was +davor liegt, ist Vorlauf. Genau diesen Fehler hat die Mitschrift beim ersten +gemessenen Übergang aufgedeckt — Deck 2 setzte fünfzehn Beats neben seiner +eigenen Eins ein, weil es bei Frame 0 anfing. `do deckN.jump_entry` räumt das +in einem Griff weg. + +**Ein frisch geladenes Deck steht dort inzwischen von selbst.** Wer von Hand +auflegt, cued auf den ersten Schlag; `do deckN.load` und `do master.queue_next` +tun jetzt dasselbe. Vorher stand das Deck auf Sekunde 0, und der nächste Griff +startete es dort — mitten in der Stille vor dem ersten Schlag. + +Die Reihenfolge ist dabei nicht beliebig: Der Sprung muss **nach** dem +Stimmentausch kommen. Vorher gesetzt verbraucht ihn die alte Stimme auf ihrem +eigenen Track, und die neue fängt wieder bei null an. Gemessen am laufenden +Programm: `beat -1.02` gegen `beat -0.03`. + +Steht der Abspielkopf im Vorlauf, ist `section` **leer**. Das ist keine Lücke, +sondern die Auskunft: Dort läuft noch kein Abschnitt. Dasselbe gilt für einen +Track ohne Outro — nicht jede Produktion blendet aus, und `beats_to_outro` +antwortet dann leer statt mit einer erfundenen Null. + +**`intro_beats` ist für die Auswahl da.** „Ein Track mit langem Intro" ist damit +eine Anforderung, die ein Agent stellen kann, statt sie zu erraten. + +## Wenn einer dem anderen dazwischenkommt + +Eine Rampe gibt auf, sobald jemand anders denselben Regler anfasst. Nur erfuhr +das lange niemand: Der Taktgeber meldete es, und der Thread, der ihn aufruft, +warf die Meldung weg. Für einen einzelnen Bediener fiel das nie auf — er *war* +derjenige, der den Regler angefasst hat. + +Für ein Team ist es der schlimmste denkbare Zustand: Zwei greifen nach demselben +Fader, einer verliert und plant weiter auf einer Annahme, die seit zwanzig +Sekunden falsch ist. + +```text +sub master.events +ok sub 1 neu, 1 gesamt +… +event plan 3 abgeloest channel1.fader — jemand anders hat den Regler +event plan 4 fertig master.crossfader 1.0000 +event plan 2 gestrichen +``` + +Gemeldet wird, was der Plan von sich aus tut: `fertig`, `abgeloest`, +`abgebrochen`, `ausgefuehrt` — und `gestrichen`, wenn jemand den Auftrag eines +anderen zurücknimmt. Die Zeilen kommen unaufgefordert, mit `event` davor; ein +Bediener, der sie nicht abonniert hat, bekommt nichts. + +**Warum kein einzelner Wert.** Ein Control „letztes Ereignis" wäre einfacher +gewesen und falsch: Der Taktgeber läuft alle 5 ms, verglichen wird alle 50. +Zwischen zwei Blicken passen zehn Ereignisse, und neun davon wären weg — +ausgerechnet dann, wenn viel gleichzeitig geschieht. Stattdessen ein Ring mit +laufender Nummer; wer seine kennt, bekommt alles seither. + +**Und wer zu langsam liest, erfährt das:** + +```text +warnung 3 Ereignisse verloren — zu langsam gelesen +``` + +Dieselbe Regel wie bei `record_dropped` und den unlesbaren Zeilen der +Mitschrift. Eine Lücke, die aussieht wie keine, ist das schlechteste von allem. + +**Wer nicht abonnieren kann, fragt.** Über MCP wird je Aufruf neu verbunden, ein +Abo hält dort nicht. Deshalb gibt es beides als Wert: + +```text +get master.events → value master.events plan 3 abgeloest channel1.fader +get master.event_count → value master.event_count 17 +``` + +Springt der Zähler zwischen zwei Blicken um mehr als eins, sind Zeilen +dazwischen liegengeblieben — und auch das ist besser sichtbar als still. + +## Die Form einer Bewegung + +Eine Rampe lief bisher immer gerade. Dieselbe Strecke lässt sich aber +verschieden verteilen, und das hört man: + +```text +ramp master.crossfader 1 32 weich # langsam los, durchziehen, weich ankommen +ramp channel1.eq_low 0 8 # ohne Angabe: linear +ramp channel2.fader 0.9 16 spaet deck1 +``` + +| Form | Verlauf | Wofür | +| --- | --- | --- | +| `linear` | gleichmäßig | Bass-Swaps und alles, was nicht als Geste gemeint ist | +| `weich` | S-Kurve | lange Blenden — der Anfang unauffällig, die Mitte entschieden | +| `spaet` | lange fast nichts, dann schnell | hält den ausgehenden Track so lange wie möglich präsent | +| `frueh` | sofort viel, dann auslaufen | macht den Wechsel zum Ereignis | + +**Deck und Form stehen hinten, in beliebiger Reihenfolge.** Erkannt werden sie +daran, *was* sie sind — die Namen sind disjunkt. Eine feste Reihenfolge wäre nur +eine Falle für den, der die Form angeben will, aber kein Deck, und das ist der +häufigere Fall. + +Jede Form fängt bei 0 an und kommt bei 1 an, und keine läuft zwischendurch +zurück; ein Fader, der umkehrt, wäre kaputt und nicht ausdrucksstark. Bei +`weich` ist die Steigung an beiden Enden null — deshalb kein Ruck beim +Losfahren und keiner beim Ankommen. + +Die Form steht auch im Plan: + +```text +plan 3 ramp master.crossfader -1.0000 → 1.0000 über 32 Beats weich, 12.4 gelaufen (deck1) +``` + +Ohne das sähen zwei verschieden gemeinte Bewegungen für einen zweiten Bediener +gleich aus. + +**Daneben gibt es `master.crossfader_curve`** — die Kennlinie des Crossfaders +selbst, von weich bis Schnitt. Sie stand im ersten automatisch gefahrenen Set +ungenutzt auf weich, und der Kritiker hat das Pegelloch in der Mitte prompt +gefunden. Form und Kurve sind zwei verschiedene Dinge: Die Form verteilt die +Bewegung über die Zeit, die Kurve übersetzt die Reglerstellung in Lautstärke. + +## Ein Repertoire statt eines Handgriffs + +Fünf benannte Übergänge: + +```text +do master.uebergang bassswap 16 +uebergang bassswap über 16 Beats, 6 Zeilen + set channel2.eq_low 0 → ok channel2.eq_low 0 + in phrase set deck2.play 1 → ok plan 1 in 13.5 Beats: set deck2.play 1 + in phrase ramp master.crossfader 0 16 weich → ok plan 2 … + in phrase+16 ramp channel1.eq_low 0 8 → ok plan 3 … + in phrase+16 ramp channel2.eq_low 1 8 → ok plan 4 … + in phrase+24 ramp master.crossfader 1 16 weich → ok plan 5 … +``` + +| Griff | Was er tut | Üblich | +| --- | --- | --- | +| `blende` | lange Blende über den Crossfader, weich verteilt | 32 Beats | +| `bassswap` | beide laufen in der Mitte, dann tauschen die Bässe | 16 | +| `schnitt` | auf der Eins umschalten, ohne Übergang | 0 | +| `filter` | dem Ausgehenden den Boden wegziehen, während der Neue kommt | 16 | +| `schleife` | den Ausgehenden in eine Schleife legen und darüber wechseln | 16 | + +**Die Schleife ist der einzige Griff, der dem Ausgehenden Zeit gibt.** Ein +Track, der in vier Beats zu Ende wäre, hält so noch eine ganze Phrase durch — +genau dafür setzt ein Mensch am Ende eines Stücks eine Schleife: nicht, um +etwas zu wiederholen, sondern um nicht gehetzt wechseln zu müssen. + +```text +in phrase set deck1.loop_beats 16 +in phrase set deck2.play 1 +in phrase ramp master.crossfader 1 16 weich +in phrase+16 set deck1.loop_active 0 +``` + +Die Schleife ist genau so lang wie die Blende darüber: Der Ausgehende läuft sie +einmal durch, und wenn sie herum ist, ist der Wechsel fertig. Eine Schleife, die +länger steht als der Übergang, wiederholt hörbar — und das ist der Fehler, den +dieser Griff gerade vermeiden soll. Deshalb wird sie am Ende auch wieder +**gelöst**: Ein Deck, das im Hintergrund weiterschleift, ist beim nächsten Griff +eine Überraschung. + +**Jeder Griff endet damit, dass das ausgehende Deck steht.** Das klang zuerst +nach einer Kleinigkeit und ist die Stelle, an der aus einem Übergang ein *Set* +wird: Bis dahin lief der ausgehende Track nach der Blende weiter — unhörbar +hinter dem geschlossenen Crossfader, aber laufend —, und der nächste +`uebergang` wurde deshalb abgewiesen: „es laufen mehrere Decks". Beim ersten +Griff fällt das nicht auf, beim zweiten sofort. + +**Getaktet wird nach dem ausgehenden Deck.** Ohne Angabe nimmt `in` das erste +Deck mit Beatgrid — beim zweiten Griff also ausgerechnet das gerade gestoppte. +Ein stehendes Deck hält den Plan an, und der ganze Übergang stand still: +angenommen, eingetragen, nie gefahren. Das ausgehende Deck läuft dagegen per +Definition, solange der Griff dauert. + +**Und keine Bewegung endet im selben Takt, in dem ihr Deck stehenbleibt.** Sonst +bekommt sie ihren letzten Schritt nicht mehr: Der Regler steht am Ziel, der +Auftrag aber für immer im Plan — sichtbar für jeden zweiten Bediener und nicht +mehr wegzubekommen, weil das Deck nicht mehr läuft. Deshalb ein Beat Nachlauf, +den niemand hört. + +Drei Griffe hintereinander am laufenden Programm, ohne Handgriff dazwischen +außer Laden und `jump_entry`: + +```text +get master.transitions → value master.transitions blende, bassswap, schleife +plan → ok 0 vorgemerkt +``` + +**Es geschieht nichts, was man nicht auch selbst tippen könnte.** Jeder Griff +ist eine Handvoll gewöhnlicher Zeilen, die durch denselben Weg laufen wie alles +andere — sie stehen im Plan, in der Mitschrift und bei den Ereignissen, ein +zweiter Bediener sieht sie kommen, und `cancel` nimmt sie zurück. Die Antwort +nennt jede davon, damit man sie lesen, ändern und beim nächsten Mal von Hand +anders setzen kann. + +**Die Anlage wählt nicht aus.** Welcher Griff passt, hängt daran, ob der +ausgehende Track ein Outro hat, der eingehende ein langes Intro, wie groß der +Energieunterschied ist — und vor allem daran, was vorher schon dreimal gefahren +wurde. Seit der Gliederung stehen die Zahlen dafür im Steuerraum (`section`, +`intro_beats`, `beats_to_outro`); die Entscheidung gehört dem, der sie begründen +kann. Nähme die Anlage sie ab, verlöre das Set genau den Teil, um den es hier +geht. + +Vorausgesetzt wird, dass **genau ein** Deck läuft und auf dem anderen etwas +liegt. Läuft nichts, gibt es keinen Übergang; laufen beide, ist er im Gange — +und wer dann gemeint ist, kann niemand wissen. In beiden Fällen sagt die Anlage +das, statt ein Deck zu wählen. + +## Ein Set, das sich selbst weiterträgt + +Die Teile ergeben zusammen mehr als ihre Summe. Ein Set ist Liste, Laden, +Griff — und keins davon braucht mehr eine Hand dazwischen: + +```text +do master.queue_add /musik/mitte.wav +do master.queue_note 1 mehr Druck nach dem Aufbau +when deck1.beats_left < 200 do master.queue_next +when deck1.beats_to_outro < 0 do master.uebergang bassswap 16 +``` + +Die erste Bedingung legt den nächsten Track auf, sobald der laufende sich dem +Ende nähert — auf dem freien Deck, auf dem ersten Downbeat. Die zweite fährt den +Übergang, sobald das Outro anfängt. Danach steht das ausgehende Deck, der Plan +ist leer, und dieselben zwei Zeilen lassen sich für den nächsten Track wieder +setzen. + +**Gewählt hat trotzdem ein Mensch oder ein Agent**, und zwar zweimal: welcher +Track in die Liste kommt — mit Notiz, warum — und welcher Griff gefahren wird. +Die Anlage führt aus und begründet, was sie gerade tut; sie entscheidet nicht, +was als Nächstes gut wäre. `do master.search_next` sortiert dafür die Sammlung, +`master.why` sagt den Satz dazu, `master.repeats` warnt vor Eintönigkeit. + +**Was dabei schiefgehen kann, sagt die Anlage.** Beim ersten selbstausgelösten +Übergang lief der ausgehende Track mitten im Bass-Swap aus; seitdem bricht der +Plan ab und meldet `deck1 ist durchgelaufen`, statt mit dem Crossfader in der +Mitte stehenzubleiben. Gegen den Fall selbst gibt es den Griff `schleife`. + +## Der Bogen: was das Set vorhat + +Ein einzelner guter Übergang ist Handwerk. Ein gutes Set ist Architektur — +Aufbau, Plateau, Bruch, Wiederaufbau, über eine Stunde und nicht über vier +Minuten. Bisher konnte die Anlage jeden Übergang begründen und keine +Reihenfolge. + +```text +set master.arc 0 0.3, 20 0.7, 45 0.95, 60 0.5, 80 1.0 +do master.arc_start +get master.arc_gap → value master.arc_gap 0.400000 +get master.arc_trend → value master.arc_trend steigt +``` + +Zeiten in **Minuten**, Energie zwischen 0 und 1. Zwischen den Punkten wird +geradlinig verbunden — nicht weil das musikalisch stimmt, sondern weil alles +andere eine Genauigkeit vortäuschte, die eine von Hand gesetzte Kurve nicht hat. + +| Control | Was | +| --- | --- | +| `arc` | die Kurve selbst, schreibbar | +| `arc_minutes` | wie lange das Set läuft | +| `arc_target` | was der Bogen hier vorsieht | +| `arc_actual` | was gerade läuft | +| `arc_gap` | **Soll minus Ist** — die Zahl, nach der gewählt wird | +| `arc_trend` | `steigt`, `haelt` oder `faellt` | + +Damit wird die eigentliche Frage sagbar: + +```text +when master.arc_gap > 0.3 do master.queue_next +``` + +**Die Ist-Energie kommt aus der Art des Abschnitts, nicht aus dem Pegel.** Die +Gliederung misst je Abschnitt einen Pegel, aber der ist auf den lautesten +Abschnitt *desselben* Tracks bezogen: Der Drop eines leisen Stücks steht dort +genauso bei 0,99 wie der eines lauten. Über Tracks hinweg ist das nicht +vergleichbar, und ein Bogen, der solche Zahlen addiert, rechnet mit Äpfeln. +Stattdessen eine grobe Leiter — Intro 0,2, Break 0,35, Aufbau 0,55, Drop 0,9 —, +und grob ist hier ehrlicher als eine Nachkommastelle, die niemand einlösen kann. + +**Ohne `arc_start` gibt es keinen Ort auf dem Bogen**, und dann wird auch keiner +behauptet: `arc_minutes`, `arc_target` und `arc_gap` antworten leer. Eine Kurve +ohne Uhr ist ein Bild, kein Maßstab. + +**Für ein Team ist der Bogen das, worüber man sich einig sein muss.** Ohne ihn +verhandeln zwei Agenten über den nächsten Track ohne gemeinsamen Maßstab: Der +eine will Druck, der andere Luft, und beide haben recht, weil es keinen Satz +gibt, gegen den sich das prüfen ließe. Mit ihm heißt die Frage nicht mehr +„welcher Track ist gut", sondern „was fehlt hier gerade". + +## Einzelspuren: die Stimme wegnehmen + +Zwei Stimmen gleichzeitig sind der hörbarste Mixfehler überhaupt, und ohne +Trennung lässt er sich nur vermeiden, indem man gar nicht überlagert. + +```text +get deck1.stems → value deck1.stems 3 +get deck1.stem3_name → value deck1.stem3_name vocals +set deck1.stem3_level 0 +``` + +| Control | Was | +| --- | --- | +| `stems` | wie viele Einzelspuren der geladene Track hat | +| `stemN_name` | wie die Spur heißt — aus dem Dateinamen | +| `stemN_level` | ihr Pegel, 0 bis 1; schreibbar | + +**Wo die Spuren liegen:** neben der Datei in einem Ordner gleichen Namens mit +der Endung `.stems` — zu `nachtschicht.wav` also `nachtschicht.stems/` mit +`vocals.wav`, `drums.wav`, `bass.wav`, `other.wav`. Kein neues Dateiformat, +keine neue Abhängigkeit, und genau die Form, in der die gängigen Trennwerkzeuge +ihr Ergebnis ablegen. Ein eigenes Stem-Format zu lesen wäre die Alternative +gewesen: eine MP4-Datei mit fünf AAC-Spuren, die einem Hersteller gehört. Ein +Ordner mit vier WAV-Dateien gehört niemandem. + +**Getrennt wird hier nicht.** Eine Stimme aus einer Mischung zu lösen ist Arbeit +für ein neuronales Netz, ein eigenes Werkzeug und eine eigene Lizenzfrage. +Gelesen wird, was jemand anders getrennt hat. Ohne Ordner verhält sich alles wie +vorher — kein Sonderweg, den jemand einschalten müsste. + +**Die Zeitstreckung entscheidet einmal für alle Spuren.** Sie sucht sich in +jedem Hop die Stelle, an der die Wellenform am besten anschließt; liefe diese +Suche je Spur getrennt, fände jede eine andere, und was vorher zusammen klang, +klänge verwaschen. Gesucht wird deshalb auf der Summe, angewandt auf jede Spur. +Ein Wächter misst das: Mit vollen Pegeln muss dasselbe herauskommen wie beim +Strecken der Summe, Ton für Ton. + +Gemessen am laufenden Programm, zwei Mitschnitte desselben Stücks: + +| | Energie bei 1500 Hz | Gesamtpegel | +| --- | --- | --- | +| mit Stimme | 9,8 · 10⁻⁵ | 0,061 | +| ohne Stimme | 4,9 · 10⁻⁹ | 0,039 | + +Die Stimme ist um den Faktor 20 000 weg, Drums und Bass stehen. + +**Was noch fehlt: Streaming von Platte.** Vier Spuren kosten das Fünffache eines +Tracks, weil die Summe daneben stehen bleibt — rund 500 MB je Deck bei fünf +Minuten. Für zwei Decks geht das; für die vier, die der Plan vorsieht, braucht +es Streaming. Das steht bewusst noch aus: Der Abspielpfad ist der einzige Teil +mit Echtzeitauflagen, und Platten-I/O gehört dort zuletzt hinein. + +## Der Raum: was draußen geschieht, verschiebt das Ziel + +Die vier Signalplätze gab es lange, aber sie waren Deko — Werte gingen hinein, +und nichts hat darauf reagiert. Ein Bogen, der von der ersten Minute an +feststeht, ist ein Abspielplan. + +```text +set master.signal1_name Andrang +set master.room signal1 0.5 +set master.signal1 0.6 # und später wieder, und wieder + +get master.arc_curve → value master.arc_curve 0.700000 +get master.room_bend → value master.room_bend -0.200000 +get master.arc_target → value master.arc_target 0.500000 +``` + +| Control | Was | +| --- | --- | +| `room` | welches Signal den Bogen beugt und wie stark, schreibbar; `-` schaltet ab | +| `room_bend` | wie weit der Raum das Ziel gerade verschiebt | +| `arc_curve` | was die geschriebene Kurve hier vorsieht — ungebeugt | +| `arc_target` | was gerade angestrebt wird: Kurve **plus** Raum | +| `why` | in einem Satz, woraus das gerechnet wurde | + +**Der Raum verschiebt das Ziel, nicht die Kurve.** Das ist der ganze +Unterschied zwischen „der Raum redet mit" und „der Raum schreibt um": Am Ende +des Abends steht in `arc` immer noch der Plan, gegen den sich vergleichen +lässt, was tatsächlich geschehen ist. Gebeugt wird `arc_target` und damit +`arc_gap` — die Zahl, nach der gewählt wird. + +**Gebeugt wird nach dem Trend, nicht nach dem Wert.** Die Höhe eines Signals +ist nichts Vergleichbares: Was „0,6 Andrang" bedeutet, weiß nur, wer den Sender +geschrieben hat, und eine Anlage, die diese Zahl direkt gegen die Energie des +Bogens rechnet, addiert Äpfel — derselbe Fehler, der beim Ist-Wert schon einmal +auffiel. Eine *Änderung* ist dagegen eine Aussage über denselben Sender: „seit +drei Minuten fallend" heißt dasselbe, egal wie die Skala gemeint war. + +**Höchstens 0,25.** Ohne Deckel könnte ein hängender oder falsch skalierter +Sender das Set übernehmen, und das wäre schlimmer als ein Set, das den Raum +ignoriert. Wer mehr Wirkung will, sagt das über das Gewicht; über die Grenze +kommt er trotzdem nicht. + +### Und was daraus folgt + +```text +do master.search_next +weil Andrang fällt (-0.40/min), Ziel 0.70 → 0.50; es läuft 0.90, weniger gesucht (-0.40) +track 126.00 8A /musik/blaue-stunde.wav Blaue Stunde — Energie 0.48 (-0.02 zum Ziel 0.50), harmonisch +track 128.00 9A /musik/kellerlicht.wav Kellerlicht — Energie 0.61 (+0.11 zum Ziel 0.50), harmonisch +track 127.00 3A /musik/nachtschicht.wav Nachtschicht — Energie 0.88 (+0.38 zum Ziel 0.50), tonartfremd +``` + +Bis hierher konnte der Raum ein Ziel verschieben, aber gewählt wurde weiter nach +Tempo und Tonart allein — zwei Größen, die nichts darüber sagen, ob es gerade +lauter oder ruhiger werden soll. `search_next` sortiert nach dem Abstand zum +gebeugten Ziel und sagt je Zeile, warum sie dort steht. + +Die Energie eines Tracks kommt aus seiner **Gliederung**, nach Länge gewichtet: +Ein Stück mit kurzem Drop und langem Outro ist ruhiger als eines, bei dem es +umgekehrt ist. Sie steht nicht in der Datenbank, sondern in der Analyse-Datei +neben dem Track — sie hängt am Inhalt, nicht am Eintrag. **Ein nicht +analysierter Track steht hinten und sagt das**, statt sich mit einer erfundenen +mittleren Energie in die Mitte zu mogeln. + +**Diese Zahl misst keine Lautstärke und keine Härte.** Die Abschnittsarten +werden je Track gegen dessen *eigene* Quantile benannt — der Drop eines leisen +Stücks heißt genauso „Drop" wie der eines lauten. Was herauskommt, ist: wie viel +seiner Länge ein Track auf seinem eigenen Höhepunkt verbringt. Für den Aufbau +eines Sets ist das die brauchbarere Größe — ein Peak-Track sitzt die meiste Zeit +oben, ein Warmup-Track baut die meiste Zeit auf. Auf die Frage „welches Stück +ist härter?" antwortet sie nicht, und das ist besser, als sie falsch zu +beantworten. + +**Ausgewählt wird trotzdem nicht.** Die Anlage sortiert; welcher Track es wird, +entscheidet, wer es begründen kann. Ein `search_next`, das selbst lädt, wäre +etwas anderes — und genau das, was hier nicht gebaut wird. + +## Zurückhaltung: ob immer dasselbe gefahren wird + +Das Repertoire macht Abwechslung **möglich**. Es erzwingt sie nicht — und ein +System, das viermal hintereinander dieselbe Blende wählt, klingt weiterhin nach +Automat, auch wenn jede einzelne sauber ist. Der Vorwurf an den ersten +selbstgefahrenen Übergang („ein bisschen herzlos") war nie mit einer Zahl +beantwortet. Hier ist sie. + +```text +get master.repeats → value master.repeats 3 +get master.transitions → value master.transitions blende, blende, blende +``` + +| Control | Was | +| --- | --- | +| `repeats` | wie viele der letzten Übergänge hintereinander gleich waren | +| `transitions` | die letzten acht, ältester zuerst | + +`1` heißt: der letzte war anders als der davor, alles in Ordnung. Ab `3` +wiederholt sich jemand hörbar, und das lässt sich zur Bedingung machen: + +```text +when master.repeats > 2 do master.uebergang filter +``` + +**Gezählt wird der Seitenwechsel des Crossfaders, nicht sein Wert.** Das ist die +einzige Stelle, die sich ohne Raterei bestimmen lässt, und sie zählt genau +einmal je Übergang: Ein Bass-Swap fährt zweimal am Crossfader (erst in die +Mitte, dann hinüber), eine Blende in achtzig kleinen Schritten, ein Schnitt in +einem Sprung — angekommen sind alle drei einmal. Zählte man stattdessen den +Wert, stünde nach einer einzigen Blende eine Wiederholung von 80 da, und die +Zahl, die vor Eintönigkeit warnen soll, wäre selbst der Grund, sie zu +ignorieren. + +Wie ein Übergang heißt, entscheidet sich beim Ankommen: Wurde er über +`master.uebergang` angefordert, steht sein Name da (`bassswap`), sonst die +Bewegung, die den Fader hinübergebracht hat (`weich/32`) oder `schnitt`. Damit +zählt auch, was **von Hand** zusammengesetzt wurde — der erste automatisch +gefahrene Übergang bestand aus sieben `when`-Zeilen und keinem einzigen +`uebergang`. Ein Griff, der vorgemerkt und dann abgelöst wird, zählt nicht: Er +hat nicht stattgefunden. + +**Und es müssen zwei Decks laufen.** Wer vor dem Set den Fader auf die Seite des +einzigen laufenden Decks stellt, richtet ein — da ist nichts, wovon oder wohin +überzublenden wäre. Das stand zunächst falsch da und fiel erst am laufenden +Programm auf: Nach vier Zeilen Einrichten meldete die Anlage bereits einen +Übergang. + +**Zwei Grenzen sind echt:** Wer nur mit den Kanalfadern mischt und den +Crossfader stehen lässt, taucht hier nicht auf. Und wenn der ausgehende Track +ausläuft, bevor die Blende drüben ankommt, fehlt sie — dann lief zuletzt nur +noch ein Deck. Beides kommt vor; dann sagt diese Zahl nichts, statt etwas +Falsches zu sagen. + +## Harmonisch mischen + +Tempo ist die halbe Trackauswahl. Zwei Stücke im gleichen Takt können trotzdem +gegeneinander klingen, wenn die Tonarten nicht zusammenpassen — deshalb gibt es +sie am Deck und in der Sammlung: + +```text +get deck1.key → value deck1.key Am +get deck1.key_camelot → value deck1.key_camelot 8A +do master.search_harmonic 8A + → track 127.98 8A /musik/01.wav Nachtschicht + track 125.99 8B /musik/02.wav Alpenglühen + track 123.99 9A /musik/03.wav Blaue Stunde +``` + +**Zwei Felder statt einem.** `key` ist die Schreibweise für Menschen (`Am`, +`F#`), `key_camelot` die zum Rechnen (`8A`, `2B`). Beides in eine Zeichenkette +zu packen hieße, dass jeder Leser sie wieder auseinandernehmen muss. +`search_harmonic` nimmt beide entgegen, und was keine Tonart ist, wird +**gemeldet** statt als leeres Ergebnis zurückzukommen — ein Tippfehler darf +nicht aussehen wie eine leere Sammlung. + +Gesucht wird nach der Regel des Camelot-Rads: dieselbe Zahl (Paralleltonart), +eine weiter, eine zurück. Deshalb steht in jeder Trefferzeile jetzt eine +Camelot-Spalte hinter dem Tempo. + +**Leer heißt unbekannt, nicht „passt zu allem".** Auf Bass und Drums allein +ermittelt die Analyse bewusst keine Tonart — warum, steht in +`crates/analysis/src/tonart.rs`. Diese Tracks tauchen in einer harmonischen +Suche nicht auf; das ist richtig so, denn ein geratener Wert würde beim Mischen +mehr kosten als ein fehlender. + +## Auch die Oberfläche geht hier durch + +Nicht nur die Regler: Suchen und Laden nehmen inzwischen denselben Weg. Ein +Klick auf „A" in der Plattenkiste löst `deck1.load` aus, das Suchfeld ruft +`master.search`, und „Harmonisch zu A" ruft `master.search_harmonic` mit der +Tonart von Deck 1. Auch die Cue-Knöpfe und die Grid-Korrektur gehen diesen Weg +— sie schrieben vorher direkt ins Deck, und genau dort wären sie an der +Sammlung vorbeigelaufen. + +Das ist kein Selbstzweck. Vorher hatte die Oberfläche einen eigenen Ladepfad — +zwei Wege zum selben Ziel heißt zwei Stellen, an denen es schiefgehen kann, und +die seltener benutzte fällt seltener auf. Jetzt gilt: Was ein Agent kann, kann +die Oberfläche auch, und umgekehrt. Bricht der eine Weg, bricht der andere +sofort mit — und wird bemerkt. + +## Was noch fehlt + +- **Reverb.** Vier Effekte gibt es; ein guter Hall fehlt und ist ein eigenes + Stück Arbeit. +- **Beatgrid korrigieren.** `set deck1.bpm_grid` ist nur lesbar; ein falsch + erkanntes Grid lässt sich von außen nicht geraderücken. +- **Windows: Named Pipe.** Die Steuerung läuft dort inzwischen über die + Rückschleife (`--tcp`, mit Schlüssel), also gibt es sie überhaupt. Eine Named + Pipe wäre trotzdem der bessere Weg: Sie erbt die Rechte des Systems, so wie + der Unix-Socket, und käme ohne Schlüssel aus. +- **MIDI.** Der normierte Weg (`setn`) ist da, der Übersetzer von MIDI-CC auf + Control-Namen noch nicht. Das ist Phase 10 — und mit dem Steuerraum im Rücken + fast nur noch eine Tabelle. +- **MCP.** Die Werkzeugbeschreibungen lassen sich aus dem Katalog erzeugen; der + Erzeuger fehlt noch. diff --git a/docs/TRAKTOR-REFERENZ.md b/docs/TRAKTOR-REFERENZ.md new file mode 100644 index 0000000..237ae4b --- /dev/null +++ b/docs/TRAKTOR-REFERENZ.md @@ -0,0 +1,114 @@ +# Traktor als Referenz + +Traktor Pro 4 (Native Instruments) ist das Zielbild für die DJ-Seite dieses +Projekts. Dieses Dokument hält fest, was Traktor kann — damit klar ist, was wir +nachbauen, was wir bewusst weglassen und was wir anders machen wollen. + +Quelle: Herstellerangaben und Reviews zu Traktor Pro 4, siehe Abschnitt +[Quellen](#quellen). Nicht selbst nachgemessen. + +## Deck-Modelle + +Traktor hat bis zu vier Decks, und ein Deck ist nicht nur ein Abspieler — es hat +drei Betriebsarten: + +| Modus | Was es tut | +| --- | --- | +| **Track** | Klassische lineare Wiedergabe einer Datei | +| **Remix** | Grid aus Samples/Loops, die synchron getriggert werden | +| **Stem** | Vier getrennte Spuren innerhalb eines Tracks, live zu- und abschaltbar | + +Das ist die wichtigste Erkenntnis für unsere Architektur: **ein Deck ist ein +Container mit austauschbarem Innenleben**, kein Dateiabspieler. Genau da hängt +sich später auch die Generierung ein — ein viertes Deck-Modell. + +## Kernfunktionen + +**Echtzeit-Stem-Separation.** Traktor zerlegt einen beliebigen Track live in +Drums, Bass, Vocals und „Other", angetrieben von iZotope-RX-Algorithmen. Damit +lassen sich Acapellas spontan droppen oder Breakdowns bauen. Siehe +[BAUSTEINE.md](BAUSTEINE.md#stem-separation) — das in Echtzeit hinzubekommen ist +der technisch teuerste Punkt der ganzen Liste. + +**Pattern Player.** Lädt Drum-Kits und erzeugt polyrhythmische Percussion- +Sequenzen direkt aus dem Deck heraus. Mitgeliefert werden Kits von Künstlern wie +Rebekah, Luke Slater, Len Faki, Chris Liebing, Dubfire. + +**Effekte.** Über 40 Effekte, dazu Mixer FX, die *post-fader* liegen — wichtig +für weiche Übergänge, weil der Effekt nicht mit dem Fader weggezogen wird. + +**Beatgrids und Sync.** Automatische BPM-Erkennung mit Beatgrid pro Track, +darauf aufbauend Sync zwischen Decks. + +**Harmonisches Mixen.** Tonarterkennung, damit sich Tracks nicht beißen. + +**Master-Kette.** iZotope Ozone Maximizer auf der Summe. + +**Library.** Smartlists (regelbasierte Playlists), Sampler, Suche über die +Sammlung. + +## Datenformat: die `collection.nml` + +Traktors Sammlung liegt als XML-Datei (`.nml`). Sie enthält **keine Audiodaten**, +sondern: + +- Pfade zu den Mediendateien auf der Platte +- Metadaten (Titel, Artist, Genre, …) +- BPM und Beatgrid +- Tonart +- 1–8 Hot Cues und Loop-Marker +- Playlists + +Das ist für uns direkt relevant: **Traktor-Import ist ein realistisches Feature.** +Wer von Traktor kommt, bringt Jahre an Cue-Points mit. Es gibt fertige Parser, +z. B. [`traktor-nml-utils`](https://github.com/wolkenarchitekt/traktor-nml-utils) +(Python). + +## Controller-Anbindung + +Mappings liegen als `.tsi`-Dateien und werden im Controller Manager verwaltet. +Praktisch jeder Generic-MIDI-Controller lässt sich anlernen. Für uns vorerst ein +Nicht-Ziel, aber die Architektur sollte MIDI-Events nicht ausschließen. + +## Was wir übernehmen — und was nicht + +| Traktor-Feature | Priorität bei uns | Anmerkung | +| --- | --- | --- | +| 2 Decks, Track-Modus | **Phase 1** | Fundament | +| Crossfader, EQ, Gain, Filter | **Phase 1** | Fundament | +| BPM/Beatgrid, Sync | **Phase 2** | Ohne das kein brauchbarer Mix | +| Waveform, Cues, Loops | **Phase 3** | | +| Library + Traktor-Import | **Phase 4** | NML-Parser existiert bereits | +| Tonarterkennung | Phase 4 | Nice-to-have, kein Blocker | +| Stem-Modus | Später | Vorberechnet, nicht Echtzeit (s. u.) | +| 40+ Effekte | Später | Wenige gute schlagen viele mittelmäßige | +| Pattern Player | Später | Interessant als Agenten-Spielwiese | +| 4 Decks | Später | 2 reichen lange | +| Remix-Decks | Später | | +| MIDI/Controller | Nicht-Ziel (vorerst) | | +| DVS/Timecode-Vinyl | **Nicht-Ziel** | Riesiger Aufwand, enge Zielgruppe | + +**Zur Stem-Separation:** Traktor macht das in Echtzeit. Wir sollten das *nicht* +direkt versuchen. Stems beim Import einmal vorberechnen und ablegen bringt +denselben Nutzen im Deck, ohne das Latenzproblem. Echtzeit ist eine +Optimierung, kein Startpunkt. + +## Wo wir bewusst abweichen + +Traktor ist ein geschlossenes Werkzeug für einen Menschen an zwei Decks. Unser +Unterschied liegt woanders: + +1. **Generierte Tracks sind gleichberechtigte Quelle** — siehe [APIS.md](APIS.md) +2. **Die Crowd ist ein Eingabegerät** — siehe [AGENTEN.md](AGENTEN.md) + +Alles, was Traktor besser kann und wir nie einholen, ist damit kein Problem — +solange diese zwei Achsen funktionieren. + +## Quellen + +- [Traktor Pro 4 – Native Instruments (Review, MusicTech)](https://musictech.com/reviews/dj/native-instruments-traktor-4-pro/) +- [Native Instruments releases Traktor Pro 4 – Decoded Magazine](https://www.decodedmagazine.com/native-instruments-releases-traktor-pro-4-with-incredible-new-features/) +- [Traktor Pro 4 Produktseite – DJ TechTools](https://store.djtechtools.com/products/traktor-pro-4) +- [Configuring MIDI Controller for Controlling Traktor – Native Instruments](https://www.native-instruments.com/ni-tech-manuals/traktor-pro-manual/en/configuring-midi-controller-for-controlling-traktor) +- [traktor-nml-utils – NML-Parser](https://github.com/wolkenarchitekt/traktor-nml-utils) +- [Import Traktor – Lexicon DJ (NML-Feldbeschreibung)](https://www.lexicondj.com/manual/import-traktor) diff --git a/docs/VIBEMIND.md b/docs/VIBEMIND.md new file mode 100644 index 0000000..a83ff92 --- /dev/null +++ b/docs/VIBEMIND.md @@ -0,0 +1,278 @@ +# Anschluss an VibeMind + +Kann dieses Projekt später Teil von [VibeMind](https://github.com/Flissel/Vibemind_V1) +werden? Ja. Die Anschlussstelle steht dort schon, und dieses Dokument hält fest, +was das für Entscheidungen hier bedeutet. + +Stand der Betrachtung: VibeMind V1, August 2026. + +## Wie VibeMind Dinge anschließt + +``` +Voice · UI · Channels · API + │ + Brain Intent, Planung, Routing + │ autorisierte Übergabe + OpenFang Agentenauflösung, Freigabe, Ausführung + │ + Agenten + │ + MCP-Tools (allowlisted) + │ + Spaces +``` + +Zwei Muster sind für uns relevant: + +**MCP-Server.** VibeMind spricht Werkzeuge über MCP an — `FastMCP` über stdio, +ein Python-Paket je Server, Lesewerkzeuge laufen frei, schreibende gehen durch +eine Freigabe. Vorbild ist `mcp/docker/`. + +**Spaces.** Fachdomänen liegen unter `spaces//` mit `agents/`, `api/`, +`README.md` und `STATUS.md`. Vorbild ist `spaces/marketing/`. + +Größere Komponenten hängen als Submodule in `repo-manifest.yaml`. + +## Der naheliegende Weg + +Der Rust-Kern bleibt, was er ist — die Audio-Engine. Darüber kommt eine dünne +Steuerfläche als MCP-Server: + +| Werkzeug | Art | Zweck | +| --- | --- | --- | +| `analyze_track` | lesend | Tempo, Beatgrid, Spitzen — läuft heute schon | +| `library_search` | lesend | Tracks nach BPM, Tonart, Tags | +| `deck_state` | lesend | Position, Tempo, was liegt auf welchem Deck | +| `deck_load` | schreibend | Track auf ein Deck legen | +| `deck_play` / `deck_tempo` | schreibend | Wiedergabe steuern | +| `mixer_set` | schreibend | Fader, EQ, Filter | + +**Die Sprachgrenze ist kein Problem.** VibeMind ist Python, dieses Projekt ist +Rust — MCP läuft über eine Prozessgrenze und ist genau dafür gedacht. Der Server +kann in Rust geschrieben sein oder als dünne Python-Schicht, die über einen +lokalen Socket mit der Engine redet. + +## Was das für die Agenten-Schicht bedeutet + +[AGENTEN.md](AGENTEN.md) entwirft ein Team aus Crowd-Sensor, Energie-Analyst, +Set-Planer, Track-Wähler, Generator und Mix-Engineer. **Diese Schicht sollte hier +nicht noch einmal gebaut werden.** Brain und OpenFang machen genau das — +Intent, Planung, Routing, Freigabe, Ausführung. + +Die Arbeitsteilung wäre also: + +- **Hier:** Audio-Engine, Analyse, Library, MCP-Werkzeuge +- **VibeMind:** die Agenten, die diese Werkzeuge benutzen, als Space `musik` + +Das streicht einen ganzen Bauabschnitt. Phase A2 aus der Roadmap wird damit +von „Agenten-Framework bauen" zu „Space anlegen und Werkzeuge freischalten". + +## Zwei Stolpersteine + +### 1. Die Lizenzlage kippt zurück + +⚠️ **VibeMind steht unter MIT und ist zur Veröffentlichung vorgesehen.** + +Damit gilt die Entlastung aus [BAUSTEINE.md](BAUSTEINE.md) — „nicht kommerziell, +also ist GPL frei" — für diesen Weg **nicht mehr**. Copyleft greift bei +Weitergabe, und ein veröffentlichtes MIT-Projekt ist Weitergabe. + +Konkret fielen wieder aus: + +| Bibliothek | Warum | +| --- | --- | +| Rubber Band (GPL) | Zwänge das Gesamtprojekt unter GPL | +| aubio (GPL) | dito | +| Essentia (AGPL) | dito, zusätzlich bei Netzwerknutzung | +| CC-BY-NC-Samples | dürften nicht mitgeliefert werden | + +**Der aktuelle Stand ist zum Glück sauber.** Alles, was heute im Repo hängt, ist +MIT-verträglich: + +| Crate | Lizenz | +| --- | --- | +| `cpal` | Apache-2.0 | +| `symphonia` | MPL-2.0 — dateiweises Copyleft, in einem MIT-Projekt unproblematisch | +| `rustfft`, `blake3`, `serde`, `base64` | MIT/Apache-2.0 | + +Zeitstreckung und Tempoerkennung sind selbst geschrieben. Das war ursprünglich +eine Notlösung, weil die Lizenzfrage noch offen war — und hält jetzt genau die +Tür offen, die es für VibeMind braucht. + +**Empfehlung: permissiv bleiben.** Solange ein Anschluss an VibeMind denkbar +ist, ist Rubber Band die teure Option, auch wenn sie für den reinen Eigenbedarf +erlaubt wäre. Reicht die eigene WSOLA nicht, ist +[signalsmith-stretch](https://github.com/Signalsmith-Audio/signalsmith-stretch) +(MIT) der Weg, der beide Optionen offenlässt. + +### 2. Freigabe-Pflicht und Livebetrieb vertragen sich nicht + +In VibeMind laufen lesende MCP-Werkzeuge frei, schreibende durch eine Freigabe. +Für einen DJ-Kontext ist das eine harte Grenze: „Lege Track X auf Deck 2" ist +formal ein schreibender Aufruf, aber mitten in einem Set kann niemand auf eine +Bestätigung warten. Sechzehn Takte sind bei 128 BPM dreißig Sekunden. + +Das ist kein Hindernis, sondern eine Entwurfsvorgabe: + +- **Vorbereitende Aufrufe** (analysieren, suchen, Kandidaten generieren) dürfen + durch die Freigabe. +- **Zeitkritische Aufrufe** (Fader, EQ, Loop, Cue) brauchen eine im Voraus + erteilte Freigabe für die Dauer des Sets — ein Bereich, kein Einzelfall. + +Das ist dieselbe Frage wie die offene Entscheidung „Vollautomat oder Assistent +am Pult", nur von der Sicherheitsseite betrachtet. + +## Plattform + +VibeMind zielt auf Windows 11. CPAL unterstützt dort WASAPI und, über ein +Feature-Flag, ASIO. Für den getrennten Cue-Ausgang aus +[PLAN.md](PLAN.md#1-der-cue-ausgang-zwingt-zu-einem-gerät-mit-vier-ausgängen) +führt unter Windows praktisch kein Weg an ASIO vorbei — das ist beim Kauf des +Interfaces mitzudenken. + +## Die Voraussetzungen, nachgemessen + +Die beiden Bedingungen von oben sind erfüllt, und zwar geprüft statt behauptet. + +**Lizenzen.** Über alle 474 Pakete der `Cargo.lock`, aufgelöst gegen die +entpackten Quellen: 203× `MIT OR Apache-2.0`, 95× `MIT`, 43× `Apache-2.0 OR +MIT`, 12× `MPL-2.0` (Symphonia; dateiweises Copyleft, in einem MIT-Projekt +unproblematisch). **Kein einziges Copyleft ohne permissiven Zweig.** Drei +Pakete tragen „GPL" im Lizenzfeld — `r-efi` und `self_cell` —, alle drei +mehrfachlizenziert (`MIT OR Apache-2.0 OR LGPL-2.1-or-later`, `Apache-2.0 OR +GPL-2.0-only`); man nimmt den permissiven Zweig. + +**Schnittstelle statt UI-Innenleben.** Der Steuerraum ist die einzige Art, die +Anlage zu bedienen — auch die Oberfläche geht durch ihn. Die MCP-Brücke ist +deshalb heute schon eine dünne Hülle. + +## Was zwischen hier und einem Space liegt + +Vier Dinge, im echten `Vibemind_V1` nachgesehen, nicht aus diesem Dokument +abgeleitet. Drei sind inzwischen erledigt; die Zeilen bleiben stehen, weil der +Ausgangspunkt zur Begründung gehört: + +| | Stand damals | Stand jetzt | +| --- | --- | --- | +| `spaces/musik/` | existiert nicht — es gibt nur `marketing` | angelegt, mit Mix-Engineer und Abnahmelauf | +| Eintrag in `repo-manifest.yaml` | keiner | wartet auf den Merge dieses Zweigs | +| Freigabe-Gatter für schreibende Werkzeuge | **fehlt ganz** — alle 16 laufen frei | sieben Klassen, 18 Tests, in der CI | +| FastMCP-Herkunft | hier `fastmcp`, dort `mcp.server.fastmcp` | eine Weiche über 1.x und 2.x, unter beiden geprüft | + +Das dritte war das einzige mit Substanz; die anderen drei waren Umzug. + +### Das Freigabe-Modell für ein Pult + +VibeMind lässt lesende Werkzeuge frei und schickt schreibende durch +`approval.require_approval` — bei `mcp/docker/` mit Default-Deny und einer +Erlaubnisliste in der Umgebung. Für ein DJ-Pult ist „jeder Schreibzugriff +einzeln" unbrauchbar: Sechzehn Takte sind bei 128 BPM dreißig Sekunden, und +niemand bestätigt mitten in einer Blende. + +Die Auflösung ist keine Ausnahme vom Modell, sondern eine andere Körnung — +**Freigabe als Bereich für die Dauer eines Sets**, nicht je Aufruf: + +| Stufe | Was | Warum | +| --- | --- | --- | +| **Frei** | `musik_status`, `musik_search`, `list`, `get`, `plan`, `master.events` | Auskunft ändert nichts | +| **Set-Freigabe** | Fader, EQ, Filter, Crossfader, Loop, Cue, `uebergang`, `ramp`, `schedule`, `when` | Zeitkritisch; einmal erteilt, gilt für das Set | +| **Einzeln** | `record`/`record_stop`, `queue_add `, `deckN.load `, `master.arc` | Berührt Dateisystem oder die Dramaturgie | +| **Nie ohne Mensch** | Generator-Aufrufe | Kosten Geld, und zwar je Aufruf | + +Die Set-Freigabe hat einen Anfang und ein Ende und gehört ins Protokoll: Wer +sie erteilt hat und wann sie ausläuft, steht in der Mitschrift wie jede andere +Zeile. Ein Set ohne Ende wäre eine Dauererlaubnis mit anderem Namen. + +## Was der Musik-App zum Ziel fehlt + +Ziel ist, dass Agenten die Musik machen. Gemessen daran fehlt Folgendes — die +Liste ist nach Abhängigkeit sortiert, nicht nach Aufwand. + +**1. Der Generator ist nicht angefangen.** In `crates/` steht dazu keine Zeile. +Der ganze rechte Ast des Entwurfs in [AGENTEN.md](AGENTEN.md) — `[Generator] → +Suno/ElevenLabs` — ist Papier. Das ist die größte Lücke zwischen dem, was das +Projekt tut, und dem, wofür es gebaut wird. + +**2. Suno gibt es nicht zu haben.** Kein öffentlicher Endpunkt, keine Doku, kein +Preismodell, kein Termin — siehe [APIS.md](APIS.md). Das Partner-Programm ist +seit Juli 2026 offen und die Bewerbung einen Versuch wert, aber darauf lässt +sich nicht planen. Reverse-engineerte Dritt-„APIs" scheiden aus; das realistische +Risiko ist ein gesperrter Account. + +**Konsequenz:** Der Generator kommt hinter eine Adapter-Schnittstelle, und der +erste Adapter ist **ElevenLabs Music** — dokumentierter `/music/compose`, die +klarste Lizenzlage im Feld. Suno wird später ein weiterer Adapter, kein Umbau. +Das steht so schon in [ARCHITEKTUR.md](ARCHITEKTUR.md); neu ist nur, dass es +jetzt gebaut werden müsste. + +**3. Die Signale haben keine Quelle.** Der Raum steuert Bogen und Auswahl, aber +gemeldet wird von Hand. Der Crowd-Sensor aus dem Entwurf existiert nicht. Für +einen Space ist das weniger schlimm als es klingt: VibeMind bringt Kanäle mit, +und ein Signal von dort ist ein `set master.room signal1 …` wie jedes andere. + +**4. Zwei Decks sind fest verdrahtet.** Der Plan will vier. Solange Stems nicht +von Platte streamen (rund 500 MB je Deck), wäre der vierte ohnehin nicht +finanzierbar — die beiden hängen zusammen. + +**5. Die Analyse ist nie gegen echte Musik mit gehörter Wahrheit gelaufen.** +Gebautes Material hat zuletzt zehn von 23 Namen gefunden; es ersetzt kein Ohr. +Ein Agent, der auf `section` und `energie` plant, plant auf ungeprüften Zahlen. + +**6. Zwei unabhängige Modelle haben noch nie zugleich aufgelegt** (P2 Stufe 2). +Braucht API-Zugang und kostet Geld — das ist eine Entscheidung, keine Aufgabe. + +Nicht auf dieser Liste, weil sie nichts blockieren: Pitch Bend, Reverb, FLAC, +MIDI, und eine Named Pipe für Windows statt der Rückschleife. + +## Die Reihenfolge + +Die Einzelheiten stehen in drei Spezifikationen, nicht hier: + +- **[FREIGABE.md](FREIGABE.md)** — das Gatter: sieben Klassen, die Set-Freigabe + als Datei, zwölf Tests, die dafür stehen müssen. +- **[SPACE.md](SPACE.md)** — Aufbau des Space, sieben Hard Rules, der Umzug der + Brücke, der Manifest-Eintrag. +- **[GENERATOR.md](GENERATOR.md)** — der Adapter, die Ablage samt Prompt, die + Kostenbremse. + + +Sicherheit zuerst, dann Umzug, dann die Agenten, deren Werkzeuge schon stehen, +und zuletzt der, der Geld kostet. + +| | Schritt | Wo | Hängt an | +| --- | --- | --- | --- | +| **1** ✅ | Freigabe-Gatter mit den vier Stufen, Default-Deny, Set-Freigabe mit Ablauf | dieses Repo, `mcp/` | — | +| **2** ✅ | Brücke auf `mcp.server.fastmcp` und in die Form `mcp/musik/` mit `server.py`, `approval.py`, `tests/` | dieses Repo | 1 | +| **3** ✅ | `spaces/musik/` mit `README.md`, `AGENTS.md`, `STATUS.md`, `agents/`, `api/` | VibeMind | 2 | +| **4** | Eintrag in `repo-manifest.yaml`, dieses Repo als Submodul mit Pin | VibeMind | Merge | +| **5** ✅ | **Mix-Engineer** als erster Agent | Space | 3 | +| **6** | **Track-Wähler** | Space | 5 | +| **7** | **Set-Planer** und **Guardrail** | Space | 6 | +| **8** | Generator-Adapter, erster Anbieter ElevenLabs | dieses Repo | Budget | + +**Warum der Mix-Engineer zuerst.** Seine Werkzeuge sind fertig und gemessen: das +Repertoire aus fünf Griffen, der Zeitplan auf dem Beatgrid, die Zurückhaltung, +die Mitschrift und der Kritiker, der jeden Übergang nachmisst. Er ist der +einzige Agent, dessen Arbeit sich am selben Abend prüfen lässt — und er ist der +erste Fall, an dem sich zeigt, ob die Set-Freigabe im Betrieb trägt. + +Sie trägt: Der Lauf ist zweimal gefahren, die Freigabe stand über ein Fenster +von einer Stunde, und die Kette hielt von der Entscheidung bis zum Mitschnitt. +Schritt 4 wurde dabei **übersprungen und nicht vergessen** — der Manifest- +Eintrag braucht einen Pin, und den gibt es erst nach dem Merge; der Agent +braucht ihn nicht. Zahlen in `spaces/musik/docs/ABNAHME.md`, Fund in +[FAHRPLAN.md](FAHRPLAN.md), N4. + +**Warum der Generator zuletzt.** Er hängt an einem Anbieter, an Geld und an +einer Lizenzfrage, die keiner von uns entscheidet. Und die harte Grenze aus +[AGENTEN.md](AGENTEN.md) bleibt: Ein generierter Track braucht Zehner von +Sekunden bis Minuten, ein Dancefloor entscheidet in Sekunden. Generiert wird +**vorausschauend** — Kandidaten für wahrscheinliche nächste Zustände —, und die +Crowd steuert die Auswahl aus fertigem Material. Wer auf Echtzeit-Generierung +wartet, baut ein System, das immer zu spät kommt. + +**Zwei Dinge, die dabei nicht vergessen werden dürfen.** Generierte Tracks +brauchen einen Ort samt ihrem Prompt — sonst weiß hinterher niemand, wie ein +Stück entstanden ist. Und Kosten und Rate-Limits gehören sichtbar gemacht, im +Steuerraum wie alles andere; ein Agent, der unbemerkt Geld ausgibt, ist ein +Fehler mit Rechnung. diff --git a/docs/bilder/agent-uebergang.png b/docs/bilder/agent-uebergang.png new file mode 100644 index 0000000..5d0f580 Binary files /dev/null and b/docs/bilder/agent-uebergang.png differ diff --git a/docs/bilder/effekte.png b/docs/bilder/effekte.png new file mode 100644 index 0000000..4bd1c50 Binary files /dev/null and b/docs/bilder/effekte.png differ diff --git a/docs/bilder/fernsteuerung.png b/docs/bilder/fernsteuerung.png new file mode 100644 index 0000000..cfe0a59 Binary files /dev/null and b/docs/bilder/fernsteuerung.png differ diff --git a/docs/bilder/oberflaeche.png b/docs/bilder/oberflaeche.png new file mode 100644 index 0000000..8e09710 Binary files /dev/null and b/docs/bilder/oberflaeche.png differ diff --git a/docs/bilder/regie.png b/docs/bilder/regie.png new file mode 100644 index 0000000..660320d Binary files /dev/null and b/docs/bilder/regie.png differ diff --git a/docs/wahrheit-vorlage.txt b/docs/wahrheit-vorlage.txt new file mode 100644 index 0000000..3f207e7 --- /dev/null +++ b/docs/wahrheit-vorlage.txt @@ -0,0 +1,24 @@ +# Vorlage für die Wahrheitsdatei des Prüfstands. +# +# Leg sie neben die Musik und trag ein, was du hörst — dann sagt +# +# musik-pruefstand wahrheit.txt +# +# wo die Analyse zustimmt und wo nicht. Gemeldet werden Abstände mit +# Vorzeichen, kein Urteil: Eine Angabe von Hand ist eine Meinung, und ab wann +# etwas kaputt ist, entscheidest du. +# +# Eine Zeile je Track. Zeiten als m:ss oder in Sekunden. +# Jede Angabe ist einzeln freiwillig — wer nur das Tempo weiß, schreibt nur +# das Tempo. Weglassen ist besser als raten. +# +# datei bpm tonart ... +# +# Abschnitte heißen intro, aufbau, drop, break, outro oder teil und stehen mit +# ihrem ANFANG, nicht als Bereich — das Ende ist der Anfang des nächsten. +# `teil` ist für Strecken, die schlicht laufen; sie „drop" zu nennen, damit ein +# Name dasteht, macht die Prüfung wertlos. + +Nachtschicht.mp3 bpm 124 tonart Am intro 0:00 aufbau 0:32 drop 1:04 break 2:40 drop 3:12 outro 5:12 +Alpenglühen.wav bpm 126 tonart C +EinAnderer.mp3 outro 4:48 diff --git a/mcp/musik/README.md b/mcp/musik/README.md new file mode 100644 index 0000000..e581458 --- /dev/null +++ b/mcp/musik/README.md @@ -0,0 +1,305 @@ +# musik_mcp + +Ein MCP-Server, der die laufende DJ-Anwendung an einen Agenten weiterreicht. + +Die eigentliche Arbeit macht `musik-app`; dieser Server ist ein dünner +Übersetzer zwischen MCP und dem Zeilenprotokoll auf dem Unix-Socket +(→ [docs/STEUERUNG.md](../docs/STEUERUNG.md)). + +## Warum Python neben einem Rust-Projekt + +Weil der Empfänger Python spricht: [VibeMind](../docs/VIBEMIND.md) ist die +Stelle, an die das hier andockt, und dort läuft FastMCP. Der Rust-Kern bleibt +unangetastet — die Brücke redet nur über den Socket und weiß von Audio nichts. + +Wer keinen Agenten braucht, braucht diesen Ordner nicht. `musik-app` läuft ohne +ihn, und `nc -U` tut es für die Hand auch. + +## Einrichten + +```sh +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +``` + +In einem MCP-Client (Claude Desktop, VibeMind, …): + +```json +{ + "mcpServers": { + "musik": { + "command": "/pfad/zu/.venv/bin/python", + "args": ["-m", "musik.server"], + "cwd": "/pfad/zu/musik/mcp", + "env": { "MUSIK_SOCKET": "/run/user/1000/musik.sock" } + } + } +} +``` + +`MUSIK_SOCKET` ist optional; ohne die Variable wird +`$XDG_RUNTIME_DIR/musik.sock` genommen — der Standard von `musik-app`. + +### Auf Windows: über die Rückschleife + +Unix-Sockets gibt es dort nicht. `musik-app --tcp` lauscht stattdessen auf +`127.0.0.1` und gibt beim Start einen Schlüssel aus; die Brücke braucht beides: + +```json +{ + "env": { + "MUSIK_TCP": "7657", + "MUSIK_SCHLUESSEL": "50270126063f57dd96e73b655ce56d5a" + } +} +``` + +`MUSIK_TCP` nimmt einen Port oder `host:port`; auf Windows wird die +Rückschleife auch ohne die Variable versucht. **Der Schlüssel entsteht bei jedem +Start neu** — steht dort noch der von gestern, meldet die Brücke das und rät +nicht herum. Ohne Schlüssel führt die Anwendung über diesen Weg gar nichts aus, +auch nichts Lesendes; der Grund steht in +[`docs/STEUERUNG.md`](../docs/STEUERUNG.md). + +## Werkzeuge + +| Werkzeug | Wofür | +| --- | --- | +| `musik_status` | Momentaufnahme: Decks, Kanalzüge, Summe, Mitschnitt, Plan | +| `musik_list_controls` | Was es gibt — mit Bereich, Einheit und Bedeutung | +| `musik_get` | Einen Wert lesen | +| `musik_next` | Was als Nächstes passt — sortiert nach dem, was das Set will, mit Begründung | +| `musik_search` | Sammlung durchsuchen, nach Text, Tempo oder Tonart | +| `musik_set` | Einen Wert setzen | +| `musik_do` | Eine Aktion auslösen: laden, syncen, Cue, Mitschnitt | +| `musik_ramp` | Einen Regler über Beats bewegen — eine Blende | +| `musik_schedule` | Eine Aktion oder einen Wert auf einen späteren Beat legen | +| `musik_uebergang` | Einen benannten Griff vormerken — blende, bassswap, schnitt, filter, schleife | +| `musik_cancel` | Vorgemerktes zurücknehmen | +| `musik_when` | Einen Befehl an eine Schwelle hängen — „wenn noch 32 Beats" | +| `musik_signal` | Etwas aus dem Raum melden — Energie, Andrang, Stimmung | +| `musik_queue` | Was als Nächstes kommt — mit Notiz, warum | +| `musik_queue_add` | Einen Track vormerken | +| `musik_queue_next` | Den vordersten auflegen | + +Sechzehn statt zweihundert. Ein Werkzeug je Control wäre die naheliegende +Übersetzung und die schlechtere: Der Steuerraum hat über zweihundert Einträge, +und ein Agent, der sie alle als Werkzeuge sieht, findet keins davon. +`musik_set` und `musik_do` erreichen alles, `musik_status`, `musik_search` und +`musik_next` sparen die Wege, die man sonst am häufigsten doppelt ginge. + +## Warum es Zeit-Werkzeuge braucht + +`musik_set` allein reicht für Reglerstellungen, nicht für Übergänge. Eine +Blende ist eine Bewegung über Takte; wer sie von außen nachbaut, ruft +`musik_set` in einer Schleife und schläft dazwischen — über eine +Werkzeugschnittstelle, deren Timing bei jedem Aufruf eine Modellantwort weit +entfernt ist. Das eiert hörbar, und der Agent kann in der Zeit nichts anderes +tun. + +`musik_ramp` und `musik_schedule` sagen einmal, was passieren soll, und kommen +sofort zurück. Gerechnet wird in **Beats, nicht in Sekunden**: Dreht jemand am +Tempo, bleibt die Blende musikalisch richtig; steht das Deck, wartet sie. + +```text +musik_ramp(control='channel1.eq_low', ziel=0, ueber_beats=8) +musik_ramp(control='master.crossfader', ziel=1, ueber_beats=32, in_beats=16) +musik_schedule(beats=64, aktion='deck2.sync') +``` + +## Fragen statt Rechnen + +`musik_status` nennt je Deck nicht nur Position und Länge, sondern das, wonach +tatsächlich gehandelt wird: + +```text +## DECK1 — Nachtschicht +- 128.02 BPM, läuft bei 154.3 s von 380.0 s +- noch **84 Beats**, 9 bis zur Phrasengrenze +- Tonart Am (8A) +``` + +Wer sich `beats_left` aus Position, Länge und Tempo selbst ausrechnet, rechnet +es bei jedem Blick neu — und macht dabei irgendwann einen Fehler, den niemand +sieht. Gezählt wird in **Grid-Beats, nicht in Sekunden**, genau wie bei `in` und +`ramp`: „noch 32 Beats" und „in 32 Beats" meinen dieselbe Strecke. + +`musik_when` hängt einen Befehl an eine Schwelle dieser Werte: + +```text +musik_when(control='deck1.beats_left', richtung='unter', schwelle=32, + aktion='master.queue_next') +``` + +Der Agent müsste sonst `deck1.beats_left` in kurzen Abständen abfragen und die +Schwelle selbst heraussuchen — je Abfrage ein Werkzeugaufruf, also eine +Modellantwort weit entfernt. So sagt er es einmal und ist wieder frei. + +Trifft die Bedingung schon zu, läuft der Befehl sofort: `when` heißt „sobald es +so weit ist", nicht „beim nächsten Überschreiten". Und ein Control, das keine +Zahl ist, wird abgewiesen — ein Auftrag auf `deck1.play < 1` würde stumm für +immer warten. + +## Was von außen hereinkommt + +Ein DJ liest die Fläche; ein Agent kann das nicht sehen. `musik_signal` gibt es +hinein: + +```text +musik_signal(name='Energie auf der Flaeche', wert=0.2) +… später … +musik_signal(name='Energie auf der Flaeche', wert=0.7) + +musik_status → ## Aus dem Raum + - **Energie auf der Flaeche** +0.70, +1.28/min +``` + +**Melde regelmäßig, nicht nur bei Änderungen.** Aus einem Wert entsteht kein +Trend, und der Trend ist das, wonach sich handeln lässt: „0,7" beantwortet keine +Frage, „0,7 und seit zwei Minuten fallend" beantwortet sie. + +Vier Plätze; derselbe Name landet immer auf demselben. Sind alle vier mit +anderen Namen belegt, kommt ein Fehler statt einer stillen Überschreibung — ein +überschriebenes Signal wäre ein Verlauf, den jemand anders gerade auswertet. + +Ab dann ist es ein Control wie jedes andere: + +```text +musik_when(control='master.signal1_trend', richtung='unter', schwelle=-0.3, + aktion='master.queue_next') +``` + +## Der Plan ist das gemeinsame Blatt + +Selten bedient nur einer. Deshalb steht im Feld `plan` von `musik_status`, was +vorgemerkt ist — von wem auch immer: + +```json +"plan": [ + {"id": 1, "art": "ramp", "text": "ramp channel1.eq_low 1.0000 → 0.0000 über 8 Beats, 2.5 gelaufen (deck1)"}, + {"id": 2, "art": "in", "text": "in 61.5 Beats: do deck2.sync (deck1)"} +] +``` + +Wer das liest, greift nicht mitten in eine fremde Blende. Und wenn doch: **Der +letzte Griff gewinnt.** Eine Rampe gibt auf, sobald jemand anders denselben +Regler anfasst — ein zweiter Agent wie der Mensch an der Oberfläche. Ohne das +wäre der Plan stärker als die Hand daneben. + +`musik_cancel` streicht deshalb ohne ausdrückliches `alle=true` nur den einen +genannten Auftrag; eine vergessene `plan_id` leert nicht die Arbeit der +anderen. + +## Die Beschreibungen werden erzeugt, nicht gepflegt + +Beim Start fragt der Server das laufende Programm, was es kann, und baut daraus +die Beschreibungen von `musik_set` und `musik_do`. Was ein Agent liest, ist +deshalb dasselbe, was in `crates/control/src/katalog.rs` steht — und dasselbe, +was in der Oberfläche als Tooltip erscheint: + +```text +Verfügbare Aktionen (erwartetes Argument in Klammern): + deck1.sync ([deck]) — Auf das andere Deck ziehen — Tempo UND Phase; … + deck1.load () — Track laden; arbeitet im Hintergrund, … + deck1.jump_cue (<1..8>) — Einen gesetzten Hot Cue anspringen +``` + +Ein neues Control erscheint hier ohne eine Zeile Python. Zwei Beschreibungen, +die auseinanderlaufen könnten, gibt es nicht. + +Läuft die Anwendung beim Start des Servers nicht, bleibt die Beschreibung +allgemein und verweist auf `musik_list_controls` — die Liste kommt dann zur +Laufzeit. + +## Was als Nächstes kommt + +Sobald mehr als einer auswählt, ist „was kommt als Nächstes" eine Frage an die +Anlage und nicht an einen Kopf. Zwei Agenten mit je eigener Liste legen +irgendwann beide auf dasselbe Deck; einer verliert, und niemand merkt es, bis +der falsche Track läuft. + +```text +musik_queue_add(pfad='…/track.mp3', notiz='passt harmonisch zu 8A, mehr Druck') +musik_queue() → 1. …/warm.mp3 — ruhig anfangen + 2. …/track.mp3 — passt harmonisch zu 8A, mehr Druck +musik_queue_next() → load deck2 angenommen + queue 1 abgenommen …/warm.mp3 + notiz ruhig anfangen +``` + +**Jeder Eintrag trägt eine Notiz.** Das ist der Unterschied zu einer Playlist: +Wer vormerkt, weiß warum — und ohne die Notiz muss der Nächste den Grund aus +BPM und Tonart erraten. Bei einem Team von Agenten heißt „erraten": erfinden. + +Drei Dinge sind mit Absicht so: + +- **Derselbe Pfad wird nicht zweimal angenommen.** Die Antwort nennt die + Nummer, unter der er schon steht. Zwei, die unabhängig nach 128 BPM in 8A + suchen, finden denselben Track — ihn stillschweigend zweimal aufzunehmen + hieße, ihn zweimal zu spielen. +- **`musik_queue_next` legt ohne Deckangabe nur auf ein Deck, das nicht + läuft.** Laufen alle, kommt ein Fehler statt einer Vermutung. Ein Track über + einen laufenden gelegt reißt den Mix ab. +- **Scheitert das Laden, bleibt der Eintrag stehen.** Sonst wäre er weg, ohne + gespielt worden zu sein, und das fällt erst auf, wenn er fehlt. + +Ändern und Streichen brauchen kein eigenes Werkzeug — `master.queue_drop`, +`queue_bump`, `queue_note` und `queue_clear` nehmen genau ein Argument und +stehen mit ihrer Erklärung schon in der Beschreibung von `musik_do`. + +## Sicherheit + +- **Kein Netz.** Der Server spricht stdio mit seinem Client und einen + Unix-Socket mit der Anwendung. Wer die Socketdatei nicht öffnen darf, kommt + nicht hinein; eine eigene Anmeldung gibt es deshalb nicht. +- **Keine eingeschleusten Befehle.** Das Protokoll ist zeilenweise, also wird + jedes Argument auf Zeilenumbrüche geprüft und sonst abgewiesen. Ein Pfad mit + `\n` wäre sonst ein zweiter Befehl, den niemand geschickt hat. +- `musik_do` ist als `destructiveHint` gemeldet: `load` tauscht den Track eines + Decks, `record` schreibt eine Datei — beides überschreibt etwas. `musik_cancel` + ebenso: Gestrichenes ist weg und kann von jemand anderem stammen. + +## Prüfen + +```sh +cargo run --release -p musik-app -- --socket /tmp/musik.sock +MUSIK_SOCKET=/tmp/musik.sock .venv/bin/python musik/tests/test_server.py +``` + +Der Test spricht den Server über einen echten MCP-Client an — `tools/list` und +`tools/call`, nicht an der Schnittstelle vorbei. Ohne laufende Anwendung meldet +er sich mit Rückgabewert 77 ab, statt Grün zu behaupten, wo nichts geprüft +wurde. + +## Wo im Track ein Deck steht + +`musik_status` trägt seit der Strukturanalyse je Deck auch `section` +(`intro`, `aufbau`, `drop`, `break`, `outro`, `teil`), `section_beats_left`, +`beats_to_outro`, `intro_beats` und `entry`. Im Bericht steht das als eine +Zeile: + +```text +- Abschnitt **aufbau**, noch 12 Beats · Outro in 108 Beats +``` + +Damit muss ein Agent nicht mehr aus Position und Länge schätzen, wo im Stück er +ist. `beats_to_outro` wird negativ, sobald das Outro läuft — die Zahl, an der +ein `musik_when` hängen kann. + +**Leer heißt nicht null.** Steht der Abspielkopf im Vorlauf vor der ersten Eins, +gibt es keinen Abschnitt; hat ein Track kein Outro, gibt es keine Zahl. Beides +kommt als `null` zurück und nicht als 0. + +## Wenn ein anderer dazwischenkommt + +`musik_status` trägt `master.last_event` und `master.event_count`. Dort steht, +was der Plan zuletzt von sich aus gemeldet hat — meistens `fertig`, im +interessanten Fall `abgeloest channel1.fader — jemand anders hat den Regler`. + +Über MCP lässt sich nicht abonnieren: Die Brücke öffnet je Aufruf eine neue +Verbindung. Deshalb der Zähler daneben. **Springt er zwischen zwei Blicken um +mehr als eins, sind Ereignisse dazwischen liegengeblieben** — dann lohnt ein +Blick in den Plan, ob noch dasteht, was man vorgemerkt hatte. + +Wer dauerhaft zuhören kann, nimmt den Socket direkt und `sub master.events`; +dort kommt jede Zeile einzeln. diff --git a/mcp/musik/__init__.py b/mcp/musik/__init__.py new file mode 100644 index 0000000..414cf79 --- /dev/null +++ b/mcp/musik/__init__.py @@ -0,0 +1,5 @@ +"""Die MCP-Brücke zur DJ-Anlage. + +Ein Paket, kein loses Skript — damit es sich installieren lässt und in +VibeMind dieselbe Form hat wie `mcp/docker/`. +""" diff --git a/mcp/musik/controls.txt b/mcp/musik/controls.txt new file mode 100644 index 0000000..84d4b97 --- /dev/null +++ b/mcp/musik/controls.txt @@ -0,0 +1,203 @@ +# Alle Controls der Anlage, erzeugt aus `list` an der laufenden Anwendung: +# +# printf 'list\n' | nc -U "$XDG_RUNTIME_DIR/musik.sock" \ +# | grep -oP '^control \K[a-z0-9_.]+' | sort -u > mcp/controls.txt +# +# `test_freigabe.py` hält dagegen, dass jedes davon einer Klasse zugeordnet +# ist. Kommt ein Control dazu, ohne dass diese Datei neu erzeugt wird, fällt +# es hier nicht auf — wohl aber im Betrieb: Ein unbekanntes Control wird +# abgewiesen, mit genau dieser Begründung. +channel1.assign +channel1.cue +channel1.eq_high +channel1.eq_low +channel1.eq_mid +channel1.fader +channel1.filter +channel1.fx +channel1.fx_amount +channel1.fx_mix +channel1.fx_sync +channel1.fx_time +channel1.trim +channel2.assign +channel2.cue +channel2.eq_high +channel2.eq_low +channel2.eq_mid +channel2.fader +channel2.filter +channel2.fx +channel2.fx_amount +channel2.fx_mix +channel2.fx_sync +channel2.fx_time +channel2.trim +channel3.assign +channel3.cue +channel3.eq_high +channel3.eq_low +channel3.eq_mid +channel3.fader +channel3.filter +channel3.fx +channel3.fx_amount +channel3.fx_mix +channel3.fx_sync +channel3.fx_time +channel3.trim +deck1.artist +deck1.beat +deck1.beat_phase +deck1.beatjump +deck1.beats_left +deck1.beats_to_outro +deck1.beats_to_phrase +deck1.bpm +deck1.bpm_grid +deck1.cue1 +deck1.cue2 +deck1.cue3 +deck1.cue4 +deck1.cue5 +deck1.cue6 +deck1.cue7 +deck1.cue8 +deck1.duration +deck1.entry +deck1.finished +deck1.grid_anchor +deck1.grid_here +deck1.grid_scale +deck1.intro_beats +deck1.jump_cue +deck1.jump_entry +deck1.key +deck1.key_camelot +deck1.keylock +deck1.load +deck1.load_status +deck1.loop_active +deck1.loop_beats +deck1.phrase_beats +deck1.play +deck1.position +deck1.section +deck1.section_beats_left +deck1.stem1_level +deck1.stem1_name +deck1.stem2_level +deck1.stem2_name +deck1.stem3_level +deck1.stem3_name +deck1.stem4_level +deck1.stem4_name +deck1.stems +deck1.sync +deck1.tempo +deck1.title +deck2.artist +deck2.beat +deck2.beat_phase +deck2.beatjump +deck2.beats_left +deck2.beats_to_outro +deck2.beats_to_phrase +deck2.bpm +deck2.bpm_grid +deck2.cue1 +deck2.cue2 +deck2.cue3 +deck2.cue4 +deck2.cue5 +deck2.cue6 +deck2.cue7 +deck2.cue8 +deck2.duration +deck2.entry +deck2.finished +deck2.grid_anchor +deck2.grid_here +deck2.grid_scale +deck2.intro_beats +deck2.jump_cue +deck2.jump_entry +deck2.key +deck2.key_camelot +deck2.keylock +deck2.load +deck2.load_status +deck2.loop_active +deck2.loop_beats +deck2.phrase_beats +deck2.play +deck2.position +deck2.section +deck2.section_beats_left +deck2.stem1_level +deck2.stem1_name +deck2.stem2_level +deck2.stem2_name +deck2.stem3_level +deck2.stem3_name +deck2.stem4_level +deck2.stem4_name +deck2.stems +deck2.sync +deck2.tempo +deck2.title +master.arc +master.arc_actual +master.arc_curve +master.arc_gap +master.arc_minutes +master.arc_start +master.arc_target +master.arc_trend +master.crossfader +master.crossfader_curve +master.cue_gain +master.cue_mix +master.event_count +master.events +master.gain +master.playlist +master.playlists +master.queue +master.queue_add +master.queue_bump +master.queue_clear +master.queue_drop +master.queue_next +master.queue_note +master.record +master.record_dropped +master.record_seconds +master.record_stop +master.recording +master.repeats +master.room +master.room_bend +master.search +master.search_harmonic +master.search_mixable +master.search_next +master.signal1 +master.signal1_age +master.signal1_name +master.signal1_trend +master.signal2 +master.signal2_age +master.signal2_name +master.signal2_trend +master.signal3 +master.signal3_age +master.signal3_name +master.signal3_trend +master.signal4 +master.signal4_age +master.signal4_name +master.signal4_trend +master.transitions +master.uebergang +master.why diff --git a/mcp/musik/freigabe.py b/mcp/musik/freigabe.py new file mode 100644 index 0000000..b940efa --- /dev/null +++ b/mcp/musik/freigabe.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +"""Das Gatter: was ein Agent von sich aus darf. + +Die Spezifikation steht in `docs/FREIGABE.md`; hier steht nur, was sie +ausführt. Kurz: + +* Klassifiziert wird nach dem, was ein Aufruf **berührt**, nicht nach seinem + Namen — bei über zweihundert Controls wäre eine Namensliste nach dem nächsten + Feature falsch. +* Lesen ist frei. Alles andere braucht eine gültige Freigabe. +* Die Freigabe steht in einer **Datei**, nicht in der Umgebung. Eine + Umgebungsvariable steht fest, sobald der Prozess läuft; wer mitten im Set + merkt, dass der Agent Unsinn macht, muss ohne Neustart widerrufen können. +* Fail-closed in jedem Zweifelsfall. + +**Wogegen das nicht hilft.** Das Gatter schützt davor, dass ein Agent seine +Werkzeuge missbraucht. Es schützt nicht vor etwas, das bereits Socket-Zugang +hat — ein Agent mit einer Shell oder freiem Dateizugriff umgeht es. Dass +solche Werkzeuge im Space nicht freigeschaltet sind, ist Aufgabe dessen, der +ihn einrichtet, und steht dort als erste harte Regel. +""" + +from __future__ import annotations + +import datetime as _dt +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +#: Wie lange eine Freigabe höchstens im Voraus gelten darf. +#: +#: Kein Sicherheitsmerkmal, sondern eine Bremse gegen den bequemsten Fehler: +#: einmal „bis 2099" schreiben und nie wieder daran denken. +HOECHSTDAUER = _dt.timedelta(hours=12) + +#: Klassen, die es gibt. `lesen` ist immer frei, `erzeugen` nie über die Datei. +KLASSEN = ("lesen", "mischen", "spielen", "zeit", "datei", "dramaturgie", "erzeugen") + +#: Klassen, die in der Freigabe-Datei stehen dürfen. +#: +#: `lesen` braucht keine Freigabe, `erzeugen` bekommt keine — es kostet je +#: Aufruf Geld und gehört hinter eine Bestätigung. Beide hier zuzulassen wäre +#: die Art Bequemlichkeit, die man später nicht mehr los wird. +ERTEILBAR = frozenset({"mischen", "spielen", "zeit", "datei", "dramaturgie"}) + + +class FreigabeFehler(RuntimeError): + """Die Freigabe-Datei ist nicht zu gebrauchen.""" + + +@dataclass(frozen=True) +class Freigabe: + """Was eine gültige Freigabe-Datei sagt.""" + + klassen: frozenset[str] + bis: _dt.datetime + von: str + + def gilt(self, jetzt: Optional[_dt.datetime] = None) -> bool: + jetzt = jetzt or _dt.datetime.now(_dt.timezone.utc) + return jetzt < self.bis + + def notiz(self) -> str: + """Die Zeile, die einmal je Fenster in die Mitschrift geht.""" + klassen = " ".join(sorted(self.klassen)) + return f"note freigabe {klassen} bis {self.bis.isoformat()} von {self.von}" + + +def datei_pfad() -> Optional[Path]: + roh = os.environ.get("MUSIK_FREIGABE_DATEI", "").strip() + return Path(roh) if roh else None + + +def lesen(pfad: Path, jetzt: Optional[_dt.datetime] = None) -> Freigabe: + """Liest die Freigabe-Datei. Wirft bei allem, was nicht eindeutig ist.""" + jetzt = jetzt or _dt.datetime.now(_dt.timezone.utc) + try: + text = pfad.read_text(encoding="utf-8") + except OSError as fehler: + raise FreigabeFehler(f"{pfad} nicht lesbar: {fehler}") from fehler + + klassen: Optional[frozenset[str]] = None + bis: Optional[_dt.datetime] = None + von = "unbekannt" + + for zeile in text.splitlines(): + zeile = zeile.strip() + if not zeile or zeile.startswith("#"): + continue + wort, _, rest = zeile.partition(" ") + rest = rest.strip() + if wort == "klassen": + gewuenscht = set(rest.split()) + if not gewuenscht: + raise FreigabeFehler("`klassen` ist leer") + # Ein Tippfehler verwirft die **ganze** Datei. Sonst funktionierten + # drei von vier Klassen still weiter, und niemand merkte es. + unbekannt = gewuenscht - ERTEILBAR + if unbekannt: + raise FreigabeFehler( + f"unbekannte oder nicht erteilbare Klasse: {' '.join(sorted(unbekannt))}. " + f"Erteilbar sind: {' '.join(sorted(ERTEILBAR))}" + ) + klassen = frozenset(gewuenscht) + elif wort == "bis": + bis = _zeitpunkt(rest) + elif wort == "von": + von = rest or "unbekannt" + + if klassen is None: + raise FreigabeFehler("`klassen` fehlt") + if bis is None: + raise FreigabeFehler("`bis` fehlt") + if bis <= jetzt: + raise FreigabeFehler(f"abgelaufen seit {bis.isoformat()}") + if bis - jetzt > HOECHSTDAUER: + raise FreigabeFehler( + f"`bis` liegt weiter als {int(HOECHSTDAUER.total_seconds() // 3600)} h " + "voraus — so lange wird nicht im Voraus freigegeben" + ) + return Freigabe(klassen=klassen, bis=bis, von=von) + + +def _zeitpunkt(roh: str) -> _dt.datetime: + """RFC 3339 **mit** Zeitzone. + + Ohne Zeitzone wird abgewiesen und nicht als Ortszeit geraten: Der Prozess + läuft womöglich woanders als der Mensch, der die Datei geschrieben hat, und + eine Freigabe, die zwei Stunden länger gilt als gedacht, ist genau das, was + nicht passieren soll. + """ + try: + wert = _dt.datetime.fromisoformat(roh.replace("Z", "+00:00")) + except ValueError as fehler: + raise FreigabeFehler(f"`bis {roh}` ist kein Zeitpunkt") from fehler + if wert.tzinfo is None: + raise FreigabeFehler(f"`bis {roh}` hat keine Zeitzone — Z oder +02:00 anhängen") + return wert + + +def pruefen(klasse: str, befehl: str) -> tuple[bool, str]: + """Ob dieser Aufruf laufen darf. Gibt (erlaubt, Grund) zurück. + + Gelesen wird bei **jedem** Aufruf. Das kostet einen kurzen Read; gegen einen + Werkzeugaufruf, der ohnehin über eine Prozessgrenze geht, ist das nichts. + Zwischenspeichern wäre genau die Optimierung, die das Widerrufen kaputt + macht. + """ + if klasse not in KLASSEN: + return False, f"unbekannte Klasse {klasse!r} — das ist ein Fehler im Werkzeug" + if klasse == "lesen": + return True, "lesen ist frei" + if klasse == "erzeugen": + return False, ( + "`erzeugen` läuft nie über die Freigabe-Datei — es kostet je Aufruf " + "Geld und braucht eine Bestätigung je Aufruf" + ) + + pfad = datei_pfad() + if pfad is None: + return False, "keine gültige Freigabe (MUSIK_FREIGABE_DATEI nicht gesetzt)" + try: + freigabe = lesen(pfad) + except FreigabeFehler as fehler: + return False, f"keine gültige Freigabe ({fehler})" + if klasse not in freigabe.klassen: + erlaubt = " ".join(sorted(freigabe.klassen)) + return False, f"Freigabe gilt für {erlaubt}, nicht für {klasse}" + return True, f"freigegeben von {freigabe.von} bis {freigabe.bis.isoformat()}" + + +def verweigert(klasse: str, befehl: str, grund: str) -> str: + """Die Antwort bei einer Ablehnung. + + Sie sagt genau, was gelaufen wäre, und wie man es erlauben würde — und + **nichts über den Zustand der Anlage**. Wer nicht schreiben darf, erfährt + auch nicht, was gerade läuft. + """ + if klasse == "erzeugen": + weg = "eine Bestätigung je Aufruf; die Freigabe-Datei hilft hier nicht" + else: + weg = f"`klassen` in MUSIK_FREIGABE_DATEI um {klasse} ergänzen" + return ( + f"freigabe verweigert für: {befehl}\n" + f" Klasse: {klasse}\n" + f" Grund: {grund}\n" + f" Erlaubt: {weg}" + ) + + +# ── Welche Klasse ein Control berührt ──────────────────────────────────── +# +# Die Namen stammen aus `list` an der laufenden Anlage (194 Controls), nicht +# aus dem Gedächtnis. Wer hier etwas ändert, prüft dort nach. + +#: Auskunft. Ändert nichts, also frei. +_LESEN = frozenset({ + "artist", "assign", "beat", "beat_phase", "beats_left", "beats_to_outro", + "beats_to_phrase", "bpm", "duration", "entry", "finished", "intro_beats", + "key", "key_camelot", "load_status", "phrase_beats", "playlist", + "playlists", "position", "record_dropped", "record_seconds", "recording", + "repeats", "section", "section_beats_left", "stems", "title", + "transitions", "why", "queue", "event_count", "events", + "arc_actual", "arc_curve", "arc_gap", "arc_minutes", "arc_target", + "arc_trend", "room_bend", + "search", "search_harmonic", "search_mixable", "search_next", +}) + +#: Klang, aber kein neuer Track. Zeitkritisch. +_MISCHEN = frozenset({ + "fader", "trim", "eq_low", "eq_mid", "eq_high", "filter", "cue", + "crossfader", "crossfader_curve", "gain", "cue_gain", "cue_mix", + "fx", "fx_amount", "fx_mix", "fx_sync", "fx_time", +}) + +#: Wiedergabe: was läuft, wo es steht, wie schnell. +_SPIELEN = frozenset({ + "play", "jump_cue", "jump_entry", "sync", "tempo", "keylock", + "loop_active", "loop_beats", "beatjump", "queue_next", +}) + +#: Berührt Dateisystem oder Sammlung — nicht zeitkritisch. +_DATEI = frozenset({ + "load", "record", "record_stop", "queue_add", + "grid_anchor", "grid_here", "grid_scale", "bpm_grid", +}) + +#: Was das Set vorhat. +_DRAMATURGIE = frozenset({ + "arc", "arc_start", "room", "queue_note", "queue_bump", "queue_clear", + "queue_drop", "uebergang", +}) + + +def klasse_fuer_control(name: str) -> Optional[str]: + """Welche Klasse dieses Control berührt, oder `None`. + + `None` heißt **unbekannt** und wird von [`pruefen`] abgewiesen. Das ist + Absicht: Ein neues Control, das niemand einsortiert hat, soll auffallen und + nicht stillschweigend in die bequemste Klasse rutschen. Der Preis ist, dass + jedes neue Control hier einzutragen ist — den zahlt man einmal je Feature, + und er ist kleiner als eine Freigabe, die mehr erlaubt als gedacht. + """ + kurz = name.split(".", 1)[-1] if "." in name else name + + # Nummerierte Familien: cue1..8, stem1_level, signal1_trend, … + if kurz.startswith("cue") and kurz[3:].isdigit(): + return "spielen" + if kurz.startswith("stem") and len(kurz) > 4: + rest = kurz[4:] + if rest[:1].isdigit(): + endung = rest[1:] + if endung == "_level": + return "mischen" + if endung == "_name": + return "lesen" + return None + if kurz.startswith("signal") and len(kurz) > 6: + rest = kurz[6:] + if rest[:1].isdigit(): + # signal1 selbst wird gesetzt, signal1_age/_name/_trend gelesen. + return "dramaturgie" if rest[1:] == "" else "lesen" + + for klasse, namen in ( + ("lesen", _LESEN), + ("mischen", _MISCHEN), + ("spielen", _SPIELEN), + ("datei", _DATEI), + ("dramaturgie", _DRAMATURGIE), + ): + if kurz in namen: + return klasse + return None + + +def pruefen_control(name: str, befehl: str) -> tuple[bool, str]: + """Wie [`pruefen`], aber die Klasse kommt aus dem Control-Namen.""" + klasse = klasse_fuer_control(name) + if klasse is None: + return False, ( + f"unbekanntes Control {name!r} — es ist in mcp/freigabe.py keiner " + "Klasse zugeordnet. Fail-closed: lieber abweisen als raten." + ) + return pruefen(klasse, befehl) diff --git a/mcp/musik/pyproject.toml b/mcp/musik/pyproject.toml new file mode 100644 index 0000000..a620757 --- /dev/null +++ b/mcp/musik/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "musik-mcp" +version = "0.1.0" +description = "MCP-Brücke zur DJ-Anlage (stdio) — der Steuerraum als Werkzeuge" +requires-python = ">=3.11" + +# Nicht `mcp` allein: In 2.x heißt FastMCP MCPServer, und ein ungepinntes +# `mcp` bricht darum bei der nächsten Installation. `server.py` kommt über +# eine Weiche mit beiden zurecht — die Spanne hier sagt, welche geprüft sind. +dependencies = ["mcp>=1.9,<3", "pydantic>=2"] + +[project.optional-dependencies] +test = ["pytest>=8"] + +[project.scripts] +musik-mcp = "musik.server:main" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/mcp/musik/server.py b/mcp/musik/server.py new file mode 100644 index 0000000..563680e --- /dev/null +++ b/mcp/musik/server.py @@ -0,0 +1,2215 @@ +#!/usr/bin/env python3 +"""MCP-Server für Musik. + +Reicht den Steuerraum der laufenden DJ-Anwendung an einen Agenten weiter. Die +eigentliche Arbeit macht `musik-app`; dieser Server ist nur ein Übersetzer +zwischen MCP und dem Zeilenprotokoll auf dem Unix-Socket. + +Warum eine eigene Sprache: VibeMind spricht MCP, und ein Agent soll die Anlage +bedienen können, ohne die Oberfläche zu kennen. Warum Python: Genau dort läuft +VibeMind, und die Brücke ist dünn genug, dass sie den Rust-Kern nicht anfasst. + +**Der Steuerraum beschreibt sich selbst.** Die Beschreibungen von `musik_set` +und `musik_do` werden beim Start aus dem laufenden Programm erzeugt, nicht von +Hand gepflegt. Ein neues Control in `crates/control/src/katalog.rs` erscheint +damit ohne eine Zeile hier — und die Erklärung, die der Agent liest, ist +dieselbe, die auch als Tooltip in der Oberfläche steht. Zwei Beschreibungen, +die auseinanderlaufen könnten, gibt es nicht. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +import tempfile +from enum import Enum +from pathlib import Path +from typing import Any, Optional + +from . import freigabe + +# **Eine Weiche statt einer Wette auf eine Major-Version.** +# +# Im MCP-SDK heißt die Klasse bis 1.x `FastMCP` unter `mcp.server.fastmcp`, ab +# 2.x `MCPServer` unter `mcp.server.mcpserver`. Nachgemessen an 1.29.1 und +# 2.1.1: `tool()`, der Konstruktor und `run()` sind gleich geblieben, nur Name +# und Modulpfad nicht. VibeMinds eigener `mcp/docker/` importiert heute den +# alten Pfad und hängt an einem ungepinnten `mcp` — der bricht, sobald jemand +# frisch installiert. Diese Weiche nimmt beides. +try: # mcp >= 2 + from mcp.server.mcpserver import MCPServer as _Server +except ModuleNotFoundError: # mcp < 2 + from mcp.server.fastmcp import FastMCP as _Server +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +mcp = _Server("musik_mcp") + +# -------------------------------------------------------------------------- +# Verbindung +# -------------------------------------------------------------------------- + +#: Wie lange auf eine Antwort gewartet wird. Reglerbewegungen sind sofort da; +#: nur `search` fasst eine SQLite-Abfrage an, und auch die ist schnell. +ZEITLIMIT = 5.0 + +#: Wie viele Signalplätze es gibt. Muss zu `crate::signal::SIGNALE` passen — +#: mehr wäre eine Behauptung über Plätze, die es nicht gibt. +SIGNALE = 4 + +#: Zeilen, die eine Antwort abschließen. `get` antwortet mit genau einer +#: `value`-Zeile, alles andere endet auf `ok` oder `err`. +ABSCHLUSS = ("ok", "err", "value") + + +def socket_pfad() -> Path: + """Wo die Anwendung lauscht. + + `MUSIK_SOCKET` schlägt alles. Sonst das Laufzeitverzeichnis des Benutzers, + wie es `musik-app` als Standard verwendet. + """ + if aus_umgebung := os.environ.get("MUSIK_SOCKET"): + return Path(aus_umgebung) + if laufzeit := os.environ.get("XDG_RUNTIME_DIR"): + return Path(laufzeit) / "musik.sock" + return Path(tempfile.gettempdir()) / "musik.sock" + + +def tcp_ziel() -> tuple[str, int] | None: + """Wohin über die Rückschleife verbunden wird, falls überhaupt. + + `MUSIK_TCP` ist entweder ein Port (`7657`) oder `host:port`. Auf Windows + gibt es keine Unix-Sockets, deshalb ist die Rückschleife dort der Normalfall + und wird auch ohne Angabe versucht. + """ + roh = os.environ.get("MUSIK_TCP") + if roh is None: + return ("127.0.0.1", 7657) if os.name == "nt" else None + roh = roh.strip() + if ":" in roh: + host, _, port = roh.rpartition(":") + return (host or "127.0.0.1", int(port)) + return ("127.0.0.1", int(roh)) + + +def tcp_schluessel() -> str | None: + """Der Schlüssel, den die Anwendung beim Start ausgibt. + + Steht in `MUSIK_SCHLUESSEL`. Ohne ihn lässt die Anwendung über die + Rückschleife nichts ausführen — das ist Absicht, denn dort kommt jedes + Programm auf dem Rechner hin, eine Webseite im Browser eingeschlossen. + """ + return os.environ.get("MUSIK_SCHLUESSEL") + + +#: Merker, damit die Notiz einmal je Freigabe-Fenster in die Mitschrift geht +#: und nicht bei jedem Aufruf. Eine Mitschrift, die zehnmal dasselbe sagt, wird +#: nicht gelesen. +_gemeldete_freigabe: Optional[str] = None + + +def _torwaechter(klasse: str, befehl: str) -> Optional[str]: + """Gibt eine Ablehnung zurück, oder `None`, wenn der Aufruf laufen darf. + + Geprüft wird **vor** dem Verbindungsaufbau: erst ausführen und dann melden + wäre kein Gatter. + """ + erlaubt, grund = freigabe.pruefen(klasse, befehl) + if not erlaubt: + return freigabe.verweigert(klasse, befehl, grund) + return None + + +def _torwaechter_control(control: str, befehl: str) -> Optional[str]: + """Dasselbe, aber die Klasse kommt aus dem Namen des Controls.""" + erlaubt, grund = freigabe.pruefen_control(control, befehl) + if not erlaubt: + klasse = freigabe.klasse_fuer_control(control) or "unbekannt" + return freigabe.verweigert(klasse, befehl, grund) + return None + + +async def _notiz_wenn_neu() -> None: + """Schreibt die Freigabe einmal je Fenster in die Mitschrift. + + Ohne das ist hinterher nicht rekonstruierbar, unter welcher Vollmacht ein + Set gefahren wurde — und genau das fragt man, wenn etwas schiefging. + """ + global _gemeldete_freigabe + pfad = freigabe.datei_pfad() + if pfad is None: + return + try: + gueltig = freigabe.lesen(pfad) + except freigabe.FreigabeFehler: + return + notiz = gueltig.notiz() + if notiz == _gemeldete_freigabe: + return + try: + await sprich(notiz) + except NichtErreichbar: + # Die Notiz ist eine Fußnote. Wenn die Anlage nicht erreichbar ist, + # scheitert der eigentliche Aufruf gleich von selbst und sagt es. + return + _gemeldete_freigabe = notiz + + +class NichtErreichbar(RuntimeError): + """Die Anwendung läuft nicht oder der Socket stimmt nicht.""" + + +def _pruefe_einzeilig(text: str, feld: str) -> str: + """Weist Zeilenumbrüche ab. + + Das Protokoll ist zeilenweise. Ein Umbruch in einem Dateipfad wäre sonst + ein zweiter Befehl, den niemand geschickt hat — dieselbe Art Lücke wie eine + SQL-Injektion, nur billiger zu schließen. + """ + if "\n" in text or "\r" in text: + raise ValueError(f"{feld} darf keinen Zeilenumbruch enthalten") + return text + + +async def _lies_antwort(leser: asyncio.StreamReader) -> list[str]: + """Liest die Zeilen bis zum Abschluss einer Antwort.""" + zeilen: list[str] = [] + while True: + roh = await leser.readline() + if not roh: + return zeilen + zeile = roh.decode("utf-8", "replace").rstrip("\r\n") + zeilen.append(zeile) + if zeile.split(" ", 1)[0] in ABSCHLUSS: + return zeilen + + +async def sprich(*befehle: str) -> list[list[str]]: + """Schickt Befehle über eine Verbindung und gibt je eine Antwort zurück. + + Mehrere Befehle in einem Rutsch, weil eine Momentaufnahme sonst ein Dutzend + Verbindungen kostet. + """ + ziel = tcp_ziel() + if ziel is not None: + host, port = ziel + wo = f"{host}:{port}" + try: + leser, schreiber = await asyncio.wait_for( + asyncio.open_connection(host, port), timeout=ZEITLIMIT + ) + except (ConnectionRefusedError, OSError) as fehler: + raise NichtErreichbar( + f"Musik ist unter {wo} nicht erreichbar. Läuft `musik-app --tcp`? " + "Das Ziel lässt sich mit MUSIK_TCP setzen." + ) from fehler + except asyncio.TimeoutError as fehler: + raise NichtErreichbar(f"{wo} antwortet nicht") from fehler + else: + wo = str(socket_pfad()) + try: + leser, schreiber = await asyncio.wait_for( + asyncio.open_unix_connection(wo), timeout=ZEITLIMIT + ) + except (FileNotFoundError, ConnectionRefusedError) as fehler: + raise NichtErreichbar( + f"Musik ist unter {wo} nicht erreichbar. Läuft `musik-app`? " + "Der Socketpfad lässt sich mit der Umgebungsvariablen MUSIK_SOCKET " + "setzen, in der Anwendung mit --socket." + ) from fehler + except asyncio.TimeoutError as fehler: + raise NichtErreichbar(f"{wo} antwortet nicht") from fehler + + try: + # Über die Rückschleife steht die Anmeldung vor allem anderen. Erst + # danach nimmt die Anwendung überhaupt einen Befehl an. + if ziel is not None: + schluessel = tcp_schluessel() + if not schluessel: + raise NichtErreichbar( + "Über die Rückschleife braucht es einen Schlüssel. Er steht " + "beim Start von `musik-app` im Fenster; setz ihn in " + "MUSIK_SCHLUESSEL." + ) + schreiber.write(f"auth {schluessel}\n".encode("utf-8")) + await schreiber.drain() + antwort = await asyncio.wait_for(_lies_antwort(leser), ZEITLIMIT) + if not antwort or not antwort[-1].startswith("ok"): + raise NichtErreichbar( + "Der Schlüssel wurde nicht angenommen. Er entsteht bei jedem " + "Start neu — steht in MUSIK_SCHLUESSEL noch der von gestern?" + ) + + antworten: list[list[str]] = [] + for befehl in befehle: + schreiber.write((befehl + "\n").encode("utf-8")) + await schreiber.drain() + antworten.append(await asyncio.wait_for(_lies_antwort(leser), ZEITLIMIT)) + return antworten + finally: + schreiber.close() + try: + await schreiber.wait_closed() + except (ConnectionResetError, BrokenPipeError): + pass + + +async def eine_antwort(befehl: str) -> list[str]: + return (await sprich(befehl))[0] + + +def _fehlerzeile(zeilen: list[str]) -> Optional[str]: + for zeile in zeilen: + if zeile.startswith("err "): + return zeile[4:] + return None + + +# -------------------------------------------------------------------------- +# Katalog +# -------------------------------------------------------------------------- + + +class Control(BaseModel): + """Ein Control, so wie das Programm es beschreibt.""" + + name: str + art: str + raum: str + einheit: str + schreibbar: bool + text: str + + +def _control_aus_zeile(zeile: str) -> Optional[Control]: + """`control deck1.tempo zahl 0.92..1.08 faktor rw Tempo-Regler; …`""" + teile = zeile.split(" ", 6) + if len(teile) < 7 or teile[0] != "control": + return None + return Control( + name=teile[1], + art=teile[2], + raum=teile[3], + einheit=teile[4], + schreibbar=teile[5] == "rw", + text=teile[6], + ) + + +async def katalog(praefix: str = "") -> list[Control]: + befehl = f"list {praefix}".strip() + zeilen = await eine_antwort(befehl) + if fehler := _fehlerzeile(zeilen): + raise RuntimeError(fehler) + return [c for zeile in zeilen if (c := _control_aus_zeile(zeile))] + + +def _uebersicht(controls: list[Control], nur_aktionen: bool) -> str: + """Kompakte Aufzählung für eine Tool-Beschreibung.""" + passend = [c for c in controls if (c.art == "aktion") == nur_aktionen] + if not nur_aktionen: + passend = [c for c in passend if c.schreibbar] + return "\n".join(f" {c.name} ({c.raum}) — {c.text}" for c in passend) + + +def _startkatalog() -> list[Control]: + """Holt den Katalog einmal beim Start, für die Tool-Beschreibungen. + + Läuft die Anwendung gerade nicht, bleibt die Beschreibung allgemein — der + Agent bekommt die richtige Liste dann über `musik_list_controls`. + """ + try: + return asyncio.run(katalog()) + except Exception as fehler: # noqa: BLE001 — beim Start ist alles verzeihlich + print(f"musik_mcp: Katalog nicht abrufbar ({fehler})", file=sys.stderr) + return [] + + +KATALOG = _startkatalog() + +_HINWEIS_OHNE = ( + "\n\nDie Anwendung lief beim Start dieses Servers nicht, deshalb steht hier " + "keine Liste. `musik_list_controls` liefert sie zur Laufzeit." +) + +BESCHREIBUNG_SET = ( + "Setzt einen Wert im Mischpult — Fader, EQ, Filter, Tempo, Hot Cue.\n\n" + "Werte außerhalb des erlaubten Bereichs werden begrenzt, nicht abgelehnt. " + "Zurück kommt der Wert, der wirklich angekommen ist.\n\n" + + ( + "Schreibbare Controls (Bereich in Klammern):\n" + _uebersicht(KATALOG, False) + if KATALOG + else _HINWEIS_OHNE.strip() + ) +) + +BESCHREIBUNG_DO = ( + "Löst eine Aktion aus — laden, syncen, einen Hot Cue anspringen, " + "mitschneiden.\n\n" + "Aktionen haben keinen Zustand; zum Setzen von Werten ist `musik_set` " + "zuständig.\n\n" + + ( + "Verfügbare Aktionen (erwartetes Argument in Klammern):\n" + + _uebersicht(KATALOG, True) + if KATALOG + else _HINWEIS_OHNE.strip() + ) +) + + +# -------------------------------------------------------------------------- +# Eingaben +# -------------------------------------------------------------------------- + + +class Format(str, Enum): + MARKDOWN = "markdown" + JSON = "json" + + +class Basis(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True, extra="forbid") + + +class ControlEingabe(Basis): + control: str = Field( + ..., + description="Name des Controls, etwa 'deck1.tempo' oder 'channel2.fader'", + min_length=3, + max_length=64, + ) + + @field_validator("control") + @classmethod + def _einzeilig(cls, v: str) -> str: + return _pruefe_einzeilig(v, "control") + + +class SetzenEingabe(ControlEingabe): + wert: str = Field( + ..., + description=( + "Neuer Wert. Zahlen als Dezimalzahl ('0.8'), Schalter als '0'/'1', " + "Auswahlen beim Namen ('delay'), '-' leert einen Hot Cue" + ), + min_length=1, + max_length=256, + ) + normiert: bool = Field( + default=False, + description=( + "Wert als 0..1 auffassen und in den echten Bereich dehnen — " + "gedacht für Regler, die ihren Bereich nicht kennen" + ), + ) + + @field_validator("wert") + @classmethod + def _wert_einzeilig(cls, v: str) -> str: + return _pruefe_einzeilig(v, "wert") + + +class AktionEingabe(Basis): + aktion: str = Field( + ..., + description="Name der Aktion, etwa 'deck2.sync' oder 'deck1.load'", + min_length=3, + max_length=64, + ) + argument: Optional[str] = Field( + default=None, + description="Argument der Aktion, etwa ein Dateipfad oder eine Zahl", + max_length=4096, + ) + + @field_validator("aktion", "argument") + @classmethod + def _einzeilig(cls, v: Optional[str]) -> Optional[str]: + return None if v is None else _pruefe_einzeilig(v, "argument") + + +class ListenEingabe(Basis): + praefix: str = Field( + default="", + description="Nur Controls, deren Name so anfängt, etwa 'deck1.'", + max_length=64, + ) + limit: int = Field(default=50, description="Höchstzahl der Treffer", ge=1, le=500) + offset: int = Field(default=0, description="Wie viele übersprungen werden", ge=0) + response_format: Format = Field(default=Format.MARKDOWN, description="Ausgabeform") + + @field_validator("praefix") + @classmethod + def _einzeilig(cls, v: str) -> str: + return _pruefe_einzeilig(v, "praefix") + + +class SucheEingabe(Basis): + text: str = Field( + default="", + description="Freitext über Titel, Künstler und Album; leer listet alles", + max_length=256, + ) + mischbar_zu_bpm: Optional[float] = Field( + default=None, + description=( + "Statt Freitext nach Tempo suchen: Tracks, die zu diesem Wert " + "passen (±6 %). Schlägt den Freitext." + ), + gt=0, + le=400, + ) + harmonisch_zu: Optional[str] = Field( + default=None, + description=( + "Statt Freitext nach Tonart suchen: Tracks, deren Tonart harmonisch " + "passt. Nimmt 'Am', 'F#' oder Camelot '8A'. Schlägt den Freitext, " + "wird aber selbst von mischbar_zu_bpm geschlagen." + ), + max_length=8, + ) + limit: int = Field(default=25, description="Höchstzahl der Treffer", ge=1, le=200) + response_format: Format = Field(default=Format.MARKDOWN, description="Ausgabeform") + + @field_validator("text") + @classmethod + def _einzeilig(cls, v: str) -> str: + return _pruefe_einzeilig(v, "text") + + @field_validator("harmonisch_zu") + @classmethod + def _tonart_einzeilig(cls, v: Optional[str]) -> Optional[str]: + return None if v is None else _pruefe_einzeilig(v, "harmonisch_zu") + + +class StatusEingabe(Basis): + response_format: Format = Field(default=Format.MARKDOWN, description="Ausgabeform") + + +def _zahl_als_text(wert: float) -> str: + """Zahl fürs Protokoll — ohne Exponentialschreibweise. + + `str(1e-05)` wäre '1e-05', und das Pult liest es zwar, aber in einer + Protokollzeile sieht es aus wie ein Tippfehler. + """ + return f"{wert:.6f}".rstrip("0").rstrip(".") or "0" + + +class DeckName(str, Enum): + """Die Decks, die es gibt. + + Von Hand aufgezählt und nicht aus dem Katalog erzeugt: Lief die Anwendung + beim Start nicht, wäre die Auswahl leer und das Schema kaputt. Kommt ein + drittes Deck dazu, gehört es hierher — das Protokoll selbst nimmt jedes + `deckN` an. + """ + + DECK1 = "deck1" + DECK2 = "deck2" + + +class Form(str, Enum): + """Wie sich eine Bewegung über ihre Beats verteilt.""" + + linear = "linear" + weich = "weich" + spaet = "spaet" + frueh = "frueh" + + +class RampeEingabe(Basis): + control: str = Field( + ..., + description="Regler, der wandern soll, etwa 'channel1.eq_low'", + min_length=3, + max_length=64, + ) + ziel: float = Field(..., description="Wert am Ende der Bewegung") + ueber_beats: float = Field( + ..., + description="Länge der Bewegung in Beats. 0 setzt sofort.", + ge=0, + le=4096, + ) + in_beats: Optional[float] = Field( + default=None, + description="Erst so viele Beats warten, dann losfahren", + ge=0, + le=4096, + ) + ab_phrase: bool = Field( + default=False, + description=( + "Auf der nächsten Phrasengrenze losfahren statt sofort. in_beats " + "zählt dann ab der Grenze. **Das ist fast immer das Richtige** — " + "ein Übergang beginnt auf der Eins, nicht nach einer runden Zahl." + ), + ) + form: Form = Field( + default=Form.linear, + description=( + "Wie sich die Bewegung über die Beats verteilt — dieselbe Strecke, " + "andere Wirkung. 'linear' gleichmäßig (unauffällig, für Bass-Swaps); " + "'weich' als S-Kurve (langsam los, durchziehen, weich ankommen — für " + "lange Blenden); 'spaet' lange fast nichts, dann schnell (hält den " + "ausgehenden Track präsent); 'frueh' sofort viel, dann auslaufen " + "(macht den Wechsel zum Ereignis)." + ), + ) + taktgeber_deck: Optional[DeckName] = Field( + default=None, + description=( + "Wessen Beats gezählt werden. Ohne Angabe erbt ein Kanalzug den " + "Takt von seinem Deck, die Summe nimmt das erste Deck mit Grid." + ), + ) + + @field_validator("control") + @classmethod + def _einzeilig(cls, v: str) -> str: + return _pruefe_einzeilig(v, "control") + + +class GriffName(str, Enum): + """Die benannten Übergänge.""" + + blende = "blende" + bassswap = "bassswap" + schnitt = "schnitt" + filter = "filter" + schleife = "schleife" + + +class UebergangEingabe(Basis): + griff: GriffName = Field( + ..., + description=( + "Welcher Übergang. 'blende' lange Blende über den Crossfader, weich " + "verteilt (32 Beats); 'bassswap' beide laufen in der Mitte, dann " + "tauschen die Bässe (16); 'schnitt' auf der Eins umschalten, ohne " + "Übergang; 'filter' dem Ausgehenden den Boden wegziehen, während der " + "Neue kommt (16); 'schleife' den Ausgehenden in eine Schleife legen " + "und darüber wechseln (16) — der einzige Griff, der ihm Zeit gibt, " + "wenn sein Track gleich zu Ende ist." + ), + ) + beats: Optional[float] = Field( + default=None, + description="Länge in Beats. Ohne Angabe die übliche des Griffs.", + ge=0, + le=4096, + ) + + +class VormerkEingabe(Basis): + beats: float = Field( + ..., + description="In wie vielen Beats der Befehl ausgeführt wird", + ge=0, + le=4096, + ) + ab_phrase: bool = Field( + default=False, + description=( + "beats ab der nächsten Phrasengrenze zählen statt ab jetzt. " + "**Für alles Musikalische das Richtige** — was auf der Eins sitzen " + "soll, gehört auf die Phrase und nicht auf eine runde Zahl." + ), + ) + aktion: Optional[str] = Field( + default=None, + description="Aktion, die dann ausgelöst wird, etwa 'deck2.sync'", + max_length=64, + ) + argument: Optional[str] = Field( + default=None, + description="Argument der Aktion, etwa ein Dateipfad", + max_length=4096, + ) + control: Optional[str] = Field( + default=None, + description="Statt einer Aktion: Control, das dann gesetzt wird", + max_length=64, + ) + wert: Optional[str] = Field( + default=None, + description="Wert für das Control; Pflicht, wenn control gesetzt ist", + max_length=256, + ) + + @field_validator("aktion", "argument", "control", "wert") + @classmethod + def _einzeilig(cls, v: Optional[str]) -> Optional[str]: + return None if v is None else _pruefe_einzeilig(v, "Argument") + + @model_validator(mode="after") + def _genau_eines(self) -> VormerkEingabe: + if bool(self.aktion) == bool(self.control): + raise ValueError("entweder aktion oder control angeben, nicht beides") + if self.control and not self.wert: + raise ValueError("control braucht einen wert") + return self + + def befehl(self) -> str: + if self.aktion: + return f"do {self.aktion}" + (f" {self.argument}" if self.argument else "") + return f"set {self.control} {self.wert}" + + +class StreichEingabe(Basis): + plan_id: Optional[int] = Field( + default=None, + description="Nummer aus `musik_status` — der eine Auftrag, der weg soll", + ge=1, + ) + alle: bool = Field( + default=False, + description=( + "Alles streichen, auch was andere vorgemerkt haben. Bewusst ein " + "eigener Schalter, damit ein vergessenes plan_id nicht den ganzen " + "Plan leert." + ), + ) + + @model_validator(mode="after") + def _eines_von_beiden(self) -> StreichEingabe: + if (self.plan_id is None) == (not self.alle): + raise ValueError("entweder plan_id nennen oder alle=true setzen") + return self + + +class Richtung(str, Enum): + UNTER = "unter" + UEBER = "ueber" + + +class BedingungEingabe(Basis): + control: str = Field( + ..., + description="Wert, auf den gewartet wird, etwa 'deck1.beats_left'", + min_length=3, + max_length=64, + ) + richtung: Richtung = Field( + ..., + description="'unter' feuert, sobald der Wert die Schwelle unterschreitet", + ) + schwelle: float = Field(..., description="Die Schwelle") + aktion: Optional[str] = Field( + default=None, + description="Aktion, die dann ausgelöst wird, etwa 'master.queue_next'", + max_length=64, + ) + argument: Optional[str] = Field( + default=None, description="Argument der Aktion", max_length=4096 + ) + control_setzen: Optional[str] = Field( + default=None, + description="Statt einer Aktion: Control, das dann gesetzt wird", + max_length=64, + ) + wert: Optional[str] = Field( + default=None, + description="Wert dafür; Pflicht, wenn control_setzen gesetzt ist", + max_length=256, + ) + + @field_validator("control", "aktion", "argument", "control_setzen", "wert") + @classmethod + def _einzeilig(cls, v: Optional[str]) -> Optional[str]: + return None if v is None else _pruefe_einzeilig(v, "Argument") + + @model_validator(mode="after") + def _genau_eines(self) -> BedingungEingabe: + if bool(self.aktion) == bool(self.control_setzen): + raise ValueError("entweder aktion oder control_setzen angeben, nicht beides") + if self.control_setzen and not self.wert: + raise ValueError("control_setzen braucht einen wert") + return self + + def befehl(self) -> str: + if self.aktion: + return f"do {self.aktion}" + (f" {self.argument}" if self.argument else "") + return f"set {self.control_setzen} {self.wert}" + + +class SignalEingabe(Basis): + name: str = Field( + ..., + description=( + "Wofür das Signal steht, etwa 'Energie auf der Flaeche'. Derselbe " + "Name landet immer auf demselben Platz." + ), + min_length=1, + max_length=64, + ) + wert: float = Field( + ..., + description="Der Messwert, -1 bis 1. 0 ist neutral, nicht 'nichts'.", + ge=-1, + le=1, + ) + + @field_validator("name") + @classmethod + def _einzeilig(cls, v: str) -> str: + return _pruefe_einzeilig(v, "name") + + +class ListeEingabe(Basis): + limit: int = Field(default=50, description="Höchstzahl der Einträge", ge=1, le=200) + response_format: Format = Field(default=Format.MARKDOWN, description="Ausgabeform") + + +class VormerkenEingabe(Basis): + pfad: str = Field( + ..., + description="Dateipfad des Tracks, so wie ihn `musik_search` liefert", + min_length=1, + max_length=4096, + ) + notiz: str = Field( + default="", + description=( + "Warum er dort steht — 'passt harmonisch zu 8A', 'mehr Druck nach " + "dem Break'. Der Nächste, der die Liste liest, muss den Grund sonst " + "erraten." + ), + max_length=512, + ) + als_naechstes: bool = Field( + default=False, + description="Direkt an den Anfang statt hinten anhängen", + ) + + @field_validator("pfad", "notiz") + @classmethod + def _einzeilig(cls, v: str) -> str: + return _pruefe_einzeilig(v, "Argument") + + +class NaechstesEingabe(Basis): + limit: int = Field(default=10, description="Höchstzahl der Vorschläge", ge=1, le=200) + response_format: Format = Field(default=Format.MARKDOWN, description="Ausgabeform") + + +class AuflegenEingabe(Basis): + deck: Optional[DeckName] = Field( + default=None, + description=( + "Auf welches Deck. Ohne Angabe auf eines, das gerade nicht läuft; " + "laufen alle, kommt ein Fehler statt eines abgerissenen Mixes." + ), + ) + + +# -------------------------------------------------------------------------- +# Werkzeuge +# -------------------------------------------------------------------------- + +NUR_LESEN = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, +} + + +@mcp.tool( + name="musik_status", + annotations={"title": "Momentaufnahme der Anlage", **NUR_LESEN}, +) +async def musik_status(params: StatusEingabe) -> str: + """Was gerade läuft: beide Decks, die Kanalzüge, die Summe — und der Plan. + + Der erste Griff, bevor man etwas verändert. Eine Momentaufnahme statt eines + Dutzends einzelner Abfragen. + + **Der Plan steht mit drin, weil selten nur einer bedient.** Wer sieht, dass + schon eine Blende auf `channel1.fader` läuft, greift nicht mitten hinein — + und wenn doch, bricht die Blende ab (die Anlage bevorzugt den letzten + Griff, nicht den Plan). + + Args: + params (StatusEingabe): response_format — 'markdown' oder 'json'. + + Returns: + str: Markdown-Übersicht oder JSON mit dieser Form: + { + "decks": [{"deck": "deck1", "title": str, "artist": str, + "bpm": float|null, "key": str|null, "key_camelot": str|null, + "position": float, "duration": float, + "beat": float|null, "beats_left": float|null, + "beats_to_phrase": float|null, "phrase_beats": float|null, + "section": str, "section_beats_left": float|null, + "beats_to_outro": float|null, "intro_beats": float|null, + "entry": float|null, + "playing": bool, "finished": bool, "load_status": str}], + "channels": [{"channel": "channel1", "fader": float, "cue": bool, + "fx": str}], + "master": {"crossfader": float, "gain": float, "recording": bool, + "record_seconds": float, "record_dropped": float}, + "signals": [{"slot": int, "name": str, "value": float|null, + "trend_per_minute": float|null, "age_seconds": float|null}], + "plan": [{"id": int, "art": "ramp"|"in"|"wenn", "text": str}] + } + + Beispiele: + - „Was liegt auf den Decks?" + - „Läuft die Aufnahme noch?" + - „Wie lange habe ich noch?" → `beats_left`, in Grid-Beats gezählt. + - „Wo im Track bin ich?" → `section` sagt intro, aufbau, drop, break, + outro oder teil; `beats_to_outro` sagt, wann der Übergang darf. + Leer heißt: keine Gliederung erkannt, nicht „keine Struktur". + - „Hat jemand schon etwas vorgemerkt?" → das Feld `plan`. + - Nicht dafür: einen einzelnen Wert lesen → `musik_get`. + """ + try: + return await _status(params.response_format) + except NichtErreichbar as fehler: + return f"Fehler: {fehler}" + + +def _als_zahl(text: str) -> Optional[float]: + try: + return float(text) + except ValueError: + return None + + +def _als_text(text: str) -> Optional[str]: + """`-` ist die Antwort des Pults für „leer" — die wird nicht weitergereicht. + + Sonst stünde in der Ausgabe ein Bindestrich, wo „unbekannt" gemeint ist, + und der ließe sich nicht von einem echten Wert unterscheiden. + """ + return None if text in ("-", "") else text + + +async def _werte(controls: list[str]) -> dict[str, str]: + """Liest mehrere Controls über eine Verbindung.""" + antworten = await sprich(*(f"get {c}" for c in controls)) + aus: dict[str, str] = {} + for control, zeilen in zip(controls, antworten): + zeile = zeilen[-1] if zeilen else "" + aus[control] = zeile.split(" ", 2)[2] if zeile.startswith("value ") else "-" + return aus + + +def _liste_aus_zeilen(zeilen: list[str]) -> list[dict[str, Any]]: + """`queue 3 /musik/track.mp3 mehr Druck nach dem Break` + + Pfad und Notiz werden an der Dateiendung getrennt — dieselbe Regel wie bei + den Suchtreffern, damit beides Leerzeichen enthalten darf. + """ + aus: list[dict[str, Any]] = [] + for zeile in zeilen: + if not zeile.startswith("queue "): + continue + teile = zeile.split(" ", 2) + if len(teile) < 3 or not teile[1].isdigit(): + continue + pfad, notiz = _pfad_und_titel(teile[2]) + aus.append({"nr": int(teile[1]), "path": pfad, "note": None if notiz == "-" else notiz}) + return aus + + +def _plan_aus_zeilen(zeilen: list[str]) -> list[dict[str, Any]]: + """`plan 1 ramp channel1.fader 0.0000 → 1.0000 über 16 Beats, …`""" + aus: list[dict[str, Any]] = [] + for zeile in zeilen: + if not zeile.startswith("plan "): + continue + teile = zeile.split(" ", 2) + if len(teile) < 3 or not teile[1].isdigit(): + continue + aus.append( + { + "id": int(teile[1]), + "art": teile[2].split(" ", 1)[0], + "text": teile[2], + } + ) + return aus + + +async def _status(form: Format) -> str: + vorhandene = {c.name for c in await katalog()} + decks = sorted({n.split(".")[0] for n in vorhandene if n.startswith("deck")}) + kanaele = sorted({n.split(".")[0] for n in vorhandene if n.startswith("channel")}) + + deck_felder = [ + "title", + "artist", + "bpm", + "key", + "key_camelot", + "position", + "duration", + # Die musikalischen Größen gehören in dieselbe Momentaufnahme wie alles + # andere. Wer sie sich aus Position, Länge und Tempo selbst ausrechnet, + # rechnet sie bei jedem Blick neu — und macht dabei irgendwann einen + # Fehler, den niemand sieht. + "beat", + "beats_left", + "beats_to_phrase", + "phrase_beats", + # Die Gliederung. Ohne sie muss ein Agent aus Pegel und Bauchgefühl + # raten, wo im Track er ist — und legt den Übergang mitten in den Drop. + "section", + "section_beats_left", + # Einzelspuren (P6): Ohne sie lässt sich „zwei Stimmen gleichzeitig" + # nur vermeiden, indem man gar nicht überlagert. + "stems", + "stem1_name", + "stem1_level", + "stem2_name", + "stem2_level", + "stem3_name", + "stem3_level", + "stem4_name", + "stem4_level", + "beats_to_outro", + "intro_beats", + "entry", + "play", + "finished", + "load_status", + ] + kanal_felder = ["fader", "cue", "fx"] + signal_felder = [ + f"master.signal{i}{f}" + for i in range(1, SIGNALE + 1) + for f in ("_name", "", "_trend", "_age") + ] + master_felder = [ + "crossfader", + "gain", + "recording", + "record_seconds", + "record_dropped", + # Was geschehen ist, ohne dass jemand danach gefragt hat: eine + # abgelöste Rampe, ein gestrichener Auftrag. Wer über MCP arbeitet, + # kann nicht abonnieren — die Brücke öffnet je Aufruf eine neue + # Verbindung. Deshalb die letzte Zeile **und** der Zähler: Springt er + # um mehr als eins, sind Zeilen dazwischen verlorengegangen, und das + # muss sichtbar sein statt still. + "events", + "event_count", + # Der Bogen: was das Set vorhat. Ohne ihn ist „welcher Track als + # Nächstes" eine Geschmacksfrage, mit ihm eine mit Antwort. + "arc_minutes", + "arc_target", + "arc_actual", + "arc_gap", + "arc_trend", + # Zurückhaltung: ob immer dasselbe gefahren wird. Fünf Griffe im + # Repertoire machen Abwechslung möglich, nicht selbstverständlich. + "repeats", + "transitions", + # Der Raum: was draußen geschieht, verschiebt das Ziel. Ohne + # `arc_curve` daneben wäre nicht zu sehen, wieviel davon der Raum ist. + "arc_curve", + "room", + "room_bend", + "why", + ] + + gefragt = ( + [f"{d}.{f}" for d in decks for f in deck_felder] + + [f"{k}.{f}" for k in kanaele for f in kanal_felder] + + [f"master.{f}" for f in master_felder] + + signal_felder + ) + roh = await _werte(gefragt) + zeitplan, liste = await sprich("plan", "do master.queue") + plan = _plan_aus_zeilen(zeitplan) + warteschlange = _liste_aus_zeilen(liste) + + daten: dict[str, Any] = { + "decks": [ + { + "deck": d, + "title": roh.get(f"{d}.title", ""), + "artist": roh.get(f"{d}.artist", ""), + "bpm": _als_zahl(roh.get(f"{d}.bpm", "-")), + "key": _als_text(roh.get(f"{d}.key", "-")), + "key_camelot": _als_text(roh.get(f"{d}.key_camelot", "-")), + "position": _als_zahl(roh.get(f"{d}.position", "-")) or 0.0, + "duration": _als_zahl(roh.get(f"{d}.duration", "-")) or 0.0, + "beat": _als_zahl(roh.get(f"{d}.beat", "-")), + "beats_left": _als_zahl(roh.get(f"{d}.beats_left", "-")), + "beats_to_phrase": _als_zahl(roh.get(f"{d}.beats_to_phrase", "-")), + "phrase_beats": _als_zahl(roh.get(f"{d}.phrase_beats", "-")), + "section": _als_text(roh.get(f"{d}.section", "-")), + "section_beats_left": _als_zahl(roh.get(f"{d}.section_beats_left", "-")), + "beats_to_outro": _als_zahl(roh.get(f"{d}.beats_to_outro", "-")), + "intro_beats": _als_zahl(roh.get(f"{d}.intro_beats", "-")), + "entry": _als_zahl(roh.get(f"{d}.entry", "-")), + "playing": roh.get(f"{d}.play") == "1", + "finished": roh.get(f"{d}.finished") == "1", + "load_status": roh.get(f"{d}.load_status", ""), + # Nur die belegten Plätze: Vier leere Zeilen zu melden hieße, + # vier Dinge zu zeigen, die es nicht gibt. + "stems": [ + { + "slot": n, + "name": _als_text(roh.get(f"{d}.stem{n}_name", "-")), + "level": _als_zahl(roh.get(f"{d}.stem{n}_level", "-")), + } + for n in range(1, 5) + if _als_text(roh.get(f"{d}.stem{n}_name", "-")) + ], + } + for d in decks + ], + "channels": [ + { + "channel": k, + "fader": _als_zahl(roh.get(f"{k}.fader", "-")) or 0.0, + "cue": roh.get(f"{k}.cue") == "1", + "fx": roh.get(f"{k}.fx", "off"), + } + for k in kanaele + ], + "master": { + "crossfader": _als_zahl(roh.get("master.crossfader", "-")) or 0.0, + "gain": _als_zahl(roh.get("master.gain", "-")) or 0.0, + "recording": roh.get("master.recording") == "1", + "record_seconds": _als_zahl(roh.get("master.record_seconds", "-")) or 0.0, + "record_dropped": _als_zahl(roh.get("master.record_dropped", "-")) or 0.0, + "arc_minutes": _als_zahl(roh.get("master.arc_minutes", "-")), + "arc_target": _als_zahl(roh.get("master.arc_target", "-")), + "arc_actual": _als_zahl(roh.get("master.arc_actual", "-")), + "arc_gap": _als_zahl(roh.get("master.arc_gap", "-")), + "arc_trend": _als_text(roh.get("master.arc_trend", "-")), + "arc_curve": _als_zahl(roh.get("master.arc_curve", "-")), + "room": _als_text(roh.get("master.room", "-")), + "room_bend": _als_zahl(roh.get("master.room_bend", "-")), + "why": _als_text(roh.get("master.why", "-")), + "repeats": int(_als_zahl(roh.get("master.repeats", "-")) or 0), + "transitions": _als_text(roh.get("master.transitions", "-")), + "last_event": _als_text(roh.get("master.events", "-")), + "event_count": int(_als_zahl(roh.get("master.event_count", "-")) or 0), + }, + # Nur die benutzten Plätze: Vier leere Zeilen zu melden hieße, dem + # Leser vier Dinge zu zeigen, über die niemand etwas gesagt hat. + "signals": [ + { + "slot": i, + "name": roh.get(f"master.signal{i}_name", ""), + "value": _als_zahl(roh.get(f"master.signal{i}", "-")), + "trend_per_minute": _als_zahl(roh.get(f"master.signal{i}_trend", "-")), + "age_seconds": _als_zahl(roh.get(f"master.signal{i}_age", "-")), + } + for i in range(1, SIGNALE + 1) + if _als_text(roh.get(f"master.signal{i}_name", "-")) + ], + "plan": plan, + "queue": { + "count": len(warteschlange), + "next": warteschlange[0] if warteschlange else None, + }, + } + + if form is Format.JSON: + return json.dumps(daten, indent=2, ensure_ascii=False) + + zeilen = ["# Musik"] + for d in daten["decks"]: + tempo = f"{d['bpm']:.2f} BPM" if d["bpm"] else "kein Beatgrid" + lauf = "läuft" if d["playing"] else "steht" + zeilen.append( + f"\n## {d['deck'].upper()} — {d['title'] or 'nichts geladen'}" + + (f" ({d['artist']})" if d["artist"] else "") + ) + zeilen.append( + f"- {tempo}, {lauf} bei {d['position']:.1f} s von {d['duration']:.1f} s" + ) + if d["beats_left"] is not None: + zeilen.append( + f"- noch **{d['beats_left']:.0f} Beats**, " + f"{d['beats_to_phrase']:.0f} bis zur Phrasengrenze" + ) + if d["section"]: + teil = f"- Abschnitt **{d['section']}**" + if d["section_beats_left"] is not None: + teil += f", noch {d['section_beats_left']:.0f} Beats" + if d["beats_to_outro"] is not None: + bis = d["beats_to_outro"] + teil += ( + f" · Outro läuft seit {-bis:.0f} Beats" + if bis < 0 + else f" · Outro in {bis:.0f} Beats" + ) + zeilen.append(teil) + if d["key"]: + zeilen.append(f"- Tonart {d['key']} ({d['key_camelot']})") + if d["stems"]: + teile = ", ".join( + f"{s['name']} {s['level']:.2f}" + for s in d["stems"] + if s["level"] is not None + ) + zeilen.append( + f"- Einzelspuren: {teile}\n" + f" `musik_set('{d['deck']}.stem1_level', 0)` nimmt eine heraus" + ) + if d["finished"]: + zeilen.append("- **durchgelaufen**") + if d["load_status"] not in ("bereit", ""): + zeilen.append(f"- Laden: {d['load_status']}") + + m = daten["master"] + if m["arc_gap"] is not None: + richtung = {"steigt": "steigt", "haelt": "hält", "faellt": "fällt"}.get( + m["arc_trend"] or "", m["arc_trend"] or "" + ) + fehlt = ( + f"**{m['arc_gap']:+.2f}** — " + + ("mehr Energie gesucht" if m["arc_gap"] > 0.05 else + "weniger gesucht" if m["arc_gap"] < -0.05 else "passt") + ) + raum = "" + if m["room_bend"] is not None and m["arc_curve"] is not None: + raum = ( + f"\n\nDer Raum ({m['room']}) beugt das Ziel um " + f"**{m['room_bend']:+.2f}** — die Kurve selbst sagt " + f"{m['arc_curve']:.2f}." + ) + zeilen.append( + f"\n## Bogen\nBei {m['arc_minutes']:.0f} min: soll " + f"{m['arc_target']:.2f}, ist {m['arc_actual']:.2f}, Lücke {fehlt}" + + (f" · {richtung} in den nächsten Minuten" if richtung else "") + + raum + ) + if m["why"]: + zeilen.append(f"\n**Warum:** {m['why']}") + + if m["transitions"]: + zeile = f"\n## Übergänge\n{m['transitions']}" + if m["repeats"] >= 3: + zeile += ( + f"\n\n**{m['repeats']}× hintereinander derselbe Griff.** " + "Fünf stehen im Repertoire: blende, bassswap, schnitt, " + "filter, schleife." + ) + zeilen.append(zeile) + + if daten["master"]["last_event"]: + zeilen.append( + f"\n**Zuletzt im Plan:** {daten['master']['last_event']} " + f"(Ereignis {daten['master']['event_count']})" + ) + zeilen.append( + "Springt die Nummer beim nächsten Blick um mehr als eins, sind " + "dazwischen Ereignisse liegengeblieben." + ) + + zeilen.append("\n## Mischpult") + for k in daten["channels"]: + extra = f", FX {k['fx']}" if k["fx"] not in ("off", "-") else "" + cue = ", Kopfhörer" if k["cue"] else "" + zeilen.append(f"- {k['channel']}: Fader {k['fader']:.2f}{extra}{cue}") + + m = daten["master"] + zeilen.append( + f"- Crossfader {m['crossfader']:+.2f}, Summe {m['gain']:.2f}" + ) + if m["recording"]: + warnung = ( + f" — **{m['record_dropped']:.0f} Frames fehlen**" + if m["record_dropped"] + else "" + ) + zeilen.append(f"- Mitschnitt läuft: {m['record_seconds']:.1f} s{warnung}") + + if daten["signals"]: + zeilen.append("\n## Aus dem Raum") + for s in daten["signals"]: + wert = "—" if s["value"] is None else f"{s['value']:+.2f}" + trend = ( + "" + if s["trend_per_minute"] is None + else f", {s['trend_per_minute']:+.2f}/min" + ) + # Das Alter gehört dazu: Ein Wert von vor zwanzig Minuten ist keine + # Lüge, aber auch keine Auskunft über jetzt. + alt = ( + f" (vor {s['age_seconds']:.0f} s)" + if s["age_seconds"] is not None and s["age_seconds"] > 120 + else "" + ) + zeilen.append(f"- **{s['name']}** {wert}{trend}{alt}") + + if plan: + zeilen.append("\n## Vorgemerkt") + zeilen += [f"- **{a['id']}** {a['text']}" for a in plan] + + if warteschlange: + naechster = warteschlange[0] + zeilen.append( + f"\n## Liste ({len(warteschlange)})\n" + f"- als Nächstes **{naechster['nr']}** `{naechster['path']}`" + + (f" — {naechster['note']}" if naechster["note"] else "") + ) + + return "\n".join(zeilen) + + +@mcp.tool( + name="musik_list_controls", + annotations={"title": "Steuerraum aufzählen", **NUR_LESEN}, +) +async def musik_list_controls(params: ListenEingabe) -> str: + """Zählt auf, was sich bedienen lässt — mit Bereich, Einheit und Bedeutung. + + Der Steuerraum beschreibt sich selbst: Jedes Control nennt seinen Typ, den + erlaubten Wertebereich, die Einheit, ob es schreibbar ist und was es tut. + Wer das gelesen hat, braucht kein Handbuch. + + Args: + params (ListenEingabe): + - praefix (str): nur Namen, die so anfangen, etwa 'deck1.' + - limit (int): 1–500, Standard 50 + - offset (int): zum Blättern + - response_format: 'markdown' oder 'json' + + Returns: + str: Markdown-Tabelle oder JSON: + {"total": int, "count": int, "offset": int, "has_more": bool, + "next_offset": int|null, + "controls": [{"name": str, "art": str, "raum": str, "einheit": str, + "schreibbar": bool, "text": str}]} + + Beispiele: + - „Was kann Deck 1?" → praefix='deck1.' + - „Welche Effekte gibt es?" → praefix='channel1.fx' + - Nicht dafür: aktuelle Werte lesen → `musik_get` oder `musik_status`. + """ + try: + alle = await katalog(params.praefix) + except NichtErreichbar as fehler: + return f"Fehler: {fehler}" + except RuntimeError as fehler: + return f"Fehler: {fehler}" + + ausschnitt = alle[params.offset : params.offset + params.limit] + weiter = params.offset + len(ausschnitt) < len(alle) + + if params.response_format is Format.JSON: + return json.dumps( + { + "total": len(alle), + "count": len(ausschnitt), + "offset": params.offset, + "has_more": weiter, + "next_offset": params.offset + len(ausschnitt) if weiter else None, + "controls": [c.model_dump() for c in ausschnitt], + }, + indent=2, + ensure_ascii=False, + ) + + if not ausschnitt: + return f"Keine Controls mit dem Anfang '{params.praefix}'." + + zeilen = [ + f"# Steuerraum ({len(ausschnitt)} von {len(alle)})", + "", + "| Control | Typ | Bereich | Einheit | | Bedeutung |", + "| --- | --- | --- | --- | --- | --- |", + ] + zeilen += [ + f"| `{c.name}` | {c.art} | {c.raum} | {c.einheit} | " + f"{'rw' if c.schreibbar else 'r'} | {c.text} |" + for c in ausschnitt + ] + if weiter: + zeilen.append(f"\nWeitere ab offset={params.offset + len(ausschnitt)}.") + return "\n".join(zeilen) + + +@mcp.tool( + name="musik_get", + annotations={"title": "Einen Wert lesen", **NUR_LESEN}, +) +async def musik_get(params: ControlEingabe) -> str: + """Liest einen einzelnen Wert. + + Args: + params (ControlEingabe): control (str) — etwa 'deck1.bpm'. + + Returns: + str: Der Wert als Text. '-' heißt, dass es ihn gerade nicht gibt — + ein ungesetzter Hot Cue, ein Deck ohne Beatgrid. Bei einem Fehler + 'Fehler: '. + + Beispiele: + - „Wie schnell läuft Deck 2?" → control='deck2.bpm' + - Nicht dafür: viele Werte auf einmal → `musik_status`. + """ + try: + zeilen = await eine_antwort(f"get {params.control}") + except NichtErreichbar as fehler: + return f"Fehler: {fehler}" + + if fehler := _fehlerzeile(zeilen): + return f"Fehler: {fehler}. `musik_list_controls` zeigt, was es gibt." + zeile = zeilen[-1] if zeilen else "" + return zeile.split(" ", 2)[2] if zeile.startswith("value ") else "-" + + +@mcp.tool( + name="musik_next", + annotations={"title": "Was als Nächstes passt", **NUR_LESEN}, +) +async def musik_next(params: NaechstesEingabe) -> str: + """Was als Nächstes passt — nach Tempo, Tonart und dem, was das Set will. + + Anders als `musik_search` ist das nicht eine Suche, sondern eine + **Reihenfolge mit Begründung**: sortiert nach dem Abstand zur Energie, die + Bogen und Raum gerade anstreben, und jede Zeile sagt, warum sie dort steht. + + Braucht ein laufendes Deck und einen laufenden Bogen (`musik_set('master.arc', + ...)` und `musik_do('master.arc_start')`). Ohne die kommt ein Fehler statt + einer Liste, die nach nichts sortiert ist. + + **Ausgewählt wird nicht.** Die Anlage sortiert; welcher Track es wird, + entscheidet, wer es begründen kann. + + Args: + params (NaechstesEingabe): + - limit (int): 1–200, Standard 10 + - response_format: 'markdown' oder 'json' + + Returns: + str: Markdown oder JSON: + {"reason": str, "count": int, "truncated": bool, + "tracks": [{"bpm": float|null, "key": str|null, "path": str, + "title": str, "why": str}]} + + Beispiele: + - „Was soll ich als Nächstes auflegen?" → musik_next() + - Nicht dafür: einen bestimmten Track finden → `musik_search`. + """ + try: + zeilen = await eine_antwort("do master.search_next") + except NichtErreichbar as fehler: + return f"Fehler: {fehler}" + if fehler := _fehlerzeile(zeilen): + return f"Fehler: {fehler}" + + grund = "" + treffer: list[dict[str, Any]] = [] + begrenzt = False + for zeile in zeilen: + if zeile.startswith("weil "): + grund = zeile[5:] + continue + if zeile.startswith("hinweis "): + begrenzt = True + continue + if not zeile.startswith("track "): + continue + teile = zeile.split(" ", 3) + if len(teile) < 4: + continue + # `track — `. Erst den Grund + # abtrennen, sonst landet er im Titel. + rest, _, warum = teile[3].partition(" — ") + pfad, titel = _pfad_und_titel(rest) + treffer.append( + { + "bpm": _als_zahl(teile[1]), + "key": None if teile[2] == "-" else teile[2], + "path": pfad, + "title": titel, + "why": warum, + } + ) + + treffer = treffer[: params.limit] + if params.response_format is Format.JSON: + return json.dumps( + { + "reason": grund, + "count": len(treffer), + "truncated": begrenzt, + "tracks": treffer, + }, + indent=2, + ensure_ascii=False, + ) + + zeilen_aus = [f"# Als Nächstes\n\n{grund}" if grund else "# Als Nächstes", ""] + if not treffer: + zeilen_aus.append("Nichts in der Sammlung, das dazu passt.") + return "\n".join(zeilen_aus) + for t in treffer: + tempo = f"{t['bpm']:.2f} BPM" if t["bpm"] else "kein Tempo" + key = f", {t['key']}" if t["key"] else "" + zeilen_aus.append( + f"- **{t['title']}** — {tempo}{key}\n {t['why']}\n `{t['path']}`" + ) + if begrenzt: + zeilen_aus.append("\nDie Liste ist begrenzt; es gibt mehr.") + return "\n".join(zeilen_aus) + + +@mcp.tool( + name="musik_search", + annotations={"title": "Sammlung durchsuchen", **NUR_LESEN}, +) +async def musik_search(params: SucheEingabe) -> str: + """Sucht Tracks in der Sammlung — nach Text, Tempo oder Tonart. + + Args: + params (SucheEingabe): + - text (str): Freitext über Titel, Künstler, Album + - mischbar_zu_bpm (float|None): stattdessen nach Tempo suchen (±6 %) + - harmonisch_zu (str|None): stattdessen nach Tonart suchen + - limit (int): 1–200, Standard 25 + - response_format: 'markdown' oder 'json' + + Returns: + str: Markdown-Liste oder JSON: + {"count": int, "truncated": bool, + "tracks": [{"bpm": float|null, "key": str|null, "path": str, + "title": str}]} + `key` ist die Camelot-Zahl (8A, 5B); `path` ist es, was `musik_do` mit + der Aktion 'deckN.load' braucht. + + Beispiele: + - „Was habe ich von Alpenglühen?" → text='Alpen' + - „Was passt zu 128 BPM?" → mischbar_zu_bpm=128 + - „Was passt harmonisch zu Deck 1?" → erst `musik_get('deck1.key')`, + dann harmonisch_zu mit dem Ergebnis. + - Nicht dafür: einen Track auflegen → `musik_do('deck1.load', pfad)`. + """ + if params.mischbar_zu_bpm is not None: + befehl = f"do master.search_mixable {params.mischbar_zu_bpm}" + elif params.harmonisch_zu: + befehl = f"do master.search_harmonic {params.harmonisch_zu}" + else: + befehl = f"do master.search {params.text}".rstrip() + + try: + zeilen = await eine_antwort(befehl) + except NichtErreichbar as fehler: + return f"Fehler: {fehler}" + if fehler := _fehlerzeile(zeilen): + return f"Fehler: {fehler}" + + treffer: list[dict[str, Any]] = [] + begrenzt = False + for zeile in zeilen: + if zeile.startswith("hinweis "): + begrenzt = True + continue + if not zeile.startswith("track "): + continue + teile = zeile.split(" ", 3) + if len(teile) < 4: + continue + # `track ` — der Pfad kann Leerzeichen + # enthalten, der Titel auch. Getrennt wird deshalb hinten am Titel, der + # immer der Dateiname ohne Endung oder der Tag ist. + pfad, titel = _pfad_und_titel(teile[3]) + treffer.append( + { + "bpm": _als_zahl(teile[1]), + "key": None if teile[2] == "-" else teile[2], + "path": pfad, + "title": titel, + } + ) + + treffer = treffer[: params.limit] + if params.response_format is Format.JSON: + return json.dumps( + {"count": len(treffer), "truncated": begrenzt, "tracks": treffer}, + indent=2, + ensure_ascii=False, + ) + + if not treffer: + return "Keine Treffer." + zeilen_aus = [f"# {len(treffer)} Treffer", ""] + for t in treffer: + tempo = f"{t['bpm']:.2f} BPM" if t["bpm"] else "kein Tempo" + key = f", {t['key']}" if t["key"] else "" + zeilen_aus.append(f"- **{t['title']}** — {tempo}{key}\n `{t['path']}`") + if begrenzt: + zeilen_aus.append("\nDie Liste ist begrenzt; es gibt mehr.") + return "\n".join(zeilen_aus) + + +def _pfad_und_titel(rest: str) -> tuple[str, str]: + """Trennt ` ` — beide dürfen Leerzeichen enthalten. + + Der Titel ist der Dateiname ohne Endung, wenn es keine Tags gibt. Also + wird von hinten gesucht: Der Pfad endet an der Endung. + """ + for endung in (".wav", ".mp3", ".flac", ".m4a", ".ogg", ".aiff", ".aif"): + stelle = rest.lower().find(endung + " ") + if stelle != -1: + schnitt = stelle + len(endung) + return rest[:schnitt], rest[schnitt:].strip() + pfad, _, titel = rest.partition(" ") + return pfad, titel + + +@mcp.tool( + name="musik_set", + description=BESCHREIBUNG_SET, + annotations={ + "title": "Einen Wert setzen", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, +) +async def musik_set(params: SetzenEingabe) -> str: + """Setzt einen Wert. Ausführliche Beschreibung siehe `description`. + + Args: + params (SetzenEingabe): control, wert, normiert. + + Returns: + str: 'ok ' mit dem tatsächlich angekommenen Wert, oder + 'Fehler: '. + """ + verb = "setn" if params.normiert else "set" + befehl = f"{verb} {params.control} {params.wert}" + if abgewiesen := _torwaechter_control(params.control, befehl): + return abgewiesen + await _notiz_wenn_neu() + try: + zeilen = await eine_antwort(befehl) + except NichtErreichbar as fehler: + return f"Fehler: {fehler}" + + if fehler := _fehlerzeile(zeilen): + return f"Fehler: {fehler}. `musik_list_controls` zeigt, was es gibt." + return "\n".join(zeilen) + + +@mcp.tool( + name="musik_do", + description=BESCHREIBUNG_DO, + annotations={ + "title": "Eine Aktion auslösen", + "readOnlyHint": False, + # `load` tauscht den Track eines Decks, `record` schreibt eine Datei — + # beides überschreibt etwas, das vorher da war. + "destructiveHint": True, + "idempotentHint": False, + "openWorldHint": False, + }, +) +async def musik_do(params: AktionEingabe) -> str: + """Löst eine Aktion aus. Ausführliche Beschreibung siehe `description`. + + Args: + params (AktionEingabe): aktion, argument. + + Returns: + str: Die Antwortzeilen des Programms. `sync` nennt Tempo und + Phasenfehler, `load` meldet Annahme (nicht Erledigung — der Fortschritt + steht in 'deckN.load_status'), `search` gibt Treffer aus. + Bei einem Fehler 'Fehler: '. + """ + befehl = f"do {params.aktion}" + if params.argument: + befehl += f" {params.argument}" + + if abgewiesen := _torwaechter_control(params.aktion, befehl): + return abgewiesen + await _notiz_wenn_neu() + try: + zeilen = await eine_antwort(befehl) + except NichtErreichbar as fehler: + return f"Fehler: {fehler}" + + if fehler := _fehlerzeile(zeilen): + return f"Fehler: {fehler}. `musik_list_controls` zeigt die Aktionen." + return "\n".join(zeilen) + + +# -------------------------------------------------------------------------- +# Zeit +# -------------------------------------------------------------------------- +# +# Warum das nicht der Agent selbst macht: Ein Übergang ist eine Bewegung über +# Takte, keine Folge von Reglerstellungen. Wer ihn von außen nachbaut, müsste +# `musik_set` in einer engen Schleife rufen und dazwischen schlafen — über eine +# Werkzeugschnittstelle, deren Timing bei jedem Aufruf eine Modellantwort weit +# entfernt ist. Das eiert hörbar, und der Agent kann in der Zeit nichts anderes +# tun. Hier sagt er einmal, was passieren soll, und ist wieder frei. + +SCHREIBT = { + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": False, +} + + +@mcp.tool( + name="musik_ramp", + annotations={"title": "Einen Regler über Beats bewegen", **SCHREIBT}, +) +async def musik_ramp(params: RampeEingabe) -> str: + """Fährt einen Regler über mehrere Beats auf einen Wert — eine Blende. + + Gerechnet wird in **Beats, nicht in Sekunden**: Dreht jemand am Tempo, + bleibt die Blende musikalisch richtig. Steht das Deck, wartet sie. + + Der Aufruf kommt sofort zurück; die Bewegung läuft in der Anwendung weiter. + Wie weit sie ist, steht im Feld `plan` von `musik_status`. + + **Sie gibt auf, sobald jemand anders denselben Regler anfasst** — ein + Mensch an der Oberfläche wie ein zweiter Agent. Wer also mitten in eine + fremde Blende `musik_set` ruft, gewinnt und beendet sie damit. + + Args: + params (RampeEingabe): + - control (str): der Regler, etwa 'channel1.eq_low'. Muss eine Zahl + sein — ein Schalter lässt sich nicht blenden. + - ziel (float): der Wert am Ende + - ueber_beats (float): Länge der Bewegung + - in_beats (float|None): erst so lange warten + - taktgeber_deck ('deck1'|'deck2'|None): wessen Beats gezählt werden + + Returns: + str: 'ok plan …' mit der Nummer, unter der der Auftrag im Plan + steht — die braucht `musik_cancel`. Sonst 'Fehler: '. + + Beispiele: + - „Blende den Bass von Deck 1 über 8 Beats raus" → + control='channel1.eq_low', ziel=0, ueber_beats=8 + - „Auf der nächsten Eins den Crossfader über 32 rüberziehen" → + control='master.crossfader', ziel=1, ueber_beats=32, ab_phrase=true + - „Nach 16 Beats den Crossfader über 32 rüberziehen" → + control='master.crossfader', ziel=1, ueber_beats=32, in_beats=16 + - „Lange Blende, die sich anfühlt wie eine Hand" → + control='master.crossfader', ziel=1, ueber_beats=32, ab_phrase=true, + form='weich' + - Nicht dafür: einen Wert sofort setzen → `musik_set`. + + **Die Form ist der Unterschied zwischen Handwerk und Automat.** Vier + gleich lange, gleich gerade Blenden hintereinander klingen nach Maschine, + auch wenn jede einzelne sauber ist. + """ + befehl = ( + f"ramp {params.control} {_zahl_als_text(params.ziel)} " + f"{_zahl_als_text(params.ueber_beats)}" + ) + if params.taktgeber_deck: + befehl += f" {params.taktgeber_deck.value}" + if params.form is not Form.linear: + befehl += f" {params.form.value}" + if params.ab_phrase: + versatz = params.in_beats or 0 + bezug = "phrase" if versatz == 0 else f"phrase+{_zahl_als_text(versatz)}" + befehl = f"in {bezug} {befehl}" + elif params.in_beats is not None: + befehl = f"in {_zahl_als_text(params.in_beats)} {befehl}" + + if abgewiesen := _torwaechter("zeit", befehl): + return abgewiesen + await _notiz_wenn_neu() + try: + zeilen = await eine_antwort(befehl) + except NichtErreichbar as fehler: + return f"Fehler: {fehler}" + + if fehler := _fehlerzeile(zeilen): + return f"Fehler: {fehler}" + return "\n".join(zeilen) + + +@mcp.tool( + name="musik_schedule", + annotations={"title": "Etwas auf einen Beat legen", **SCHREIBT}, +) +async def musik_schedule(params: VormerkEingabe) -> str: + """Merkt eine Aktion oder einen Wert für einen späteren Beat vor. + + Für alles, was auf den Takt gehört statt auf die Uhr: einen zweiten Track + auf der 33 starten, nach 64 Beats den Kanal aufziehen, am Ende der Phrase + syncen. + + Args: + params (VormerkEingabe): + - beats (float): in wie vielen Beats + - **entweder** aktion (str) + argument (str|None) + - **oder** control (str) + wert (str) + + Returns: + str: 'ok plan …' mit der Nummer für `musik_cancel`, sonst + 'Fehler: '. Was der Befehl dann geantwortet hat, sieht ein + Abonnent auf dem Socket; über MCP zeigt es sich am Zustand. + + Beispiele: + - „Starte Deck 2 in 16 Beats" → beats=16, control='deck2.play', wert='1' + - „Sync in einer Phrase" → beats=32, aktion='deck2.sync' + - Nicht dafür: eine Blende → `musik_ramp` (auch verzögert, mit + in_beats). + """ + if abgewiesen := _torwaechter("zeit", f"in {params.beats} Beats: {params.befehl()}"): + return abgewiesen + await _notiz_wenn_neu() + try: + bezug = _zahl_als_text(params.beats) + if params.ab_phrase: + bezug = "phrase" if params.beats == 0 else f"phrase+{bezug}" + zeilen = await eine_antwort(f"in {bezug} {params.befehl()}") + except NichtErreichbar as fehler: + return f"Fehler: {fehler}" + + if fehler := _fehlerzeile(zeilen): + return f"Fehler: {fehler}. `musik_list_controls` zeigt, was es gibt." + return "\n".join(zeilen) + + +@mcp.tool( + name="musik_uebergang", + annotations={ + "title": "Einen benannten Übergang vormerken", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": False, + }, +) +async def musik_uebergang(params: UebergangEingabe) -> str: + """Merkt einen Übergang aus dem Repertoire vor. + + Fünf Griffe statt einer immer gleichen Blende. Ein DJ, der jedes Mal + dasselbe macht, ist der langweiligste im Raum — auch wenn jeder einzelne + Übergang sauber ist. `musik_status` sagt unter „Übergänge", wie oft + zuletzt derselbe kam. + + **Die Anlage wählt nicht aus.** Welcher Griff passt, hängt daran, ob der + ausgehende Track ein Outro hat (`beats_to_outro`), der eingehende ein + langes Intro (`intro_beats`), wie groß der Energieunterschied ist — und + vor allem daran, was vorher schon dreimal gefahren wurde. Diese + Entscheidung gehört dir, samt Begründung; leg sie als Notiz an den + Warteschlangen-Eintrag. + + Es geschieht nichts, was du nicht auch selbst tippen könntest: Die Antwort + nennt jede vorgemerkte Zeile. Alles läuft durch denselben Weg wie sonst, + steht also im Plan, in der Mitschrift und bei den Ereignissen, und + `musik_cancel` nimmt es zurück. + + Voraussetzung: **genau ein** Deck läuft, auf dem anderen liegt etwas. + Sonst wäre nicht zu erraten, wer aus- und wer eingeht — dann die Zeilen + von Hand setzen. + + Args: + params (UebergangEingabe): griff (str), beats (float|None). + + Returns: + str: die vorgemerkten Zeilen mit ihren Antworten, oder 'Fehler: …'. + + Beispiele: + - „Blende rüber" → griff='blende' + - „Bass-Swap über 32" → griff='bassswap', beats=32 + - „Auf der Eins hart umschalten" → griff='schnitt' + - „Der Track ist gleich zu Ende, ich brauche Zeit" → griff='schleife' + """ + befehl = f"do master.uebergang {params.griff.value}" + if params.beats is not None: + befehl += f" {_zahl_als_text(params.beats)}" + if abgewiesen := _torwaechter("zeit", befehl): + return abgewiesen + await _notiz_wenn_neu() + try: + zeilen = await eine_antwort(befehl) + except NichtErreichbar as fehler: + return f"Fehler: {fehler}" + + if fehler := _fehlerzeile(zeilen): + return f"Fehler: {fehler}" + return "\n".join(zeilen) + + +@mcp.tool( + name="musik_cancel", + annotations={ + "title": "Vorgemerktes zurücknehmen", + "readOnlyHint": False, + # Was gestrichen ist, ist weg — und kann von jemand anderem stammen. + "destructiveHint": True, + "idempotentHint": True, + "openWorldHint": False, + }, +) +async def musik_cancel(params: StreichEingabe) -> str: + """Nimmt einen vorgemerkten Auftrag zurück. + + Die Nummern stehen im Feld `plan` von `musik_status`. **Der Plan ist + gemeinsam** — was dort steht, kann ein anderer Agent oder der Mensch an der + Oberfläche vorgemerkt haben. Deshalb streicht dieses Werkzeug ohne + ausdrückliches `alle=true` nur den einen genannten Auftrag. + + Eine laufende Blende bleibt stehen, wo sie gerade ist; sie fährt nicht + zurück. Wer den Ausgangswert zurückhaben will, setzt ihn selbst. + + Args: + params (StreichEingabe): plan_id (int|None), alle (bool). + + Returns: + str: 'ok gestrichen' oder 'Fehler: '. + + Beispiele: + - „Nimm die Blende zurück" → erst `musik_status`, dann plan_id= + - „Alles abbrechen" → alle=true + """ + befehl = "cancel" if params.alle else f"cancel {params.plan_id}" + if abgewiesen := _torwaechter("zeit", befehl): + return abgewiesen + await _notiz_wenn_neu() + try: + zeilen = await eine_antwort(befehl) + except NichtErreichbar as fehler: + return f"Fehler: {fehler}" + + if fehler := _fehlerzeile(zeilen): + return f"Fehler: {fehler}" + return "\n".join(zeilen) + + +@mcp.tool( + name="musik_signal", + annotations={ + "title": "Etwas von außen melden", + "readOnlyHint": False, + "destructiveHint": False, + # Zwei Meldungen desselben Namens sind zwei Messpunkte, keine + # Wiederholung — daraus entsteht ja gerade der Trend. + "idempotentHint": False, + "openWorldHint": False, + }, +) +async def musik_signal(params: SignalEingabe) -> str: + """Meldet einen Wert aus dem Raum — Energie, Andrang, Stimmung. + + Ein DJ liest die Fläche. Ein Agent kann das nicht sehen, also muss es + jemand hereingeben: ein Mikrofonpegel, eine Umfrage, ein Mensch im Chat. + Ab dann ist es ein Control wie jedes andere und lässt sich mit `musik_when` + zur Bedingung machen. + + **Ein einzelner Wert nützt wenig.** „Energie 0,7" beantwortet keine Frage; + „0,7 und seit zwei Minuten fallend" beantwortet sie. Deshalb wird jede + Meldung als Messpunkt aufbewahrt und der Trend daraus gerechnet — melde + also **regelmäßig**, nicht nur bei Änderungen. + + Es gibt vier Plätze. Derselbe Name landet immer auf demselben; sind alle + vier mit anderen Namen belegt, kommt ein Fehler statt einer Überschreibung. + + Args: + params (SignalEingabe): name (str), wert (float, -1 bis 1). + + Returns: + str: 'ok master.signalN ' oder 'Fehler: '. + + Beispiele: + - „Die Fläche füllt sich" → name='Andrang', wert=0.6 + - „Die Energie kippt" → name='Energie', wert=-0.3 + - Auswerten: `musik_status` zeigt Wert, Trend und Alter je Signal; + `musik_when('master.signal1', 'unter', -0.2, …)` reagiert darauf. + """ + if abgewiesen := _torwaechter("dramaturgie", f"set master.signal <{params.name}> {params.wert}"): + return abgewiesen + await _notiz_wenn_neu() + namen = [f"master.signal{i}_name" for i in range(1, SIGNALE + 1)] + try: + belegt = await _werte(namen) + except NichtErreichbar as fehler: + return f"Fehler: {fehler}" + + platz = next( + (i for i in range(1, SIGNALE + 1) if belegt.get(namen[i - 1]) == params.name), + None, + ) + if platz is None: + platz = next( + ( + i + for i in range(1, SIGNALE + 1) + if _als_text(belegt.get(namen[i - 1], "-")) is None + ), + None, + ) + if platz is None: + vergeben = ", ".join( + f"{i}: {belegt.get(namen[i - 1])}" for i in range(1, SIGNALE + 1) + ) + return ( + f"Fehler: alle {SIGNALE} Plätze sind belegt ({vergeben}). " + "Einen freimachen mit musik_set auf signalN_name = '-'." + ) + # Erst der Name, dann der Wert: Ein Wert auf einem namenlosen Platz + # sagt niemandem, wovon er handelt. + antworten = await sprich( + f"set master.signal{platz}_name {params.name}", + f"set master.signal{platz} {_zahl_als_text(params.wert)}", + ) + else: + antworten = await sprich( + f"set master.signal{platz} {_zahl_als_text(params.wert)}" + ) + + for antwort in antworten: + if fehler := _fehlerzeile(antwort): + return f"Fehler: {fehler}" + return "\n".join(antworten[-1]) + + +@mcp.tool( + name="musik_when", + annotations={"title": "Auf einen Zustand warten", **SCHREIBT}, +) +async def musik_when(params: BedingungEingabe) -> str: + """Merkt einen Befehl für den Moment vor, in dem ein Wert eine Schwelle reißt. + + Das Gegenstück zu `musik_schedule`: Der eine wartet auf Takte, dieser auf + einen Zustand. „Wenn Deck A noch 32 Beats hat, leg den nächsten auf" ist die + Frage, die beim Auflegen wirklich gestellt wird. + + **Warum das nicht der Agent selbst macht.** Er müsste `deck1.beats_left` in + kurzen Abständen abfragen und die Schwelle selbst heraussuchen — je Abfrage + ein Werkzeugaufruf, also eine Modellantwort weit entfernt. Hier sagt er es + einmal und ist wieder frei. + + Der Aufruf kommt sofort zurück. Trifft die Bedingung schon jetzt zu, läuft + der Befehl unmittelbar: `when` heißt „sobald es so weit ist", nicht „beim + nächsten Überschreiten". + + Args: + params (BedingungEingabe): + - control (str): der Wert, etwa 'deck1.beats_left'. Muss eine Zahl + sein — ein Schalter ließe sich mit keiner Schwelle vergleichen. + - richtung ('unter'|'ueber'), schwelle (float) + - **entweder** aktion (str) + argument (str|None) + - **oder** control_setzen (str) + wert (str) + + Returns: + str: 'ok plan …' mit der Nummer für `musik_cancel`, sonst + 'Fehler: '. + + Beispiele: + - „Wenn Deck A fast durch ist, leg den nächsten auf" → + control='deck1.beats_left', richtung='unter', schwelle=32, + aktion='master.queue_next' + - „Bei 16 Beats Rest Deck B starten" → control='deck1.beats_left', + richtung='unter', schwelle=16, control_setzen='deck2.play', wert='1' + - Nicht dafür: nach einer festen Zahl Beats → `musik_schedule`. + """ + zeichen = "<" if params.richtung is Richtung.UNTER else ">" + befehl = ( + f"when {params.control} {zeichen} " + f"{_zahl_als_text(params.schwelle)} {params.befehl()}" + ) + + if abgewiesen := _torwaechter("zeit", befehl): + return abgewiesen + await _notiz_wenn_neu() + try: + zeilen = await eine_antwort(befehl) + except NichtErreichbar as fehler: + return f"Fehler: {fehler}" + + if fehler := _fehlerzeile(zeilen): + return f"Fehler: {fehler}. `musik_list_controls` zeigt, was es gibt." + return "\n".join(zeilen) + + +# -------------------------------------------------------------------------- +# Was als Nächstes kommt +# -------------------------------------------------------------------------- +# +# Nur drei Werkzeuge, obwohl es sieben Aktionen sind: `musik_do` erreicht den +# Rest ohne eine Zeile hier — `master.queue_drop`, `queue_bump`, `queue_note`, +# `queue_clear` nehmen genau ein Argument und stehen mit ihrer Erklärung schon +# in der erzeugten Beschreibung. Ein eigenes Werkzeug bekommt nur, wo sonst die +# Zusammensetzung oder das Auseinandernehmen beim Agenten läge. + + +@mcp.tool( + name="musik_queue", + annotations={"title": "Was als Nächstes kommt", **NUR_LESEN}, +) +async def musik_queue(params: ListeEingabe) -> str: + """Zeigt die Liste der vorgemerkten Tracks, in ihrer Reihenfolge. + + **Die Liste ist gemeinsam.** Wer auswählt, schreibt hier hinein; wer + auflegt, nimmt hier heraus. Zwei Agenten, die je ihre eigene Liste im Kopf + führen, legen irgendwann beide auf dasselbe Deck. + + Jeder Eintrag trägt eine Notiz — warum er dort steht. Das ist der + Unterschied zu einer Playlist, und für den Nächsten, der liest, der ganze + Punkt. + + Args: + params (ListeEingabe): limit (1–200), response_format. + + Returns: + str: Markdown-Liste oder JSON: + {"count": int, "entries": [{"nr": int, "path": str, "note": str|null}]} + `nr` spricht einen Eintrag an — für `musik_do('master.queue_drop', …)`, + `queue_bump` und `queue_note`. + + Beispiele: + - „Was kommt als Nächstes?" + - „Warum steht der da?" → das Feld `note`. + - Nicht dafür: den nächsten auflegen → `musik_queue_next`. + """ + try: + zeilen = await eine_antwort("do master.queue") + except NichtErreichbar as fehler: + return f"Fehler: {fehler}" + if fehler := _fehlerzeile(zeilen): + return f"Fehler: {fehler}" + + eintraege = _liste_aus_zeilen(zeilen)[: params.limit] + if params.response_format is Format.JSON: + return json.dumps( + {"count": len(eintraege), "entries": eintraege}, indent=2, ensure_ascii=False + ) + + if not eintraege: + return "Die Liste ist leer." + aus = [f"# {len(eintraege)} vorgemerkt", ""] + for e in eintraege: + notiz = f" — {e['note']}" if e["note"] else "" + aus.append(f"{e['nr']}. `{e['path']}`{notiz}") + return "\n".join(aus) + + +@mcp.tool( + name="musik_queue_add", + annotations={"title": "Einen Track vormerken", **SCHREIBT}, +) +async def musik_queue_add(params: VormerkenEingabe) -> str: + """Merkt einen Track für später vor, mit dem Grund dazu. + + **Derselbe Pfad wird nicht zweimal angenommen** — die Antwort nennt dann + die Nummer, unter der er schon steht. Das ist der häufigste Zusammenstoß, + wenn zwei unabhängig voneinander auswählen: Beide suchen, was zu 128 BPM in + 8A passt, und finden denselben Track. + + Args: + params (VormerkenEingabe): + - pfad (str): wie ihn `musik_search` liefert + - notiz (str): warum + - als_naechstes (bool): an den Anfang statt hinten anhängen + + Returns: + str: 'queue angehaengt ' oder 'Fehler: '. + + Beispiele: + - „Merk den für nachher vor" → pfad=…, notiz='ruhiger, nach dem Peak' + - „Der soll als Nächstes" → als_naechstes=true + """ + if abgewiesen := _torwaechter("datei", f"do master.queue_add {params.pfad}"): + return abgewiesen + await _notiz_wenn_neu() + try: + zeilen = await eine_antwort(f"do master.queue_add {params.pfad}") + except NichtErreichbar as fehler: + return f"Fehler: {fehler}" + if fehler := _fehlerzeile(zeilen): + return f"Fehler: {fehler}" + + nummer = next( + ( + teile[1] + for zeile in zeilen + if zeile.startswith("queue ") and len((teile := zeile.split(" ", 2))) > 1 + ), + None, + ) + if nummer is None: + return "\n".join(zeilen) + + # Notiz und Vorziehen sind eigene Befehle auf der Leitung. Der Agent soll + # sie nicht einzeln senden müssen — der Grund gehört zum Vormerken. + nachtrag = [] + if params.notiz: + nachtrag.append(f"do master.queue_note {nummer} {params.notiz}") + if params.als_naechstes: + nachtrag.append(f"do master.queue_bump {nummer}") + if nachtrag: + for antwort in await sprich(*nachtrag): + if fehler := _fehlerzeile(antwort): + return f"queue {nummer} angelegt, aber: {fehler}" + + return "\n".join(zeilen) + + +@mcp.tool( + name="musik_queue_next", + annotations={ + "title": "Den nächsten auflegen", + "readOnlyHint": False, + # Tauscht den Track eines Decks und nimmt den Eintrag aus der Liste. + "destructiveHint": True, + "idempotentHint": False, + "openWorldHint": False, + }, +) +async def musik_queue_next(params: AuflegenEingabe) -> str: + """Nimmt den vordersten Eintrag aus der Liste und legt ihn auf. + + Ohne Deckangabe auf eines, das gerade nicht läuft. **Laufen alle, kommt ein + Fehler statt einer Vermutung** — ein Track über einen laufenden gelegt reißt + den Mix ab, und das ist keine Entscheidung, die eine Vorgabe treffen darf. + + Scheitert das Laden, bleibt der Eintrag in der Liste stehen. + + Args: + params (AuflegenEingabe): deck ('deck1'|'deck2'|None). + + Returns: + str: Die Antwortzeilen — Annahme des Ladeauftrags (nicht Erledigung; + der Fortschritt steht in 'deckN.load_status'), welcher Eintrag + abgenommen wurde, und seine Notiz. Sonst 'Fehler: '. + + Beispiele: + - „Leg den nächsten auf" + - „Leg den nächsten auf Deck 2" → deck='deck2' + """ + befehl = "do master.queue_next" + if params.deck: + befehl += f" {params.deck.value}" + + if abgewiesen := _torwaechter("spielen", befehl): + return abgewiesen + await _notiz_wenn_neu() + try: + zeilen = await eine_antwort(befehl) + except NichtErreichbar as fehler: + return f"Fehler: {fehler}" + if fehler := _fehlerzeile(zeilen): + return f"Fehler: {fehler}" + return "\n".join(zeilen) + + +def main() -> None: + """Einstiegspunkt für `musik-mcp`, siehe `pyproject.toml`. + + Ein benannter Einstieg und nicht nur `__main__`: So lässt sich das Paket + installieren und aufrufen, ohne den Pfad zur Datei zu kennen — das ist die + Form, die `mcp/docker/` in VibeMind hat. + """ + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/mcp/musik/tests/test_freigabe.py b/mcp/musik/tests/test_freigabe.py new file mode 100644 index 0000000..1ad3017 --- /dev/null +++ b/mcp/musik/tests/test_freigabe.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Die zwölf Wächter aus `docs/FREIGABE.md`. + +Ohne sie wäre die Spezifikation eine Absichtserklärung. Sie brauchen keine +laufende Anwendung — geprüft wird das Gatter, nicht die Anlage. + + python -m pytest mcp/test_freigabe.py +""" + +from __future__ import annotations + +import datetime as _dt +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from musik import freigabe # noqa: E402 + +SCHREIBEND = ("mischen", "spielen", "zeit", "datei", "dramaturgie") + + +@pytest.fixture(autouse=True) +def ohne_umgebung(monkeypatch): + """Jeder Test fängt ohne Freigabe an.""" + monkeypatch.delenv("MUSIK_FREIGABE_DATEI", raising=False) + + +def schreibe(tmp_path, monkeypatch, inhalt: str) -> Path: + pfad = tmp_path / "musik.freigabe" + pfad.write_text(inhalt, encoding="utf-8") + monkeypatch.setenv("MUSIK_FREIGABE_DATEI", str(pfad)) + return pfad + + +def spaeter(stunden: float) -> str: + wann = _dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(hours=stunden) + return wann.isoformat() + + +# ── Fail-closed ────────────────────────────────────────────────────────── + + +def test_1_ohne_umgebungsvariable_wird_alles_schreibende_abgewiesen(): + for klasse in SCHREIBEND: + erlaubt, grund = freigabe.pruefen(klasse, "set channel1.fader 0.8") + assert not erlaubt, f"{klasse} lief ohne Freigabe" + assert "MUSIK_FREIGABE_DATEI" in grund + + +def test_2_fehlende_datei_wird_abgewiesen(tmp_path, monkeypatch): + monkeypatch.setenv("MUSIK_FREIGABE_DATEI", str(tmp_path / "gibtsnicht")) + erlaubt, grund = freigabe.pruefen("mischen", "set channel1.fader 0.8") + assert not erlaubt + assert "nicht lesbar" in grund + + +def test_3_abgelaufen_wird_abgewiesen_und_sagt_es(tmp_path, monkeypatch): + schreibe(tmp_path, monkeypatch, f"klassen mischen\nbis {spaeter(-1)}\n") + erlaubt, grund = freigabe.pruefen("mischen", "set channel1.fader 0.8") + assert not erlaubt + assert "abgelaufen" in grund + + +def test_4_ohne_zeitzone_wird_nicht_als_ortszeit_geraten(tmp_path, monkeypatch): + """Der Prozess läuft womöglich woanders als der Mensch, der die Datei + geschrieben hat. Zwei Stunden mehr Gültigkeit als gedacht ist genau das, + was nicht passieren soll.""" + schreibe(tmp_path, monkeypatch, "klassen mischen\nbis 2099-01-01T00:00:00\n") + erlaubt, grund = freigabe.pruefen("mischen", "set channel1.fader 0.8") + assert not erlaubt + assert "Zeitzone" in grund + + +def test_5_zu_weit_voraus_wird_abgewiesen(tmp_path, monkeypatch): + schreibe(tmp_path, monkeypatch, f"klassen mischen\nbis {spaeter(13)}\n") + erlaubt, grund = freigabe.pruefen("mischen", "set channel1.fader 0.8") + assert not erlaubt + assert "voraus" in grund + + +def test_6_ein_tippfehler_verwirft_die_ganze_datei(tmp_path, monkeypatch): + """`mischn` statt `mischen` — dann gilt auch `zeit` nicht. Sonst liefen + drei von vier Klassen still weiter und niemand merkte den Fehler.""" + schreibe(tmp_path, monkeypatch, f"klassen mischn zeit\nbis {spaeter(2)}\n") + for klasse in ("mischen", "zeit"): + erlaubt, _ = freigabe.pruefen(klasse, "irgendwas") + assert not erlaubt, f"{klasse} lief trotz kaputter Datei" + + +# ── Was durchgeht ──────────────────────────────────────────────────────── + + +def test_7_eine_gueltige_freigabe_gilt_genau_fuer_ihre_klassen(tmp_path, monkeypatch): + schreibe(tmp_path, monkeypatch, f"klassen mischen\nbis {spaeter(2)}\nvon felix\n") + + erlaubt, grund = freigabe.pruefen("mischen", "set channel1.fader 0.8") + assert erlaubt, grund + assert "felix" in grund + + erlaubt, grund = freigabe.pruefen("datei", "do deck1.load /musik/x.wav") + assert not erlaubt + assert "nicht für datei" in grund + + +def test_8_lesen_ist_immer_frei(tmp_path, monkeypatch): + """In allen sechs Fehlerfällen oben — Auskunft ändert nichts.""" + erlaubt, _ = freigabe.pruefen("lesen", "get deck1.play") + assert erlaubt + + schreibe(tmp_path, monkeypatch, "klassen quatsch\nbis kaputt\n") + erlaubt, _ = freigabe.pruefen("lesen", "get deck1.play") + assert erlaubt, "lesen wurde von einer kaputten Datei mitgerissen" + + +def test_9_erzeugen_laeuft_nie_ueber_die_datei(tmp_path, monkeypatch): + """**Der wichtigste Test.** Ohne ihn kommt die Klasse später aus + Bequemlichkeit doch in die Datei — und dann gibt ein Agent unbemerkt Geld + aus.""" + schreibe(tmp_path, monkeypatch, f"klassen erzeugen\nbis {spaeter(1)}\n") + # Schon das Schreiben der Datei ist ungültig … + erlaubt, _ = freigabe.pruefen("mischen", "irgendwas") + assert not erlaubt, "`erzeugen` in der Datei machte sie nicht ungültig" + + # … und selbst mit einer sonst gültigen Datei bleibt erzeugen zu. + schreibe(tmp_path, monkeypatch, f"klassen mischen zeit\nbis {spaeter(1)}\n") + erlaubt, grund = freigabe.pruefen("erzeugen", "erzeuge 128 BPM Am") + assert not erlaubt + assert "nie" in grund + + +# ── Widerruf ───────────────────────────────────────────────────────────── + + +def test_10_datei_loeschen_wirkt_sofort_ohne_neustart(tmp_path, monkeypatch): + """Der Grund, warum die Freigabe in einer Datei steht und nicht in der + Umgebung: Wer mitten im Set widerrufen will, kann nicht neu starten.""" + pfad = schreibe(tmp_path, monkeypatch, f"klassen mischen\nbis {spaeter(2)}\n") + assert freigabe.pruefen("mischen", "set channel1.fader 0.8")[0] + + pfad.unlink() + erlaubt, grund = freigabe.pruefen("mischen", "set channel1.fader 0.8") + assert not erlaubt, "der Widerruf wirkte nicht" + assert "nicht lesbar" in grund + + +# ── Mitschrift ─────────────────────────────────────────────────────────── + + +def test_11_eine_freigabe_ergibt_eine_notiz(tmp_path, monkeypatch): + schreibe(tmp_path, monkeypatch, f"klassen mischen zeit\nbis {spaeter(2)}\nvon felix\n") + f = freigabe.lesen(freigabe.datei_pfad()) + notiz = f.notiz() + assert notiz.startswith("note freigabe ") + assert "mischen" in notiz and "zeit" in notiz and "felix" in notiz + + +def test_12_eine_andere_freigabe_ergibt_eine_andere_notiz(tmp_path, monkeypatch): + schreibe(tmp_path, monkeypatch, f"klassen mischen\nbis {spaeter(2)}\nvon felix\n") + erste = freigabe.lesen(freigabe.datei_pfad()).notiz() + schreibe(tmp_path, monkeypatch, f"klassen mischen\nbis {spaeter(3)}\nvon felix\n") + zweite = freigabe.lesen(freigabe.datei_pfad()).notiz() + assert erste != zweite + + +# ── Die Ablehnung selbst ───────────────────────────────────────────────── + + +def test_die_ablehnung_nennt_den_befehl_und_verraet_sonst_nichts(): + text = freigabe.verweigert("mischen", "set channel1.fader 0.8", "keine gültige Freigabe") + assert "set channel1.fader 0.8" in text + assert "mischen" in text + assert "MUSIK_FREIGABE_DATEI" in text + # Kein Wort über den Zustand der Anlage. + for verraeterisch in ("deck1", "läuft", "Position", "BPM"): + assert verraeterisch not in text + + +# ── Die Zuordnung der Controls ─────────────────────────────────────────── + + +def _controls() -> list[str]: + pfad = Path(__file__).resolve().parents[1] / "controls.txt" + return [ + z.strip() + for z in pfad.read_text(encoding="utf-8").splitlines() + if z.strip() and not z.startswith("#") + ] + + +def test_jedes_control_der_anlage_hat_eine_klasse(): + """Sonst wird es im Betrieb abgewiesen — sicher, aber überraschend.""" + ohne = sorted(n for n in _controls() if freigabe.klasse_fuer_control(n) is None) + assert not ohne, "ohne Klasse: " + " ".join(ohne) + + +def test_ein_unbekanntes_control_wird_abgewiesen_und_sagt_warum(): + """Fail-closed: lieber abweisen als in die bequemste Klasse rutschen.""" + erlaubt, grund = freigabe.pruefen_control("deck1.gibtsnicht", "set deck1.gibtsnicht 1") + assert not erlaubt + assert "unbekanntes Control" in grund + + +def test_die_familien_werden_richtig_einsortiert(): + """cue1..8, stemN_level, signalN — nummerierte Namen, eine Regel.""" + assert freigabe.klasse_fuer_control("deck1.cue3") == "spielen" + assert freigabe.klasse_fuer_control("deck1.stem2_level") == "mischen" + assert freigabe.klasse_fuer_control("deck1.stem2_name") == "lesen" + assert freigabe.klasse_fuer_control("master.signal1") == "dramaturgie" + assert freigabe.klasse_fuer_control("master.signal1_trend") == "lesen" + + +def test_ein_pfad_macht_einen_aufruf_zur_dateisache(): + """Alles, was einen Pfad entgegennimmt, ist `datei` — nicht zeitkritisch, + also auch nicht in der Set-Freigabe.""" + for name in ("deck1.load", "master.record", "master.queue_add"): + assert freigabe.klasse_fuer_control(name) == "datei", name + + +def test_lesende_controls_sind_frei_auch_ohne_freigabe(): + for name in ("deck1.bpm", "deck1.section", "master.arc_gap", "master.transitions"): + erlaubt, _ = freigabe.pruefen_control(name, f"get {name}") + assert erlaubt, name diff --git a/mcp/musik/tests/test_server.py b/mcp/musik/tests/test_server.py new file mode 100644 index 0000000..37707f1 --- /dev/null +++ b/mcp/musik/tests/test_server.py @@ -0,0 +1,699 @@ +#!/usr/bin/env python3 +"""Funktionsprobe für den MCP-Server. + +Spricht den Server im selben Prozess über einen echten MCP-Client an — also +über `tools/list` und `tools/call`, nicht an der Schnittstelle vorbei. Damit +wird geprüft, was ein Agent wirklich sieht. + +Der Test **verändert die laufende Anlage**: Er bewegt Fader, merkt Tracks vor +und legt auf, wenn kein Deck läuft. Nicht auf einer Anlage laufen lassen, an der +gerade jemand arbeitet. + +Braucht eine laufende `musik-app`. Ohne sie meldet sich der Test ab, statt +Grün zu behaupten, wo nichts geprüft wurde: + + cargo run --release -p musik-app -- --socket /tmp/musik.sock + MUSIK_SOCKET=/tmp/musik.sock python mcp/test_musik_mcp.py +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from fastmcp import Client # noqa: E402 + +from musik import server as musik_mcp # noqa: E402 + +ERWARTETE_WERKZEUGE = { + "musik_status", + "musik_list_controls", + "musik_get", + "musik_next", + "musik_search", + "musik_set", + "musik_do", + "musik_ramp", + "musik_schedule", + "musik_uebergang", + "musik_cancel", + "musik_queue", + "musik_queue_add", + "musik_queue_next", + "musik_when", + "musik_signal", +} + + +def text_von(ergebnis) -> str: + return "\n".join(teil.text for teil in ergebnis.content if hasattr(teil, "text")) + + +async def hauptteil() -> int: + if not musik_mcp.KATALOG: + print( + "übersprungen: musik-app läuft nicht.\n" + f" Socket: {musik_mcp.socket_pfad()}\n" + " Starten und MUSIK_SOCKET setzen, dann noch einmal.", + file=sys.stderr, + ) + return 77 + + fehler: list[str] = [] + uebersprungen: list[str] = [] + + def pruefe(bedingung: bool, was: str) -> None: + print(f"{'ok ' if bedingung else 'FEHL'} {was}") + if not bedingung: + fehler.append(was) + + def ausgelassen(was: str, warum: str) -> None: + print(f"-- {was} — {warum}") + uebersprungen.append(f"{was} ({warum})") + + async with Client(musik_mcp.mcp) as client: + werkzeuge = await client.list_tools() + namen = {w.name for w in werkzeuge} + pruefe(namen == ERWARTETE_WERKZEUGE, f"tools/list nennt {len(namen)} Werkzeuge") + + # Der eigentliche Punkt: Die Beschreibung wird aus dem laufenden + # Programm erzeugt, nicht von Hand gepflegt. + setzen = next(w for w in werkzeuge if w.name == "musik_set") + pruefe( + "channel1.fader" in (setzen.description or ""), + "musik_set beschreibt die echten Controls", + ) + pruefe( + "0..1" in (setzen.description or ""), + "musik_set nennt die echten Bereiche", + ) + ausloesen = next(w for w in werkzeuge if w.name == "musik_do") + pruefe( + "deck1.sync" in (ausloesen.description or ""), + "musik_do beschreibt die echten Aktionen", + ) + + status = text_von( + await client.call_tool("musik_status", {"params": {"response_format": "json"}}) + ) + daten = json.loads(status) + pruefe(len(daten["decks"]) >= 2, "musik_status zeigt beide Decks") + pruefe("recording" in daten["master"], "musik_status kennt den Mitschnitt") + pruefe( + isinstance(daten.get("plan"), list), + "musik_status zeigt den gemeinsamen Plan", + ) + pruefe( + isinstance(daten.get("queue", {}).get("count"), int), + "musik_status sagt, wie viel vorgemerkt ist", + ) + # Die Frage beim Auflegen ist nicht „wie viele Sekunden", sondern + # „wie viele Beats" — und die muss dastehen, nicht errechnet werden. + pruefe( + all("beats_left" in d for d in daten["decks"]), + "musik_status nennt die Restbeats", + ) + + liste = text_von( + await client.call_tool( + "musik_list_controls", + {"params": {"praefix": "deck1.", "response_format": "json", "limit": 5}}, + ) + ) + katalog = json.loads(liste) + pruefe(katalog["count"] == 5, "musik_list_controls hält sich an das Limit") + pruefe(katalog["has_more"], "musik_list_controls meldet, dass mehr da ist") + + gesetzt = text_von( + await client.call_tool( + "musik_set", {"params": {"control": "channel1.fader", "wert": "0.42"}} + ) + ) + pruefe("0.42" in gesetzt, f"musik_set bestätigt den Wert ({gesetzt.strip()})") + + gelesen = text_von( + await client.call_tool("musik_get", {"params": {"control": "channel1.fader"}}) + ) + pruefe(gelesen.startswith("0.42"), f"musik_get liest ihn zurück ({gelesen})") + + # Begrenzung: 9 ist zu viel für einen Fader, also kommt 1 zurück. + begrenzt = text_von( + await client.call_tool( + "musik_set", {"params": {"control": "channel1.fader", "wert": "9"}} + ) + ) + pruefe( + begrenzt.strip().endswith("1"), + f"musik_set meldet den begrenzten Wert ({begrenzt.strip()})", + ) + + quatsch = text_von( + await client.call_tool("musik_get", {"params": {"control": "deck1.quatsch"}}) + ) + pruefe( + quatsch.startswith("Fehler:") and "musik_list_controls" in quatsch, + "ein unbekanntes Control führt weiter, statt nur zu scheitern", + ) + + aktion_als_wert = text_von( + await client.call_tool( + "musik_set", {"params": {"control": "deck1.sync", "wert": "1"}} + ) + ) + pruefe( + "Aktion" in aktion_als_wert, + "eine Aktion mit set zu setzen wird erklärt, nicht verschluckt", + ) + + suche = text_von( + await client.call_tool( + "musik_search", + {"params": {"mischbar_zu_bpm": 128, "response_format": "json"}}, + ) + ) + treffer = json.loads(suche) + pruefe(treffer["count"] > 0, f"musik_search findet {treffer['count']} Tracks") + pruefe( + all(t["path"].endswith((".wav", ".mp3", ".flac")) for t in treffer["tracks"]), + "die Pfade sind vollständig, auch mit Leerzeichen", + ) + pruefe( + all("key" in t for t in treffer["tracks"]), + "jeder Treffer trägt eine Tonart-Spalte, auch wenn sie leer ist", + ) + + # Eine Tonart, die keine ist, muss auffallen und darf nicht wie ein + # leeres Ergebnis aussehen. + unsinn = text_von( + await client.call_tool( + "musik_search", {"params": {"harmonisch_zu": "H-Dur"}} + ) + ) + pruefe( + unsinn.startswith("Fehler:"), + "eine unlesbare Tonart wird gemeldet statt leer zu antworten", + ) + + # Zeilenumbrüche dürfen keinen zweiten Befehl einschleusen. + try: + await client.call_tool( + "musik_set", + {"params": {"control": "channel1.fader", "wert": "0.5\nset master.gain 0"}}, + ) + pruefe(False, "ein eingeschleuster Befehl wird abgewiesen") + except Exception: + pruefe(True, "ein eingeschleuster Befehl wird abgewiesen") + + # ------------------------------------------------------------------ + # Zeit: Blenden und vorgemerkte Befehle + # ------------------------------------------------------------------ + + async def plan_jetzt() -> list[dict]: + roh = text_von( + await client.call_tool( + "musik_status", {"params": {"response_format": "json"}} + ) + ) + return json.loads(roh)["plan"] + + # Ein Schalter ist keine Bewegung — das muss auffallen, bevor + # irgendetwas vorgemerkt wird. + kein_regler = text_von( + await client.call_tool( + "musik_ramp", + {"params": {"control": "deck1.play", "ziel": 1, "ueber_beats": 8}}, + ) + ) + pruefe( + kein_regler.startswith("Fehler:"), + "eine Blende auf einem Schalter wird abgewiesen", + ) + + # Ein halber Befehl ist schlimmer als gar keiner: Er sähe aus, als wäre + # etwas vorgemerkt. + for was, eingabe in [ + ("weder Aktion noch Control", {"beats": 8}), + ( + "beides zugleich", + { + "beats": 8, + "aktion": "deck2.sync", + "control": "channel1.fader", + "wert": "1", + }, + ), + ("ein Control ohne Wert", {"beats": 8, "control": "channel1.fader"}), + ]: + try: + await client.call_tool("musik_schedule", {"params": eingabe}) + pruefe(False, f"musik_schedule weist {was} ab") + except Exception: + pruefe(True, f"musik_schedule weist {was} ab") + + try: + await client.call_tool("musik_cancel", {"params": {}}) + pruefe(False, "musik_cancel ohne Ziel streicht nicht einfach alles") + except Exception: + pruefe(True, "musik_cancel ohne Ziel streicht nicht einfach alles") + + vorher = await plan_jetzt() + hat_grid = any(d["bpm"] for d in daten["decks"]) + if not hat_grid: + ausgelassen( + "Blende und Vormerkung", + "kein Deck hat ein Beatgrid, also gibt es keine Beats", + ) + else: + eq_vorher = text_von( + await client.call_tool( + "musik_get", {"params": {"control": "channel1.eq_low"}} + ) + ).strip() + + blende = text_von( + await client.call_tool( + "musik_ramp", + { + "params": { + "control": "channel1.eq_low", + "ziel": 0, + "ueber_beats": 64, + } + }, + ) + ).strip() + pruefe(blende.startswith("ok plan "), f"musik_ramp blendet ({blende})") + + # Dasselbe, aber erst in 32 Beats — die Zusammensetzung, die ein + # Agent wirklich braucht. + spaeter = text_von( + await client.call_tool( + "musik_ramp", + { + "params": { + "control": "channel2.eq_low", + "ziel": 0, + "ueber_beats": 16, + "in_beats": 32, + } + }, + ) + ).strip() + pruefe( + spaeter.startswith("ok plan "), + f"musik_ramp kann auch warten, bevor sie losfährt ({spaeter})", + ) + + vorgemerkt = text_von( + await client.call_tool( + "musik_schedule", + {"params": {"beats": 64, "aktion": "deck2.sync"}}, + ) + ).strip() + pruefe( + vorgemerkt.startswith("ok plan "), + f"musik_schedule legt einen Befehl auf einen Beat ({vorgemerkt})", + ) + + alte_ids = {a["id"] for a in vorher} + neu = [a for a in await plan_jetzt() if a["id"] not in alte_ids] + pruefe(len(neu) == 3, f"alle drei Aufträge stehen im Plan ({len(neu)})") + arten = sorted(a["art"] for a in neu) + pruefe( + arten == ["in", "in", "ramp"], + f"der Plan sagt, welcher Art die Aufträge sind ({arten})", + ) + + weg = text_von( + await client.call_tool( + "musik_cancel", {"params": {"plan_id": neu[0]["id"]}} + ) + ).strip() + pruefe(weg == "ok 1 gestrichen", f"musik_cancel nimmt einen zurück ({weg})") + + if vorher: + # Da steht fremde Arbeit im Plan — die wird nicht mitgestrichen. + ausgelassen( + "musik_cancel mit alle=true", + "es standen schon fremde Aufträge im Plan", + ) + for a in neu[1:]: + await client.call_tool( + "musik_cancel", {"params": {"plan_id": a["id"]}} + ) + else: + alles = text_von( + await client.call_tool("musik_cancel", {"params": {"alle": True}}) + ).strip() + pruefe( + alles == "ok 2 gestrichen", + f"musik_cancel räumt mit alle=true auf ({alles})", + ) + pruefe(not await plan_jetzt(), "danach ist der Plan leer") + + # Die Blende hat den EQ ein Stück bewegt; er kommt zurück. + if eq_vorher not in ("-", ""): + await client.call_tool( + "musik_set", + {"params": {"control": "channel1.eq_low", "wert": eq_vorher}}, + ) + + # ------------------------------------------------------------------ + # Was von außen hereinkommt + # ------------------------------------------------------------------ + + async def signale_jetzt() -> list[dict]: + roh = text_von( + await client.call_tool( + "musik_status", {"params": {"response_format": "json"}} + ) + ) + return json.loads(roh)["signals"] + + vorher_signale = {s["name"] for s in await signale_jetzt()} + marke = "Probe-Energie" + + gemeldet = text_von( + await client.call_tool( + "musik_signal", {"params": {"name": marke, "wert": 0.4}} + ) + ).strip() + pruefe( + gemeldet.startswith("ok master.signal"), + f"musik_signal nimmt eine Meldung an ({gemeldet})", + ) + + meins = next((s for s in await signale_jetzt() if s["name"] == marke), None) + pruefe(meins is not None, "das Signal steht in musik_status") + pruefe( + meins is not None and abs(meins["value"] - 0.4) < 0.001, + f"mit seinem Wert ({meins and meins['value']})", + ) + # Eine Probe ergibt noch keine Richtung — das muss leer bleiben statt + # eine Null zu behaupten. + pruefe( + meins is not None and meins["trend_per_minute"] is None, + f"aus einer Probe wird kein Trend ({meins and meins['trend_per_minute']})", + ) + + # Derselbe Name landet auf demselben Platz, sonst wären vier Meldungen + # vier Signale statt eines mit Verlauf. + await client.call_tool( + "musik_signal", {"params": {"name": marke, "wert": 0.7}} + ) + nachher = [s for s in await signale_jetzt() if s["name"] == marke] + pruefe(len(nachher) == 1, f"derselbe Name bleibt ein Platz ({len(nachher)})") + pruefe( + nachher and nachher[0]["trend_per_minute"] is not None, + "aus zwei Proben wird eine Richtung", + ) + + # Ein Signal ist ein Control wie jedes andere — also auch als Bedingung + # brauchbar, ohne Sonderweg. + platz = nachher[0]["slot"] + bedingung = text_von( + await client.call_tool( + "musik_when", + { + "params": { + "control": f"master.signal{platz}", + "richtung": "unter", + "schwelle": -0.9, + "aktion": "deck2.sync", + } + }, + ) + ).strip() + pruefe( + bedingung.startswith("ok plan "), + f"ein Signal taugt als Bedingung ({bedingung})", + ) + nummer = int(bedingung.split(" ", 3)[2]) + await client.call_tool("musik_cancel", {"params": {"plan_id": nummer}}) + + # ------------------------------------------------------------------ + # Der Raum steuert die Auswahl + # ------------------------------------------------------------------ + + # Ein Raum, der nur eine Zahl bewegt, ist Deko. Geprüft wird deshalb, + # ob er die *Reihenfolge* verschiebt — und ob dabei dasteht, warum. + vorher_raum = text_von( + await client.call_tool("musik_get", {"params": {"control": "master.room"}}) + ).strip() + await client.call_tool( + "musik_set", + {"params": {"control": "master.room", "wert": f"signal{platz} 0.5"}}, + ) + kurve = text_von( + await client.call_tool( + "musik_get", {"params": {"control": "master.arc_curve"}} + ) + ).strip() + beugung = text_von( + await client.call_tool( + "musik_get", {"params": {"control": "master.room_bend"}} + ) + ).strip() + pruefe( + beugung not in ("", "-"), + f"der Raum beugt das Ziel ({beugung}, Kurve {kurve})", + ) + + roh_next = text_von( + await client.call_tool( + "musik_next", + {"params": {"limit": 5, "response_format": "json"}}, + ) + ).strip() + # Wie überall in der Brücke kommt ein Fehler als Satz, nicht als JSON. + vorschlaege = {} if roh_next.startswith("Fehler:") else json.loads(roh_next) + if not vorschlaege.get("tracks"): + ausgelassen("musik_next", roh_next[:80] or "keine Vorschläge") + else: + pruefe( + bool(vorschlaege.get("reason")), + f"musik_next sagt, warum ({vorschlaege.get('reason')})", + ) + pruefe( + all(t["why"] for t in vorschlaege["tracks"]), + "und jede Zeile trägt ihren Grund", + ) + + # Nur zurücknehmen, was hier gesetzt wurde. + await client.call_tool( + "musik_set", + { + "params": { + "control": "master.room", + # `-` und nicht leer: `musik_set` verlangt mindestens ein + # Zeichen, und beides heißt am Pult dasselbe. + "wert": "-" if vorher_raum in ("", "-") else vorher_raum, + } + }, + ) + + # Aufräumen: nur den eigenen Platz, nie fremde. + if marke not in vorher_signale: + await client.call_tool( + "musik_set", + {"params": {"control": f"master.signal{platz}_name", "wert": "-"}}, + ) + + # ------------------------------------------------------------------ + # Auf einen Zustand warten + # ------------------------------------------------------------------ + + # Ein Schalter lässt sich mit keiner Schwelle vergleichen. Ihn + # anzunehmen hieße, einen Auftrag anzulegen, der stumm für immer wartet. + kein_wert = text_von( + await client.call_tool( + "musik_when", + { + "params": { + "control": "deck1.play", + "richtung": "unter", + "schwelle": 1, + "aktion": "deck2.sync", + } + }, + ) + ) + pruefe( + kein_wert.startswith("Fehler:"), + f"eine Bedingung auf einem Schalter wird abgewiesen ({kein_wert.strip()})", + ) + + for was, eingabe in [ + ( + "weder Aktion noch Control", + {"control": "deck1.beats_left", "richtung": "unter", "schwelle": 32}, + ), + ( + "beides zugleich", + { + "control": "deck1.beats_left", + "richtung": "unter", + "schwelle": 32, + "aktion": "deck2.sync", + "control_setzen": "channel1.fader", + "wert": "1", + }, + ), + ]: + try: + await client.call_tool("musik_when", {"params": eingabe}) + pruefe(False, f"musik_when weist {was} ab") + except Exception: + pruefe(True, f"musik_when weist {was} ab") + + if not hat_grid: + ausgelassen("musik_when", "kein Deck hat ein Beatgrid") + else: + bedingung = text_von( + await client.call_tool( + "musik_when", + { + "params": { + "control": "deck1.beats_left", + "richtung": "unter", + "schwelle": 8, + "aktion": "master.queue_next", + } + }, + ) + ).strip() + pruefe( + bedingung.startswith("ok plan "), + f"musik_when merkt eine Bedingung vor ({bedingung})", + ) + + wenn_zeilen = [a for a in await plan_jetzt() if a["art"] == "wenn"] + pruefe( + len(wenn_zeilen) == 1, + f"die Bedingung steht im Plan ({[a['art'] for a in await plan_jetzt()]})", + ) + # Der Ist-Wert gehört dazu: Wer den Plan liest, will wissen, wie + # weit die Schwelle noch weg ist. + pruefe( + "steht bei" in wenn_zeilen[0]["text"], + f"der Plan zeigt den Ist-Wert ({wenn_zeilen[0]['text']})", + ) + await client.call_tool( + "musik_cancel", {"params": {"plan_id": wenn_zeilen[0]["id"]}} + ) + + # ------------------------------------------------------------------ + # Was als Nächstes kommt + # ------------------------------------------------------------------ + + async def liste_jetzt() -> list[dict]: + roh = text_von( + await client.call_tool( + "musik_queue", {"params": {"response_format": "json"}} + ) + ) + return json.loads(roh)["entries"] + + vorher_liste = await liste_jetzt() + pfade = [t["path"] for t in treffer["tracks"][:2]] + + angehaengt = text_von( + await client.call_tool( + "musik_queue_add", + {"params": {"pfad": pfade[0], "notiz": "warm anfangen"}}, + ) + ).strip() + pruefe( + angehaengt.startswith("queue "), + f"musik_queue_add merkt einen Track vor ({angehaengt})", + ) + + # Zwei, die unabhängig auswählen, finden denselben Track. Ihn zweimal + # aufzunehmen hieße, ihn zweimal zu spielen. + doppelt = text_von( + await client.call_tool( + "musik_queue_add", {"params": {"pfad": pfade[0], "notiz": "auch gut"}} + ) + ).strip() + pruefe( + doppelt.startswith("Fehler:") and "Nummer" in doppelt, + f"derselbe Pfad wird nicht zweimal angenommen ({doppelt})", + ) + + eintraege = await liste_jetzt() + meiner = next((e for e in eintraege if e["path"] == pfade[0]), None) + pruefe(meiner is not None, "der Eintrag steht in der Liste") + pruefe( + meiner is not None and meiner["note"] == "warm anfangen", + f"die Notiz kommt mit ({meiner and meiner['note']})", + ) + + if len(pfade) > 1: + await client.call_tool( + "musik_queue_add", + { + "params": { + "pfad": pfade[1], + "notiz": "doch der zuerst", + "als_naechstes": True, + } + }, + ) + zuerst = (await liste_jetzt())[0] + pruefe( + zuerst["path"] == pfade[1], + f"als_naechstes zieht nach vorn ({zuerst['path']})", + ) + else: + ausgelassen("als_naechstes", "die Sammlung hat nur einen Track") + + # Auflegen verändert ein Deck. Nur, wenn dort ohnehin nichts läuft. + if any(d["playing"] for d in daten["decks"]): + ausgelassen("musik_queue_next", "es läuft gerade etwas") + else: + vorn = (await liste_jetzt())[0] + aufgelegt = text_von( + await client.call_tool("musik_queue_next", {"params": {}}) + ) + pruefe( + f"queue {vorn['nr']} abgenommen" in aufgelegt, + f"musik_queue_next nimmt den vordersten ({aufgelegt.splitlines()})", + ) + pruefe( + vorn["note"] is None or f"notiz {vorn['note']}" in aufgelegt, + "die Notiz kommt beim Auflegen mit", + ) + pruefe( + not any(e["nr"] == vorn["nr"] for e in await liste_jetzt()), + "und ist danach aus der Liste heraus", + ) + + # Aufräumen: nur die eigenen Einträge, nie die der anderen. + alte = {e["nr"] for e in vorher_liste} + for e in await liste_jetzt(): + if e["nr"] not in alte: + await client.call_tool( + "musik_do", + {"params": {"aktion": "master.queue_drop", "argument": str(e["nr"])}}, + ) + + print() + if uebersprungen: + print(f"{len(uebersprungen)} Prüfungen ausgelassen:") + for u in uebersprungen: + print(f" - {u}") + if fehler: + print(f"{len(fehler)} Prüfungen fehlgeschlagen:", file=sys.stderr) + for f in fehler: + print(f" - {f}", file=sys.stderr) + return 1 + print("alles grün") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(hauptteil()))