Der Musikplayer Banshee legt die Metadaten der von ihm verwalteten Songs in einer Datenbank ab, die sich durch Perlskripts auslesen, restaurieren, und mit automatisch ermittelten Beats-per-Minute-Werten auffrischen lässt.
Wie bei der Wahl zwischen vi oder Emacs schwören viele Nutzer von Musikplayern auf ihren Favoriten und wechseln selten. Denn schließlich lassen sich Einstellungen wie jahrelang mühsam von Hand erstellte Song-Ratings nicht so einfach übertragen. Zwar werde ich niemals einen anderen Editor als vi benutzen, doch neulich probierte ich den Musikplayer Banshee ([2] und Abbildung 1) aus, da die bislang in den Perlmeister-Studios verwendete Rhythmbox keinen einfachen Ratings-Export anbot.
Abbildung 1: Der Musik-Player Banshee. |
Die GUI kam extrem sauber und durchdacht daher, und als ich dann entdeckte,
dass man in Banshee vorgenommene Ratings ganz einfach sichern, exportieren oder
extern manipulieren kann, weil der Player sie in einer leicht zugänglichen
SQLite-Datenbank ablegt, war's um mich geschehen und Operation "Playerwechsel"
lief an. Wie in Abbildung 2 ersichtlich, speichert Banshee Song-Metadaten in
der Tabelle CoreTracks
der Datenbankdatei ~/.config/banshee-1/banshee.db
.
Der Pfad zu referenzierten Audio-Dateien im Filesystem steht in der Spalte
Uri
als URI mit dem Vorsatz file://
.
Wie der SQLite-Befehl .schema
zum Vorschein bringt, führt die
Tabelle weitere interessante Spalten wie die Anzahl der vergebenen
Ratings-Sterne (Rating
) oder zu wievielen Basstrommelschlägen pro Minute
Diskotänzer im Takt des Liedes rhythmisch zucken (BPM
, Beats per Minute).
Abbildung 2: Dank offenem Datenbankdesign lässt sich Banshee in die Karten sehen. |
Listing 1 sichert alle bislang per Mausklick vorgenommenen Song-Ratings in einer YAML-Datei (Abbildung 3), um sie später nach einem Systemcrash oder dem Wechsel zu einem neuen Musikplayer sofort wieder restaurieren zu können.
Ohne Parameter aufgerufen, öffnet banshee-rating-backup
die Datenbank mit
dem DBI-Modul und iteriert in der Funktion backup
ab Zeile 32 mit einem
SELECT-Befehl über alle Einträge der Tabelle CoreTracks
. Falls das für einen
Song gefundene Rating gleich 0 ist, springt Zeile 44 weiter zum nächsten
Eintrag, denn nicht vorgenommene Ratings braucht das Skript nicht zu sichern.
Falls sich aber ein positiver Wert findet, speichert Zeile 46 ihn im Hash
%ratings unter dem Pfad der Audio-Datei ab. Am Ende der Tabelle schreibt
die Funktion
DumpFile()
aus dem YAML-Modul die Ratings in die YAML-Datei
banshee-ratings.yml
.
Abbildung 3: Ausschnitt aus der YAML-Datei, in der Listing 1 die bislang vorgenommenen Ratings in Banshee sichert. |
01 #!/usr/local/bin/perl -w 02 use strict; 03 use Log::Log4perl qw(:easy); 04 Log::Log4perl->easy_init($DEBUG); 05 06 use DBI qw(:sql_types); 07 use DBD::SQLite; 08 use Data::Dumper; 09 use YAML qw(LoadFile DumpFile); 10 use Getopt::Std; 11 12 getopts( "r", \my %opts ); 13 14 my $db = glob 15 "~/.config/banshee-1/banshee.db"; 16 my $dbh = DBI->connect( "dbi:SQLite:$db", 17 "", "", { RaiseError => 1, 18 AutoCommit => 1 }); 19 20 my $yml = "banshee-ratings.yml"; 21 my %ratings = (); 22 23 if( $opts{ r } ) { 24 restore( $yml, $dbh ); 25 } else { 26 backup( $dbh, $yml ); 27 } 28 29 $dbh->disconnect(); 30 31 ########################################### 32 sub backup { 33 ########################################### 34 my( $dbh, $yml ) = @_; 35 36 my %ratings = (); 37 38 my $sth = $dbh->prepare( 39 "SELECT * FROM CoreTracks" ); 40 $sth->execute(); 41 42 while( my $hash_ref = 43 $sth->fetchrow_hashref() ) { 44 next if $hash_ref->{ Rating } == 0; 45 46 $ratings{ $hash_ref->{ Uri } } = 47 $hash_ref->{ Rating }; 48 } 49 50 DumpFile( $yml, \%ratings ); 51 52 $sth->finish(); 53 } 54 55 ########################################### 56 sub restore { 57 ########################################### 58 my( $yml, $dbh ) = @_; 59 60 my $ratings = LoadFile( $yml ); 61 62 for my $song ( keys %$ratings ) { 63 DEBUG "Restoring $song"; 64 65 my $rating = $ratings->{ $song }; 66 67 my $sth = $dbh->prepare( 68 "UPDATE CoreTracks SET Rating = ?" . 69 "WHERE Uri = ?" ); 70 $sth->execute( $rating, $song ); 71 $sth->finish(); 72 } 73 }
Zum späteren Restaurieren der Ratings ruft der User das Skript mit
banshee-rating-backup restore
auf, worauf es die Datei banshee-ratings.yml
mit der YAML-Funktion LoadFile()
in Zeile 60 einliest und über
den so initialisierten Hash iteriert. Dessen Schlüssel sind die Pfadnamen
zu den bewerteten Audio-Dateien und seine Werte die Ratings, sodass Zeile
67 pro Eintrag einfach einen Update-Befehl in SQL absetzen muss, um
die Datenbank wieder auf Vordermann zu bringen. Der typische Dreisprung
mit dem DBI-Modul besteht aus dem Zusammenstellen des SQL-Queries
mit prepare()
, einem anschließenden execute()
mit Parametern, das die
mit Fragezeichen im Query freigehaltenen Variablen ersetzt, und einem
abschließenden finish()
, das das allozierte Statement-Handle $sth
wieder freigibt.
Um eine Playlist für bestimmte Anlässe oder Gemütszustände zu erstellen, reichen Ratings allein nicht, denn wer hört schon AC/DC während eines Kerzenlichtdinners? Banshee führt in der Metadatenbank ein Feld "BPM" ("Beats Per Minute"), das die Taktschläge eines Titels pro Minute anzeigt. Wummernde Diskobässe eines Party-Titels wie "Memories" von David Guetta kommen auf 120 BPM, ein schnelles Techno-Stück auf 180 und ein klassischer Titel wie Mozarts Zauberflöte kommt ohne jegliches Getrommel aus und führt den Wert 0. Radio-DJs schwören auf diesen Messwert und stellen zum Teil mit automatischen Tools ihr BPM-kompatibles Programm zusammen.
Mit einer Kombination aus Rating und erlaubtem BPM-Bereich kann der User später die passende musikalische Untermalung für gewisse Stunden auswählen. Allerdings führen Audiodateien normalerweise keine BPM-Werte in ihren Metadaten mit sich. Version 1.5 des Banshee-Players liefert ein auf dem GStreamer-Paket bpmdetect basierendes BPM-Erkennungstool mit. Ein Klick auf eine im Dateiscan-Dialog versteckte Checkbox aktiviert den BPM-Detektor (Abbildung 4). Der Menüpunkt Tools->Rescan Music Library startet den CPU-intensiven Update, der, abhängig von der Größe der verwalteten Musiksammlung, einige Zeit läuft.
Abbildung 4: Banshee ermittelt auf Wunsch die BPM-Werte aller gefundenen Songs und füllt sie in die Datenbank. |
Die Ergebnisse lassen jedoch zu wünschen übrig. So meint das Tool zum Beispiel, dass das eher behäbige "I Feel Fine" von den Beatles mit 213 BPM-Punkten dreimal so schnell wie der nur mit dem Wert 68 bewertete superschnelle Pop-Punk-Titel "Rich Lips" von Blink-182 sei (Abbildung 5).
Abbildung 5: Banshees BPM-Messer meint ernsthaft, dass "I Feel Fine" von den Beatles drei mal so schnell wie "Rich Lips" von Blink-182 ist. |
Listing 2 versucht deshalb mit einem BPM-Verfahren der Marke Eigenbau eine
zuverlässigere Lösung auf die Beine zu stellen. Es
wandelt die komprimierten Audiodateien
mit der Utility sox
aus dem gleichnamigen
Ubuntu-Paket in rohe Audiodaten um, jagt diese durch einen schmalen Bandpass
im Bassbereich und misst dann die Abstände zwischen den hoffentlich ausgeprägten
Bassmaxima. Diese Methode funktioniert zwar auch nicht fehlerfrei,
unterscheidet aber klar Discostampf von klassischer Musik.
Das als Paket für viele Distributionen erhältliche Audio-Tool audacity zeigt in den Abbildungen 6 und 7, wie die Audiodaten zweier unterschiedlicher Musikgenres aussehen. Während das klassische Orchesterstück mit Heldentenor nur mäßig ausgesteuert ist und keinerlei stetigen Rhythmus erkennen lässt, zeigt der breitbandige Synthosound durchaus periodische Tendenzen.
Abbildung 6: "Zu Hilfe, zu Hilfe, sonst bi-i-in ich verloren" trällert Prinz Tamino in Mozarts Zauberflöte. |
Abbildung 7: Voller Synthosound: "Memories" von David Guetta |
Die Musik, die das Ohr als Bündel gleichzeitig gespielter und nahe beieinander liegender Tonfrequenzen hört, erzeugt die Soundkarte aus digitalen Abtastwerten. Eine Stereoaufnahme besteht pro Kanal typischerweise aus 44.100 verschiedenen 16-Bit-Messwerten pro Sekunde, die zwischen -32768 und +32767 varieren. Ein reiner Ton käme als Sinuskurve zum Vorschein, aber ein Musikinstrument oder eine menschliche Stimme erzeugen typischerweise ein weites Spektrum an Frequenzen.
Abbildung 8 zeigt als Beispiel die Audiodaten einer 1Hz-Sinusschwingung im Rohformat. Verfolgt man zum Beispiel den ersten (grünen) Kanal, sieht man, dass die Werte von "15 06", "18 06", "1c 06", usw. stetig ansteigen (zu beachten ist die umgekehrte Byte-Order auf Intel-Prozessoren, die zugehörigen dezimalen Werte sind 1557, 1560, 1564). Offensichtlich befindet sich der Sinus gerade im steigenden Bereich. Innerhalb einer Periode mit 44.100 Abtastwerten durchläuft das 1Hz-Signal einmal einen Bereich, der mit insgesamt 65536 Werten kodiert ist, also ist der Sprung von 0x0615 (dezimal 1557) zu 0x0618 (dezimal 1560) schon im flacheren Teil der Kurve in der Nähe des Maximums zu finden. Das Testsignal ist auf beiden Kanälen identisch, beide Kanäle (grün und blau) weisen die gleichen Werten auf.
Abbildung 8: Eine 1-Hz Sinuskurve mit einer Abtastrate von 44100 Hz im 2-Kanal-Rohformat. |
Eine mp3-Datei kodiert die Abtastwerte auf raffinierte Weise und das Skript
muss sie vor der Analyse erst in das Rohformat von Abbildung 8 bringen. Hierzu
eignet sich die Utilty sox
, die mit dem Aufruf
sox infile.mp3 -r 44100 -c 2 \ -b 16 -t raw -e signed outfile.raw
aus infile.mp3
die Rohformatdatei outfile.raw
als Zweikanal-Kodierung mit
vorzeichenbehafteten 16-Bit-Werten bei einer Abtastrate von 44100Hz erzeugt. Um
darin die Bassaktivität zu analysieren und die zu bearbeitenden Daten auf 30
Sekunden zu beschränken, hängt der BPM-Messer an das Kommando oben noch die
Argumente
... bandpass 100 1 \ trim 60 30
an. Der Bandpass blendet Frequenzen, die nicht im Bereich einer Basstrommel
liegen mit einem 3db-Dämpfer pro Oktave aus und der trim
-Filter fährt 60
Sekunden ins Musikstück hinein und extrahiert dort nur die Daten der nächsten
30 Sekunden, damit das Skript später nicht ewig herumorgeln muss.
Abbildung 9 zeigt die so gefilterten Audiodaten verschiedener Titel. Die Basstrommel von "I Feel Fine" und das Syntho-Gewummere von David Guettas "Memories" erzeugen saubere Maxima, die das Skript erkennt und durch simple Streckung auf Beats-per-Minute umrechnet. Bei klassischer Musik oder akustischen Gitarrensongs wie "I Got a Name" von Jim Croce hingegen dümpelt das Signal auf niedrigen und nahezu konstanten Werten dahin und der Algorithmus liefert den Wert 0. Das Signal von schnellen Punk-Songs wie "Rich Lips" von Blink-182 variiert stark, doch ungefähres Erfassen der Maxima führt üblicherweise ebenfalls zu brauchbaren BPM-Werten.
Abbildung 9: Die Basslinie von Songs verschiedener Genres nach Anwenden des schmalspurigen Bandfilters. |
Nach dem Verbinden mit der Datenbankdatei ruft Listing 2 die Funktion
bpm_update()
ab Zeile 29 auf. Der Select-Query in Zeile 34 liefert
die Pfade aller von Banshee verwalteten Musikdateien als URIs in
der form file://pfad/datei zurück. Da in diesen URIs Leerzeichen als
%20 codiert sind, wandelt die Funktion uri_escape()
aus dem CPAN-Modul
URI::Escape sie wieder in normale Leerzeichen zurück. Zeile 44 entfernt
dann noch das führende file://
und schon steht in der Variablen
$file
der Unix-Pfad zur Audiodatei.
001 #!/usr/local/bin/perl -w 002 use strict; 003 use Log::Log4perl qw(:easy); 004 use DBI qw(:sql_types); 005 use DBD::SQLite; 006 use Sysadm::Install qw(tap); 007 use URI::Escape; 008 use File::Temp qw(tempfile); 009 use POSIX; 010 011 my $SAMPLE_RATE = 10_000; 012 my $OFFSET = 60; 013 my $SAMPLE_SECS = 30; 014 my $MIN_SIZE = 500; 015 my $MIN_DROP = 0.7; 016 my $NWINDOWS = 20; 017 018 Log::Log4perl->easy_init({ level => $INFO, 019 category => "main" }); 020 my $db = glob 021 "~/.config/banshee-1/banshee.db"; 022 my $dbh = DBI->connect( "dbi:SQLite:$db", 023 "", "", { RaiseError => 1, 024 AutoCommit => 1 }); 025 bpm_update( $dbh ); 026 $dbh->disconnect(); 027 028 ########################################### 029 sub bpm_update { 030 ########################################### 031 my( $dbh ) = @_; 032 033 my $sth = $dbh->prepare( 034 "SELECT Uri FROM CoreTracks " . 035 "WHERE BPM = 0" ); 036 $sth->execute(); 037 038 my $upd_sth = $dbh->prepare( 039 "UPDATE CoreTracks SET BPM=? " . 040 "WHERE Uri = ?"); 041 042 while( (my $uri) = 043 $sth->fetchrow_array() ) { 044 my $file = uri_unescape( $uri ); 045 $file =~ s#^file://##; 046 INFO "Updating $uri"; 047 $upd_sth->execute( bpm( $file ), 048 $uri ); 049 } 050 $upd_sth->finish(); 051 $sth->finish(); 052 } 053 054 ########################################### 055 sub bpm { 056 ########################################### 057 my( $file ) = @_; 058 059 my $rawfile; 060 061 if( $file =~ /\.raw$/ ) { 062 $rawfile = $file; 063 } else { 064 $rawfile = File::Temp->new( 065 SUFFIX => ".raw", UNLINK => 1 ); 066 067 my($stdout, $stderr, $rc) = 068 tap "sox", $file, "-r", $SAMPLE_RATE, 069 "-c", 2, "-b", 16, "-t", "raw", 070 "-e", "signed", $rawfile, 071 "bandpass", 100, 1, 072 "trim", $OFFSET, $SAMPLE_SECS; 073 074 if( $rc ) { 075 LOGWARN "sox $file: $stderr"; 076 return -1; 077 } 078 } 079 080 return raw_bpm( 081 samples( $rawfile->filename ) ); 082 } 083 084 ########################################### 085 sub samples { 086 ########################################### 087 my( $file ) = @_; 088 089 my @vals = (); 090 sysopen FILE, "$file", O_RDONLY 091 or LOGDIE "$file: $!"; 092 093 while( sysread( FILE, my $val, 4 ) ) { 094 my($c1, $c2) = unpack 'ss', $val; 095 $c1 = 0 if $c1 < $MIN_SIZE; 096 push @vals, $c1; 097 } 098 close FILE; 099 return @vals; 100 } 101 102 ########################################### 103 sub raw_bpm { 104 ########################################### 105 my(@samples) = @_; 106 107 my $win = scalar @samples / 108 ($NWINDOWS*$SAMPLE_SECS); 109 my($bumps, $pmax, $slope) = (0, 0, "up"); 110 111 for( my $o = 0; $o <= $#samples - $win; 112 $o += $win ) { 113 my $max = 0; 114 for( my $i = $o; $i <= $o + $win; 115 $i++ ) { 116 if( $samples[$i] > $max ) { 117 $max = $samples[$i]; 118 } 119 } 120 121 if( $slope eq "up" ) { 122 if( $max < $MIN_DROP * $pmax ) { 123 $slope = "down"; 124 $bumps++; 125 } 126 } else { 127 $slope = "up" if $max > $pmax; 128 } 129 $pmax = $max; 130 } 131 132 return int($bumps / $SAMPLE_SECS * 60.0) 133 || 1; 134 }
Der zweite SQL-Befehl, den Zeile 37 mit Platzhaltern vorbereitet, frischt
den Wert der BPM-Spalte in der Datenbank auf, in dem es die
Uri als Selektionskriterium mit WHERE vorgibt. Zeile 46 schickt mit
execute()
den Update mit den eingesetzten Parametern Uri und BPM
an die Datenbank ab. Während die while-Schleife alle gefundenen Audiodateien
abklappert, bleibt das SQL-Statement in $upd_sth gespeichert und Zeile
46 ruft es jedesmal mit neuen Parametern auf. Nach dem Ende der while-Schleife
gibt finish()
die intern angelegten Datenstrukturen wieder frei.
Die Errechnung des BPM-Wertes einer Audiodatei übernimmt die
Funktion bpm()
ab Zeile 54. Falls ihr schon eine .raw-Datei überreicht
wurde, übernimmt Zeile 61 diese, doch im Normalfall dürfte es sich
um .wav, .mp3, .ogg oder Ähnliches handeln. Der mit der tap()
-Funktion
des CPAN-Moduls abgesetzte sox-Befehl ab Zeile 67 extrahiert 30
Sekunden Musik nach der 1-Minuten-Marke, pfercht sie durch den
schmalen Bandpass und legt die resultierenden Rohdaten in
einer gleichnamigen .raw-Datei ab. Um den Datenwust auf ein
erträgliches Maß zurechtzustutzen, reduziert es die Samplingrate auf
den in Zeile 11 gesetzten Wert für $SAMPLE_RATE (10.000 pro Sekunde).
Die Funktion samples()
ab Zeile 84 liest dann die in der .raw-Datei
abgelegten Werte in 4-Byte-Schritten mit sysread()
aus (2 Kanäle à 2 Byte)
und nutzt Perls eingebaute
Funktion unpack()
mit dem Platzhalter 'ss', um die zwei
vorzeichenbehafteten Integer zu extrahieren. Sie ignoriert den
Wert für den zweiten Kanal in $c2
(weil identisch) und nur der erste
Kanalwert in $c1
wandert ans Ende des Ergebnisarrays @vals
,
falls er über dem Schwellwert $MIN_SIZE liegt. Dieser in Zeile
14 auf 500 gesetzte Wert soll verhindern, dass die Maximumsuche sich
in dümpelnden Signalen in leisen Passagen verliert.
Im Voraus weiß das Skript nicht, wieviele Maxima es im Datenarray finden wird und hat auch keinen Anhaltspunkt über deren Höhe. Es arbeitet deswegen nach der sogenannten "Bergsteigermethode" (Abbildung 10), indem es jeweils alle Signalamplituden in einem schmalen Zeitfenster (z.B. einer 1/20 Sekunde) untersucht, und das dort gefundene lokale Maxium speichert. Ist das lokale Maximum im nächsten Zeitfenster größer als das gespeicherte, befindet sich der Signalgraph im Aufwärtstrend und der Algorithmus setzt ein Flag. Stellt sich in diesem Modus im nächsten Zeitfenster ein kleineres lokales Maximum ein, wurde soeben ein globales Signalmaximum überschritten und das Skript erhöht den entsprechenden Zähler.
Abbildung 10: Bergsteigermethode zur Maximumsbestimmung: Das erste Fenster, dessen Maximalwert nach einem Anstieg unter dem vorherigen Wert liegt, zeigt die Überschreitung eines Maximums an. |
Dieses Verfahren funktioniert, da die maximale Anzahl zu findender Maxima nach oben beschränkt ist. BPM-Werte über 600 sind unsinning und uninteressant, und somit gilt als gesichert, dass innerhalb zweier Zeitfenster von jeweils einer 1/20 Sekunde keine zwei Maxima vorliegen.
Die Funktion raw_bpm()
nimmt den Datenarray eines Kanals entgegen und
macht sich auf Gipfelsuche. Zeile 106 definiert die Fensterbreite, indem
sie die Anzahl der Datenpunkte durch die gewünschte Fensterdichte ($NWINDOWS,
auf 20 Windows pro Sekunde gesetzt) mal der Anzahl der Sample-Sekunden
dividiert. Die Variable $slope
markiert das Flag, mit dem der Algorithmus
feststellt, ob er sich gerade inmitten eines Aufwärts- ("up") oder
Abwärtstrends ("down") befindet. Das letzte globale Maximum speichert
raw_bpm()
in $pmax
und das lokale Maximum des gerade untersuchten
Fensters steht in $max
. Damit kleinere Signalfluktuationen das Verfahren
nicht aus dem Tritt bringen, definiert $MIN_DROP mit 0.7, dass das
globale Maximum mindestens um 30% fallen muss, damit der vorher durchlaufene
Hügel als Maximum gilt und der Zähler $bumps
um eins erhöht wird.
Zeile 131 dividiert die Anzahl gefundender Maxima dann noch durch die
Sekundenlänge des untersuchten Datenbereichs und multipliziert das
Ergebnis mit 60, um den Minutenwert BPM zu erhalten.
Die zusätzlich zu installierenden Perlmodule DBI, DBD::SQLite, Sysadm::Install, URI::Escape und Log::Log4perl finden sich entweder als Pakete in der verwendete Distro (z.B. libdbi-perl, libdbd-sqlite-perl, usw. auf Ubuntu) oder lassen sich mit einer CPAN-Shell aufs lokale System übertragen.
sox
ist nicht überall mit mp3-Support verfügbar. Wessen Gesetzeslage die
Verwendung des Formats nicht erlaubt, kann statt dessen problemlos
mit .wav- oder .ogg-Dateien arbeiten.
Bei größeren Musiksammlungen sollte Banshee aus Performancegründen im
Tools-Dialog auf MySQL umgestellt werden. Dank DBIs Datenbankunabhängigkeit
muss das Skript dann lediglich die connect()-Zeile anpassen und statt
"dbi:SQLite:$db"
das Kürzel "dbi:mysql:dbname"
angeben, sowie
Username und Passwort dort einfügen, wo in der SQLite-Version die
beiden Leerstrings stehen.
Als weitere Idee zur automatischen Klassifizierung von Audio-Dateien böte sich die Unterscheidung von Hörbüchern, gesprochenen Texten und Musikstücken an. Oder vielleicht sogar eine harmonische Analyse, sodass an manchen Tagen nur fröhliche Stimmungsmusik erschallte und an anderen auch Moll- oder gar Misstöne erlaubt wären?
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2011/06/Perl
Banshee Player Home Page: http://banshee.fm
Michael Schilliarbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com. |