So erzeugt man eine Waveform mit PHP
FragSalat
Vorbereiten der Waveform
Angenommen wir haben 10 verschiedene Audioformate, welche wir alle unterstützen und mit einer Waveform visualisieren wollen. Nun wollen wir aber nicht 10 Codecs implementieren. Also benötigen wir ein einheitliches Format, welches es uns ermöglicht die Waveform zu generieren. Hier kommt das gute alte WAV Format ins Spiel. Da das WAV Format unkomprimiert und ausreichend dokumentiert ist, werden wir versuchen jede Audiodatei in dieses Format zu konvertieren. Somit umgehen wir das nervige Decoden der Audiodateien und wir müssen nur einen Standard unterstützen. In linux habe ich mir für das Konvertieren diverse Codecs und den MPlayer installiert.
sudo apt-get install faac faad flac lame libmad0 libmpcdec6 mppenc vorbis-tools wavpack mplayer
Der MPlayer konvertiert für uns so ziemlich jedes Format erst mal in eine MP3.
- $commandPath = @exec('which "mplayer"');
- if ($commandPath) {
- // Prepare the convert command using mplayer
- $command = '%1$s -vo null -vc null -ao pcm:fast %2$s &&
- lame -m s audiodump.wav %3$s &&
- rm audiodump.wav';
- // Get the path to the new file
- $fileName = $this->getLocation('converted.mp3');
- // Execute the command
- @exec(sprintf($command, $commandPath, $this->getLocation(), $fileName));
- }
Wir wandeln die Audiodateien erst in eine MP3, da man in der Regel eine Audiodatei auch wiedergeben möchte. Somit haben wir auf jeden Fall ein platzsparendes Format, welches so gut wie überall unterstützt wird. Hier könnte man jetzt, wie in dem Artikel über die MPEG Frames beschrieben, die Spielzeit und andere Informationen auslesen bevor man anfängt die Waveform zu erzeugen.
Nun gebe ich euch hier meine BinaryReader Klasse mit, welche euch hilft die Audiodateien zu lesen.
- <?php
- namespace file\system\io;
- /**
- * This class helps to read files in binary format
- *
- * @author Thomas Schlage
- * @copyright 2013 Thomas Schlage
- * @license Commercial License <http://www.binpress.com/license/view/l/831258010c5e26599b815cfe8621d912>
- * @package com.stagetwo.file
- * @link http://www.stagetwo.eu
- */
- class BinaryReader {
- /**
- * The total file size
- */
- public $dataSize = 0;
- /**
- * The stream handle
- */
- protected $fileHandle = null;
- /**
- * Holds a sequence of bytes to read the bits from it
- */
- private $bitMemory = null;
- /**
- * The current bit position on the byte sequence
- */
- private $bitPositon = 0;
- /**
- * Initialize the file handle
- *
- * @param String Url to target file
- */
- public function __construct($file) {
- // Check if we have a url or a stream resource
- if (getType($file) == 'string') {
- $this->fileHandle = @fopen($file, 'r');
- $this->dataSize = filesize($file);
- }
- else if (getType($file) == 'resource') {
- // Use the stream resource as handle
- $this->fileHandle = $file;
- // Seek to end of file
- fseek($this->fileHandle, 0, SEEK_END);
- // The end of file is our dataSize
- $this->dataSize = $this->pos();
- // Go back to begin of file
- $this->seek(0);
- }
- }
- /**
- * Read a String until the next occurence of seperator or until length is reached
- *
- * @param Integer The maximum Length to read
- * @param Integer The string until we read
- * @return String The string between current offset and offset + length or offset + offsetOfSeperator
- */
- public function readString($length = null, $seperator = null) {
- if ($length == 0) return false;
- if (!$length) $length = $this->dataSize;
- return stream_get_line($this->fileHandle, $length, $seperator);
- }
- /**
- * Read a String in Unicode format until the next occurence of seperator or until length is reached
- *
- * @param Integer The maximum Length to read
- * @param Integer The string until we read
- * @param String The encoding the string is expected in
- * @return String The string in UTF-8 Format between current offset and offset + length or offset + offsetOfSeperator
- */
- public function readUnicodeString($length = null, $seperator = null, $encoding = 'UTF-16') {
- $string = $this->readString($length, $seperator);
- if (strlen($string) > 1) {
- // Determine the unicode type lo first or hi first
- if ($string[0] > $string[1]) {
- $encoding = 'UTF-16LE';
- }
- else {
- $encoding = 'UTF-16BE';
- }
- // Check if there are indicator bytes and remove them if yes
- if (($string[0] == "\xff" && $string[1] == "\xfe") || ($string[0] == "\xfe" && $string[1] == "\xff")) {
- $string = substr($string, 2);
- }
- }
- try {
- return iconv($encoding, 'UTF-8', $string);
- }
- catch (\Exception $e) {
- return iconv('UTF-16', 'UTF-8', $string);
- }
- finally {
- return $string;
- }
- }
- /**
- * Reads a synchronize save 32 bit (4 byte) Integer
- *
- * @return Integer The 4 bytes as parsed Integer
- */
- public function readSyncSaveInt32() {
- $int = fread($this->fileHandle, 4);
- $int = ord($int[3]) | ord($int[2]) << 8 | ord($int[1]) << 16 | ord($int[0]) << 24;
- return ($int & 0x0000007f) |
- ($int & 0x00007f00) >> 1 |
- ($int & 0x007f0000) >> 2 |
- ($int & 0x7f000000) >> 3;
- }
- /**
- * Reads a 32 bit (4 byte) Integer
- *
- * @return Integer The 4 bytes inversed as Integer
- */
- public function readInt32() {
- $int = fread($this->fileHandle, 4);
- return ord($int[3]) | ord($int[2]) << 8 | ord($int[1]) << 16 | ord($int[0]) << 24;
- }
- /**
- * Reads a 16 bit (2 byte) Integer
- *
- * @return Integer The 2 bytes inversed as Integer
- */
- public function readInt16() {
- $short = fread($this->fileHandle, 2);
- return ord($short[1]) | ord($short[0]) << 8;
- }
- /**
- * Reads a 8 bit (1 byte) Integer
- *
- * @return Integer The byte as Integer
- */
- public function readInt8() {
- return ord(fread($this->fileHandle, 1));
- }
- /**
- * Reads a number with dynamic length
- *
- * @param Integer the length of the number in bytes
- * @return Integer The parsed number
- */
- public function readNumber($bytes) {
- $int = fread($this->fileHandle, $bytes);
- $newInt = 0;
- $len = strlen($int) - 1;
- for ($i = $len; $i >= 0; $i--) {
- $newInt |= ord($int[$i]) << (($len - $i) * 8);
- }
- return $newInt;
- }
- /**
- * Reads a number with dynamic length by inversing the bytes
- *
- * @param Integer the length of the number in bytes
- * @return Integer The parsed number
- */
- public function readInversedNumber($bytes) {
- $int = fread($this->fileHandle, $bytes);
- $newInt = 0;
- for ($i = 0, $l = strlen($int); $i < $l; $i++) {
- $newInt |= ord($int[$i]) << ($i * 8);
- }
- return $newInt;
- }
- /**
- * Returns a amount of bits. Started bytes are skipped by other functions.
- *
- * @param Integer The amount of bits you wish to read
- * @return String The bits in binary string format
- */
- public function readBits($length) {
- // Check if we have enough bits in memory
- if ($this->bitPositon + $length > strlen($this->bitMemory)) {
- // Read bits we have left in memory
- $bits = substr($this->bitMemory, $this->bitPositon);
- // Calculate bits we need to reed excluding bits we have in memory
- $bytesToRead = ceil(($length - (strlen($this->bitMemory) - $this->bitPositon)) / 8);
- // Shorten bitMemory by strip readed bytes. Keep every time at least 7 bits to have 1 byte complete
- $this->bitMemory = substr($this->bitMemory, floor($this->bitPositon / 8) * 8);
- // Set new bitPositon
- $this->bitPositon = $this->bitPositon % 8;
- // Read new bytes into bitMemory
- $bytes = $this->read($bytesToRead);
- // Get bits from ascii chars.
- for ($i = 0, $l = strlen($bytes); $i < $l; $i++) {
- // Get unsigned int from unreadable binary
- $decimal = unpack('C', $bytes[$i])[1];
- // Make ascii char to binary string
- $bits = decbin($decimal);
- // Fill missing bits to have full bytes
- $this->bitMemory .= str_repeat('0', 8 - strlen($bits)) . $bits;
- }
- }
- // Get amount of bits from memory
- $bits = substr($this->bitMemory, $this->bitPositon, $length);
- $this->bitPositon += $length;
- return $bits;
- }
- /**
- * Reads a string until end of file or length is reached
- *
- * @param Integer The maximum length to read
- * @return String The string between current offset and end of file or offset + length
- */
- public function read($length) {
- return fread($this->fileHandle, $length);
- }
- /**
- * Search for a String in a stream and return its index
- *
- * @param String The string to search for
- * @param Boolean True if you want to stay witht the reader at the current position
- * @return Integer The index of the occurrence or -1 if not found
- */
- public function indexOf($string, $keepPosition = false) {
- // Remind the current position if we need to jump back
- if ($keepPosition) {
- $currentPosition = $this->pos();
- }
- // Search for the string
- stream_get_line($this->fileHandle, $this->dataSize, $seperator);
- // Get the position where we stopped
- $index = ftell($this->fileHandle);
- // Jump back to the position befor searching if needed
- if ($keepPosition) {
- fseek($this->fileHandle, $currentPosition);
- }
- // If we are at file end we didn't found anything
- return $index == $this->dataSize ? -1 : $index;
- }
- /**
- * Jump with the pointer to a wished position
- *
- * @param Integer the position to seek to
- */
- public function seek($position) {
- fseek($this->fileHandle, $position);
- }
- /**
- * Get the current stream pointer position
- *
- * @return Integer The current pointer position
- */
- public function pos() {
- return ftell($this->fileHandle);
- }
- /**
- * Check if we are at the end of the stream
- *
- * @return Boolean True if we are the the end
- */
- public function end() {
- return feof($this->fileHandle);
- }
- }
Lets Wave ... Wir generieren die Waveform
Nun konvertieren wir die zuvor erstellte MP3 Datei in eine WAV Datei. Um den gesamten Prozess zu beschleunigen können wir hier ein paar Daten fallen lassen. Sprich wir nehmen nur einen Kanal und Resamplen auf 8 bit herunter. Würden wir das nicht machen wäre unsere WAV Datei ziemlich groß und das Erzeugen der Waveform würde, da wir uns bis zum Ende durch kämpfen, ewig dauern.
Nun validieren wir die Existenz der Datei und ob diese auch wirklich im WAV Format ist. Indem wir die ersten 4 bytes auf ihren Inhalt prüfen, welcher den Text RIFF darstellen sollte, stellen wir fest ob die Datei im WAV Format ist. Nun beginnen wir mit dem Analysieren der Datei.
Die ersten 22 bytes sind für uns nicht relevant. Um den Samples die richtige Größe zuordnen zu können, benötigen wir die Anzahl der Kanäle. Vom Header benötigen wir nun nur noch die Bitrate um die Waveform generieren zu können.
- // Convert mp3 to reduced wav file
- $command = 'lame %1$s -m m -S -f -b 16 --resample 8 %2$s.mp3 && lame %2$s.mp3 -S --decode %2$s.wav';
- @exec(sprintf($command, $convertedFileName, $tempName));
- $fileHandle = fopen($tempName . '.wav', 'r');
- // Check if we have a valid wav file
- if ($fileHandle && fread($fileHandle, 4) == 'RIFF') {
- $binary = new BinaryReader($fileHandle);
- // Skip 22 bytes of header
- $binary->seek(22);
- // Read 2 bytes inversed as integer
- $channels = $binary->readInversedNumber(2);
- // Skip again unnessesary bytes
- $binary->seek(34);
- // Read 2 bytes inversed as integer
- $bitRate = $binary->readInversedNumber(2);
- // Seek to end of header
- $binary->seek(44);
- ...
Für das analysieren müssen wir die Länge eines Samples kennen.
Also berechnen wir die Bytes pro Frame und das Verhältnis um die Streuung zu erhöhen und die Leistung zu verbessern.
Sprich wir analysieren, obwohl wir die Datei bereits verlustbehaftet konvertiert haben, immer noch nicht jedes Sample.
Lets paint it - Wir Zeichen die Waveform
Wir definieren nun unsere Variablen und Initialisieren das Bild worin wir Zeichnen wollen. Das Bild wird mit der Höhe von 255 Pixeln und der Breite eines Fünftels der Frameanzahl erstellt. Die Höhe wird auf 255 gesetzt da dies hier der maximale Wert eines Samples ist und wir so unsere Werte nicht in ein Verhältnis setzen müssen. Nun iterieren wir so lange bis wir entweder das Ende des Streams oder die Anzahl der Frames erreicht haben. Auch jetzt versuchen wir die Performance zu verbessern indem wir nur jedes 5te Frame analysieren. Jetzt merkt ihr vielleicht wieso unser Bild die Breite von einem Fünftel der Frameanzahl hat. Wenn ihr eine detailliertere Waveform haben wollt, müsst ihr diesen Wert runter setzen. Wenn ihr das macht, wird das Erzeugen der Waveform aber auch dementsprechend länger dauern.
Bei jeder Iteration ermitteln wir den Wert des aktuellen Samples um ihn später in die Waveform zeichnen zu können. Dafür bilden wir aus den Bytes des Samples einen Wert welchen wir dann auf ein byte reduzieren. Bei einem 16 Bit Sample (2 byte) bilden wir aus 2 bytes einen unsigned short int Wert und reduzieren diesen wieder auf ein byte. Nun haben wir einen Wert zwischen 0 und 255, also die Größe eines unsigned char oder auch byte gennant, welcher den Endpunkt einer vertikalen Linie in der Waveform darstellt. Dieser Wert wird nun umgekehrt und schon haben wir die zweite Y Koordinate für unsere Waveform. Als Beispiel haben wir bei einem ursprünglichen Wert von 249, die Y Koordinate 6 als Startpunkt und die Y Koordinate 249 als Endpunkt. Nun zeichnen wir diese Linie auf unser Bild, überspringen 5 Frames und fangen wieder an den Wert des Samples zu ermitteln.
Bei jeder Iteration wird die X Koordinate inkrementiert und irgendwann sind wir am Ende der Audiodatei.
Das Ergebnis
Nun haben wir von Anfang bis Ende viele Samples gelesen und unsere Waveform gezeichnet. Diese muss nur noch gespeichert werden und wir sind fertig. Das ganze kann man natürlich auch nach belieben anpassen. Z.B. kann man die Performance verbessern in dem man die $ratio Variable erhöht und weniger Samples (z.B. jedes 10te) analysiert. Im Gegenzug könnte man die Waveform detaillierter zeichnen wenn man mehr Samples analysiert. Man könnte die Darstellung der Waveform ändern in dem man nicht von einer zentrierten Linie, sondern von z.B. einem Grafen ausgeht. Wenn ihr hier im Dateisystem eine Audio Datei hoch ladet, werdet ihr das Ergebnis dieser Funktion sehen.
- /**
- * Generate a waveForm image from the current audio object
- *
- * @return String The webaccessible URL to the waveForm or false
- */
- protected function generateWaveform() {
- $waveFormName = $this->getLocation('waveform.png');
- $convertedFileName = $this->getLocation('converted.mp3');
- $tempName = FILE_DIR . 'files/wcf' . rand(0, 9999);
- // Check if we already generated the waveform
- if (is_file($waveFormName)) {
- return $this->getUrl('waveform.png');
- }
- // Check if we have something to generate the waveForm
- if (!is_file($convertedFileName)) {
- return false;
- }
- // Convert mp3 to reduced wav file
- $command = 'lame %1$s -m m -S -f -b 16 --resample 8 %2$s.mp3 && lame %2$s.mp3 -S --decode %2$s.wav';
- //die(var_dump(sprintf($command, $convertedFileName, $tempName)));
- @exec(sprintf($command, $convertedFileName, $tempName));
- $fileHandle = fopen($tempName . '.wav', 'r');
- // Check if we have a valid wav file
- if ($fileHandle && fread($fileHandle, 4) == 'RIFF') {
- $binary = new BinaryReader($fileHandle);
- // Skip 22 bytes of header
- $binary->seek(22);
- // Read 2 bytes inversed as integer
- $channels = $binary->readInversedNumber(2);
- // Skip again unnessesary bytes
- $binary->seek(34);
- // Read 2 bytes inversed as integer
- $bitRate = $binary->readInversedNumber(2);
- // Seek to end of header
- $binary->seek(44);
- // Calculate the amount of bytes per frame
- $bytesPerFrame = $bitRate / 8;
- $ratio = $channels == 2 ? 40 : 80;
- // Remove header size and divide by bytesPerFrame + channelRatio
- $frameAmount = ($binary->dataSize - 44) / ($ratio + $bytesPerFrame) + 1;
- // Initialize the waveForm image
- $adapter = ImageHandler::getInstance()->getAdapter();
- $adapter->createEmptyImage($frameAmount / 5, 255);
- $adapter->setColor(255, 255, 255);
- $adapter->drawRectangle(0, 0, $adapter->getWidth(), $adapter->getHeight());
- $adapter->setColor(0, 0, 0);
- $x = $frame = 0;
- while (!$binary->end() && $x < $frameAmount) {
- // Just handle every 5th frame
- if ($frame++ % 5 != 0) {
- $binary->seek($binary->pos() + $ratio + $bytesPerFrame);
- continue;
- }
- $bytes = $binary->readString($bytesPerFrame);
- // Get the value for a 8-bit sample
- $value = $bytes[0];
- if ($bytesPerFrame != 1) {
- // Check if the value is signed or not
- $temp = ord($bytes[1]) & 128 ? 0 : 128;
- // Fulfill the byte if it was signed
- $temp = (ord($bytes[1]) & 127) + $temp;
- // Get 16 bit value and ensure it not exceed the value 255
- $value = floor(($bytes[0] + ($temp * 256)) / 256);
- }
- // Invert value
- $y = 255 - $value;
- $x++;
- // Draw line on waveForm
- $adapter->drawRectangle($x, $y, $x, $value);
- }
- // Write image to file
- $adapter->setTransparentColor(0, 0, 0);
- $adapter->writeImage($adapter->getImage(), $waveFormName);
- @unlink($tempName . '.wav', $tempName . '.mp3');
- @fclose($fileHandle);
- return $this->getUrl('waveform.png');
- }
- @unlink($tempName . '.wav', $tempName . '.mp3');
- @fclose($fileHandle);
- return false;
- }
- /**
- * @see file\data\file\type\IFileType->getAttributes()
- */
- public function getAttributes() {
- // Detect if we have mplayer installed
- $commandPath = @exec('which "mplayer"');
- if ($commandPath) {
- // Prepare the convert command using mplayer
- $command = '%1$s -vo null -vc null -ao pcm:fast %2$s &&
- lame -m s audiodump.wav %3$s &&
- rm audiodump.wav';
- // Get the path to the new file
- $fileName = $this->getLocation('converted.mp3');
- // Execute the command
- @exec(sprintf($command, $commandPath, $this->getLocation(), $fileName));
- }
- return array();
- }