Schon viel zu lange liegt hier ein LXD-Container namens bpm-tag rum. Es beinhaltet ein Programm, welches die Beats per Minute Rate von MP3 Dateien bestimmen kann. Was wäre es gut, diese Infos in der Musikdatenbank zu haben? Da könnte man Lieder mit gleichen Beat-Raten suchen! Aber auch sonst brauch dieses Projekt mal ein Update. Also fangen wir an.
Das Progamm nennt sich bpm-tool. Es ist jetzt 12 Jahre alt, fast so lange wie es MP3 gibt. Naja, das stimmt nicht ganz. MP3 wird dieses Jahr schon 30. Eine deutsche Erfindung, übrigens. Jedenfalls lag das Programm in einem LXD-Container, weil es speziell kompiliert werden musste. Mit C, sehr unangenehm, zumal es keine Updates mehr gibt. Also erst mal nach Go portiert und Binaries für verschiedene Plattformen bereitstellen. Schick. Nicht ganz. Die verwendete Upstream Lib ist auch nicht mehr maintained. Aber erst mal geht es so.
Ich habe ca. 1000 Musik-CDs. Um die irgendwann zu verwalten, fing natürlich alles mit einer Excel-Tabelle an. Dann kam Yii. Yii-Framework besteht aus PHP und kann mit ein paar Kommandos Web-Frontends zu MySQL-Datenbanken erstellen. Die MySQL-Datenbank besteht aus Tabellen zu Datenträgern, Alben und Titeln. Fortan kann ich bequem über Web meine Musik-CDs durchsuchen. Das war 2011. Dann hab ich angefangen, alle physischen CDs zu MP3s zu konvertieren und auf Festplatten auszulagern. Das muss nach 2015 gewesen sein, denn ich hatte die CD-Sammlung noch umgezogen. Oder doch nicht? Weiss ich gar nicht mehr. Jedenfalls habe ich nur noch eine Handvoll CDs, statt der ehemals 1000.
Okay, jetzt haben wir also das bpm-tool und können die Beat-Rate von allen MP3 bestimmen. Etwa 10.000 Stück. Kann man manuell machen, für die berühmten langen kalten Winterabende. Oder automatisch. Wie schon erwähnt liegen die MP3 auf Festplatte. Die sind meist über CIFS oder SAMBA im Heimnetzwerk eingebunden. Also im Proxmox schnell eine Ubuntu-VM gestartet, dort die Festplatte über Netz gemountet. Mit diesem Update Script geht es ganz schnell, die BPM-Rate jedes MP3 zu bestimmen und den Wert in die MySQL-Datenbank einzutragen. Bei der Gelegenheit kann man auch noch den Dateinamen übernehmen, denn unser nächstes Problem:
In der Musikdatenbank liegen die Informationen zu den Liedern. Sänger, Titel, Länge. CD-Nummer, Position auf der CD und CD-Name. Der Ablauf war also
Sehr umständlich. Wir brauchen eine Verbindung von der Musikdatenbank zur Musik. Nun, seit einiger Zeit läuft zu Hause ein Kubernetes-Cluster auf dem Proxymox exposed zum Internet. Auch auf dem Rechner haben wir die Festplatte mit den MP3-Files über das Heimnetzwerk gemountet. Im Cluster brauchen wir eine Storage-Class und ein PersistentVolume Objekt:
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-existing
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: mdb
spec:
capacity:
storage: 100Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-existing
local:
path: /mnt/mycloud
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- k8s
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mdb
namespace: mdb
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-existing
resources:
requests:
storage: 100Gi
```Die persistentVolumeReclaimPolicy wäre wichtig. Da anderenfalls Kubernetes unsere Festplatte löschen würde, wenn wir den PVC löschen.
Der Webserver kommt aus der Fabrik. Ein Helm-Deployment mit einem Nginx, der besagten PVC mountet und die Daten der dahinterliegenden Festplatte bereitstellt.
# kubectl -n mdb get pod
NAME READY STATUS RESTARTS AGE
mdb-webapp-9b645478c-4rtnf 1/1 Running 0 3d22h
oauth-proxy-oauth2-proxy-6d99fc9bb4-rmjf5 1/1 Running 0 3d23h
Was ist oauth-proxy? Richtig, wir wollen ja nicht, dass jedermann auf unsere MP3-Sammlung zugreift. Da würde man sofort verhaftet. Mit oauth-proxy kann man verschiedene Auth-Provider in seinem Projekt einbinden, um Nutzer zu authentifizieren. Ich habe mich an der Stelle für Google entschieden. Im Oauth-Projekt gibt es dazu eine Anleitung. Es war dennoch etwas trickreich. Für meine Belange brauchte man in der Google Cloud eine Organisation. Mit der lassen sich auch Benutzergruppen erstellen, um etwa die ganze Familie an der Musikdatenbank teilhaben zu lassen. Nach der Anleitung brauch man OAuth-Credentials, die generelle Admin-API, um auf admin.google.com die Gruppen verwalten zu können und einen Service-Account.
Meine Oauth-Proxy Config sah dann etwa so aus:
config:
configFile: |-
cookie_domains = [ ".eumel.de" ]
whitelist_domains = [ ".eumel.de" ]
email_domains = [ "*" ]
reverse_proxy = true
upstreams = [ "file:///dev/null" ]
google:
groups:
- mdb@eumelnet.de
adminEmail: admin@eumelnet.de
serviceAccountJson: |-
{...
Deployt wird mit
helm -n mdb upgrade -i oauth-proxy \
--set config.clientID="xxxx.apps.googleusercontent.com" \
--set config.clientSecret="xxxx" \
--set config.cookieSecret="xxxx" \
-f google.yaml \
oci://ghcr.io/oauth2-proxy/charts/oauth2-proxy:9.0.1 \
--create-namespace
In der google.yaml liegen die Google-spezifischen Einstellungen. Von der Idee her, soll jeder, der in der Gruppe mdb@eumelnet.de ist, Zugriff bekommen. Da Musikdatenbank und MP3-Dienst auf unterschiedlichen Webservern läuft, braucht es die Wildcard-Cookie-Domain und die Reverse Proxy Einstellung, denn auch der Oauth-Dienst läuft auf einem anderen Webserver.
Aktiviert wird das ganze über Ingress-Annotation:
metadata:
annotations:
"nginx.ingress.kubernetes.io/auth-url": "https://oauth.eumel.de/oauth2/auth",
"nginx.ingress.kubernetes.io/auth-signin": "https://oauth.eumel.de/oauth2/start?rd=$escaped_request_uri",
Fertig. Das Yii haben wir so erweitert, dass wir in der CGridView zu Titel ein Feld mit einem Link hingefügt haben:
array(
'name' => 'path',
'type' => 'raw',
'value' => 'CHtml::link(
$data->pos,
"https://mdb.eumel.de/" . ltrim($data->path, "/"),
array("target" => "_blank")
)',
),
Das funktioniert auf Anhieb! Man musste nicht mal URL-encoden, dank HTML5 läuft der Player sofort los im Browser mit dem gewünschten Lied.

Beim Folgenden hat mir ChatGPT geholfen, denn mein Wissenstand von YII ist von 2011 und von Javascript und CSS hab ich nicht wirklich Ahnung. Zu YII kann man zwar viele gute Doku lesen, oder ChatGPT fragen und sofort den passenden Snippet erhalten. Wobei ich dann erfahren habe, dass es mittlerweile YII 2 gibt und ich noch YII 1 habe.
Die erste Studie bindet einem HTML5-Player pro Titel auf der Seite ein:

Naja, nicht so toll. Ausserdem: Gibt es für Alben nicht m3u, diese Playlisten? Hab ich glaube ich für alle Alben da, aber das funktioniert nicht über Web, da die Browser das Laden lokaler Dateien verbieten.
YII wurde dann so erweitert, dass es im TitelController eine actionPlaylist Funktion gibt, die beim Parameter id die Titel eines Albums nebst Dateiname, BPM und anderen Information als JSON bereitstellt.
Die Album View kriegt jetzt auch einen Link pro Album. Diese ruft eine JavaScript Funktion mit dem Abruf der JSON Datei auf:
function loadAndPlay(albumId, url) {
fetch(url + '?id=' + albumId, {
cache: 'no-store'
})
.then(r => r.json())
.then(data => { playlist = data.tracks;
index = 0;
updateTrackInfo();
play();
});
}
Wie zu sehen, wird die Funktion play() aufgerufen, die dann den HTML5-Mediaplayer triggert. Der wurde im Design der restlichen Webseite angepasst. Zeigt den Titel, der gerade abgespielt wird, die Beat-Rate aus der Datenbank sowie die Länge und Spieldauer. Als Bedienfelder gibt es Pause/Play, Lautstärke, Vor und Zurück. Es wird auch automatisch das nächste Lied abgespielt, wenn das aktuelle Lied zu Ende ist. Der Clou:
Der Fortschrittsbalken wippt unten im Takt der Beats mit:

Darauf muss man erst mal kommen. Ausserdem soll das aktuelle Album, was gerade gespielt wird, farblich markiert sein. Das funktioniert auch manchmal, jedoch musste ich ganz viel lernen zu EventListener und DOMContentLoader, denn YII hat selbst schon genug Ajax-Zauber und lädt nicht immer die Webseite komplett neu. Da sind dann solche Sachen wie
var activeDiscnr = null;
window.addEventListener('pageshow', function (event) {
if (event.persisted) { // Seite kam aus BFCache → State neu aufbauen
if (activeDiscnr) {
setActiveAlbum(activeDiscnr);
} } });
Und 400 weitere Zeilen mit CSS und Javascript. Letztlich tuts erst mal so.
Was hat ChatGPT noch vor? Mit Webaudio und Javascript-Funktionen der grossen Streaming-Diensten kann man Lieder mit gleicher BPM hintereinanderspielen (okay) und die Beat-Rate zweier Titel soweit anpassen, dass sie nahtlos übergehen (hä?), ohne dass sich die Tonhöhe ändert (geht ja gar nicht!).
Hier mal zum Schluss ein paar Zeilen aus dem Chatverlauf:
1️⃣ Idee dahinter
Auto-BPM-Matching bedeutet:
Du hast zwei Tracks, Track A läuft gerade, Track B kommt als nächster.
BPM (Beats per Minute) von beiden Tracks wird berücksichtigt.
Ziel: Track B automatisch an Track A anpassen, damit der Übergang rhythmisch sauber ist.
Praktisch:
Track B wird leicht schneller oder langsamer abgespielt, um die Beats auf Track A zu synchronisieren.
Dann kann man Crossfade oder nahtlosen Übergang nutzen.
2️⃣ Voraussetzungen
BPM der Tracks muss bekannt sein (bei dir schon track.bpm)
WebAudio API notwendig, da <audio> keine Speed-Control mit Pitch-Korrektur erlaubt
3️⃣ Prinzip in WebAudio
AudioContext erstellen:
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const trackA = audioCtx.createMediaElementSource(playerA);
const trackB = audioCtx.createMediaElementSource(playerB);
PlaybackRate setzen:
// simple ratio, ohne Pitch-Korrektur
trackBElement.playbackRate = trackA.bpm / trackB.bpm;
Wenn Track A = 120 BPM, Track B = 100 BPM → playbackRate = 1.2
Track B läuft 20% schneller → BPM synchron
Optional: Pitch-Korrektur
Libraries wie soundtouch.js erlauben, BPM anzupassen ohne Tonhöhe zu verändern
So bleibt die Musik „natürlich“
4️⃣ Einfacher Prototyp (ohne Pitch-Korrektur)
function autoBpmMatch(nextTrack) {
const currentBpm = playlist[index].bpm;
const nextBpm = nextTrack.bpm;
const rate = currentBpm / nextBpm;
player.playbackRate = rate; // native HTML5 Audio
}
Vor player.play() aufrufen
Track B startet automatisch in BPM-Sync
5️⃣ Einschränkungen / Caveats
<audio> unterstützt nur simple playbackRate → Tonhöhe ändert sich
Für professionelles DJ-Matching:
WebAudio API + Time-Stretching / Pitch-Preserving Algorithmen
Libraries: soundtouch.js, Tone.js, Wavesurfer.js
Bei großen BPM-Unterschieden → hörbare Artefakte
6️⃣ Nächstes Upgrade
Kombiniere Auto-BPM-Matching mit Crossfade → nahtloser Übergang
Visualisierung der Beats auf Progress-Bar → DJ-Style