Tech 3 min read

Revisiting a Web Audio Player I Built 10 Years Ago

While cleaning up an old server, I found the source code for a music player I built 10 years ago. It was a preview player for an anime ending theme — copyrighted material — so I put a lot of effort into protecting the audio.

Looking back at the old code, I’m amazed at how complex it was. Things I can do in one line today took 200+ lines back then.

The Architecture Back Then

Four files.

  • index.html — jQuery 1.8.3-based UI
  • api.php — Path obfuscation + one-time token generation
  • audio.php — Token validation + iOS Range Request handling
  • audio.js — Blob URL conversion + all event monitoring + fallback handling

Problems I Had to Solve

To stop people from accessing the audio URL directly, I used an API to issue one-time tokens.

function get_unique_id($t)
{
    $pin = /* generate 32 random chars */;
    $str = $pin.'_'.$t;
    return 'r='.$pin.'&u='.hash_hmac('sha256',$str,false).'&t='.$t;
}

On top of that, the path was split into an ASCII code array.

function splitPath($path, $t)
{
    $data = array('a'=>array(), 'b'=>array());
    $strs = preg_split("//u", $path, -1, PREG_SPLIT_NO_EMPTY);
    foreach($strs as $key => $val)
    {
        $data['a'][] = ord($val);  // convert each character to ASCII code
    }
    // ...
    return $data;
}

The JS side reconstructed it before accessing.

// Reconstruct string from ASCII code array
var d3 = '';
for(var i = 0; i < d1.length; i++){
    d3 += String.fromCharCode(d1[i]);
}

One look in DevTools and it’s obvious, but at the time this was considered “sufficient protection.”

2. Preventing Downloads

Audio was fetched via XHR as binary and converted to a Blob URL.

var xhr = new XMLHttpRequest();
xhr.responseType = 'arraybuffer';
xhr.onload = function() {
    var blob = new Blob([new Uint8Array(this.response)], {'type':'audio/mp3'});
    var url = URL.createObjectURL(blob);
    audio.src = url;  // blob://... format URL
};

This way, the original URL doesn’t appear in browser history or cache. When the page is closed, the Blob URL is invalidated.

3. Playback Failing on iOS

This was the hardest part. iOS Safari at the time always sent a Range Request when playing audio.

GET /audio.php HTTP/1.1
Range: bytes=0-

Returning all data with 200 OK caused playback to silently fail. The response had to be 206 Partial Content.

if(isset($_SERVER['HTTP_RANGE']))
{
    list($dummy, $range) = explode('=', $_SERVER['HTTP_RANGE']);
    list($start, $end) = explode('-', $range);
    if(empty($end)){ $end = filesize($music_path) - 1; }
    $length = $end - $start + 1;

    header('HTTP/1.1 206 Partial Content');
    header('Accept-Ranges: bytes');
    header('Content-Length: ' . $length);
    header('Content-Range: bytes ' . $start . '-' . $end . '/' . filesize($music_path));

    $handle = fopen($music_path, 'rb');
    fseek($handle, $start);
    echo fread($handle, $length);
    fclose($handle);
}

The catch: downloader tools also use Range Requests for segmented downloads. So I added a branch to reject Range Requests that lacked a specific parameter.

4. Browser Inconsistencies

The Audio element behaved differently across browsers. I ended up monitoring every event to debug.

$(this.audio).on({
    'loadstart': function(){ consoleLine('loadstart'); },
    'progress': function(){ consoleLine('progress'); },
    'suspend': function(){ consoleLine('suspend'); },
    'abort': function(){ consoleLine('abort'); },
    'error': function(){ consoleLine('error'); },
    'emptied': function(){ consoleLine('emptied'); },
    'stalled': function(){ consoleLine('stalled'); },
    'loadedmetadata': function(){ consoleLine('loadedmetadata'); },
    'loadeddata': function(){ consoleLine('loadeddata'); },
    'canplay': function(){ consoleLine('canplay'); },
    'canplaythrough': function(){ consoleLine('canplaythrough'); },
    // ... many more
});

How I’d Write It Today

For a basic audio player, this is all you need.

<audio controls src="/audio/song.mp3"></audio>

For custom UI, something like this:

const audio = document.querySelector('audio');
const btn = document.querySelector('#playBtn');

btn.addEventListener('click', () => {
    audio.paused ? audio.play() : audio.pause();
});

What About the iOS Range Request Issue?

Web servers handle it automatically. nginx, Apache, Vercel, Cloudflare — all of them handle Range Requests correctly for static files. No need to implement it in PHP anymore.

What If Protection Is Still Needed?

Honestly, client-side protection has limits. DevTools exposes everything anyway.

Modern approaches:

  • Signed URLs — CloudFront Signed URLs etc., valid for a limited time
  • HLS/DASH streaming — Deliver audio in fragments; can be combined with DRM
  • Just serve a short preview — Accept the tradeoff and deliver 30 seconds

10 years ago vs. today:

Item10 Years AgoToday
Basic playbackjQuery + complex JS<audio controls>
iOS supportRange Request implemented in PHPServer handles it automatically
Direct link preventionToken + obfuscationSigned URL or give up
Custom UIA real hassleEasy with plain JS

Browser standardization improved, server-side handling became automatic. Web development got a lot easier.

And honestly, for copyrighted music previews these days — just embed YouTube. Copyright management is on YouTube’s side, and you can even monetize. Building your own audio hosting only makes sense for podcasts or doujin audio.