Search code examples
phpaudioffmpeg

Convert pcm_s16le audio to mp3 (in php with ffmpeg/sox/...)?


I have a bin and a cue file (CD image) and I am making a website to be able to listen to my music. I managed to split the tracks and make a (44 bytes) wav header so my browser can read the audio but the wav files are too big to be read on an unstable connection (it's 1411kbps where an mp3 would be 320kbps or lower!).

At first, is there a way to convert the audio to mp3 without making a temporary file in php? Maybe ffmpeg or sox could help?

Secondly, I would like to be able to satisfy partial content requests (so I can skip without loading the whole file). I have already succeeded it for the wav files and I would need to know a few things to do it with mp3:

  • How to make a mp3 header?
  • How to determine the file length?
  • How to convert pcm_s16le audio to mp3 without headers at a constant bit rate?

I tried searching online but I did not find what I was hoping for. I hope you can help me.


Solution

  • For my first question, to convert wav to mp3 in php without creating a temporary file, I used:

    passthru(dd if="$bin_path" bs=1 skip=$skip_bytes count=$count_bytes | ffmpeg -ss 0 -f s16le -ar 44100 -ac 2 -i pipe:0 -codec:a libmp3lame -b:a 320k -ac 2 -joint_stereo 0 -compression_level 0 -write_id3v1 0 -id3v2_version none -f mp3 -);
    

    where $bin_path is the path to your binary file (you need to escape the " if there is any in the path).
    $skip_bytes is 44100 * 4 * song_start (s)
    $count_bytes is 44100 * 4 * song_duration (s)

    Here is a breakdown of the FFmpeg command:
    For the input:
    -ss 0 start from 0.0s even if there is no audio (silence)
    -f s16le -ar 44100 -ac 2 tell that the input is pcm_s16le (-f s16le) stereo (-ac 2) with a sampling rate of 44100 Hz (-ar 44100)
    -i pipe:0 the input is comming from the pipe
    For the output:
    -codec:a libmp3lame mp3 encoding
    -b:a 320k bitrate (320kbps)
    -ac 2 -joint_stereo 0 2-channels stereo
    -compression_level 0 use constant bit rate
    -id3v2_version none skip the ID3 header
    - output to sdin

    For the second part of your question, mp3 are nothing like wav files and predicting their size is nearly impossible. However, I managed to do it anyway by making the song lasting a multiple of 1.28 seconds (mp3 (version 1, layer III) have an internal structure that repeats every 1.28 seconds).
    Here is how I've done it:

    $bin_path = "/path/to/binary.bin";
    $song_start = "182.12"; // song starts after 3m0.12s 
    $song_duration = "176.14"; // and lasts 2m56.02s
    
    $skip_bytes = 44100 * 4 * $song_start;
    $count_bytes = 44100 * 4 * $song_duration;
    
    $chunk_size_mp3 = 51200; // Size (in bytes) for 1.28s of 320kbps MP3
    $chunk_size_wav = 225792; // Size (in bytes) for 1.28s of WAV
    $file_size = (floor($song_duration/1.28)+1) * $chunk_size_mp3;
    
    $need_exit = false;
    
    //--------------------HTTP HEADERS--------------------
    ob_get_clean();
    header('Content-Type: audio/mpeg');
    header('Cache-Control: no-cache', true);
    header('Expires: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT'); // Expire now!
    header('Last-Modified: ' . gmdate('D, d M Y H:i:s', @filemtime($file_path)) . ' GMT' );
    $start_index = 0;
    $end_index = $file_size - 1;
    header('Accept-Ranges: bytes');
    
    if(isset($_SERVER['HTTP_RANGE'])){
    
        $c_start = $start_index;
        $c_end = $end_index;
    
        list(, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2);
        if(strpos($range, ',') !== false){
            header('HTTP/1.1 416 Requested Range Not Satisfiable');
            header("Content-Range: bytes $start_index-$end_index/$file_size");
            $need_exit = true;
        }
        
        if(!$need_exit){
            if($range == '-'){
                $c_start = $file_size - substr($range, 1);
            }else{
                $range = explode('-', $range);
                $c_start = $range[0];
    
                $c_end = (isset($range[1]) && is_numeric($range[1])) ? $range[1] : $c_end;
            }
            $c_end = ($c_end > $end_index) ? $end_index : $c_end;
            if($c_start > $c_end || $c_start > $file_size - 1 || $c_end >= $file_size){
                header('HTTP/1.1 416 Requested Range Not Satisfiable');
                header("Content-Range: bytes $start_index-$end_index/$file_size");
                $need_exit = true;
            }
            
            if(!$need_exit){
                if($c_start <= $chunk_size_mp3){
                    $start_index = 0;
                }else{
                    $start_index = $c_start;
                }
                if($c_end <= $chunk_size_mp3){
                    $end_index = $chunk_size_mp3 - 1;
                }else{
                    $end_index = $c_end;
                }
                $length = $end_index - $start_index + 1;
                header('HTTP/1.1 206 Partial Content');
                header('Content-Length: ' . $length);
                header("Content-Range: bytes $start_index-$end_index/".$file_size);
            }
        }
    }else{
        header('Content-Length: ' . $file_size);
    }
    //--------------------HTTP HEADERS--------------------
    
    //-----------------------STREAM-----------------------
    if(!$need_exit){
        $i = $start_index;
        if($start_index === 0){
            $mp3_data = shell_exec("php /path/to/dd.php \"$bin_path\" $skip_bytes ".($chunk_size_wav*2)." 0 | ffmpeg -ss 0 -f s16le -ar 44100 -ac 2 -i pipe:0 -codec:a libmp3lame -b:a 320k -ac 2 -joint_stereo 0 -compression_level 0 -write_id3v1 0 -id3v2_version none -f mp3 -");
            for($j = 0; $j < $chunk_size_mp3; $j++){
                echo $mp3_data[(int)$j];
            }
            $i += $chunk_size_mp3;
        }
        while($i <= $end_index){
            $start_chunk_number = floor($i/$chunk_size_mp3)-1;
            $end_chunk_number = $start_chunk_number + 2;
            $start_wav_byte = $skip_bytes + $start_chunk_number * $chunk_size_wav;
            $end_wav_byte = $start_wav_byte + 3 * $chunk_size_wav - 1;
            if($end_wav_byte > $skip_bytes + $count_bytes - 1){
                $empty_bytes = $end_wav_byte - ($skip_bytes + $count_bytes - 1);
                $end_wav_byte = $skip_bytes + $count_bytes - 1;
            }else{
                $empty_bytes = 0;
            }
            $mp3_data = shell_exec("php /path/to/dd.php \"$bin_path\" $start_wav_byte ".($end_wav_byte - $start_wav_byte + 1)." $empty_bytes | ffmpeg -ss 0 -f s16le -ar 44100 -ac 2 -i pipe:0 -codec:a libmp3lame -b:a 320k -ac 2 -joint_stereo 0 -compression_level 0 -write_id3v1 0 -id3v2_version none -f mp3 -");
            $ign_mp3_bytes = $i - $start_chunk_number * $chunk_size_mp3;
            $j = $ign_mp3_bytes;
            while($i <= $end_index && $j < 2*$chunk_size_mp3){
                echo $mp3_data[(int)$j];
                $i++;
                $j++;
            }
        }
    }
    //-----------------------STREAM-----------------------
    ?>
    

    dd.php:

    <?php
    if(isset($argv[3])){
        $bin_file = $argv[1];
        $skip_bytes = (int)$argv[2];
        $count_bytes = (int)$argv[3];
        if(!isset($argv[4])){
            $empty_bytes = 0;
        }else{
            $empty_bytes = (int)$argv[4];
        }
    
        $file_size = $count_bytes;
        $file_stream = fopen($bin_file, 'rb');
        
        fseek($file_stream, $skip_bytes);
        $i = 0;
        $buffer = 102400;
        set_time_limit(0);
        while(!feof($file_stream) && $i < $count_bytes){
            $bytesToRead = $buffer;
            if(($i+$bytesToRead) > $count_bytes){
                $bytesToRead = $count_bytes - $i;
            }
            echo fread($file_stream, $bytesToRead);
            flush();
            $i += $bytesToRead;
        }
        if($empty_bytes > 0){
            for($k = 0; $k < $empty_bytes; $k++){
                echo "\x00";
            }
        }
    }
    ?>
    

    A few notes to make here:

    • mp3 have what could be seen as a header at the beginning of each frame (the smallest part of an mp3) so there is no need to worry about making one or removing it
    • I always convert 3 chunk of mp3 at a time to send the middle one because I don't know how the mp3 compression works and I believe it would prevent any stutter
    • the best option would be to extract the songs from the CD even though it would take more storage
    • VideoStream.php helped me to make the partial request part