Sunday, March 28, 2010

Rebroadcast RealAudio streams as mp3 via HTTP

The problem

iTunes + Remote app for iPod touch + AirPort Express = best kitchen radio ever. Most of my favorite public radio stations offer MP3 streams over HTTP. My wife, however, has a soft spot in her heart for certain stations that insist on broadcasting RealAudio over RTSP. How to get these to stream to the Airport Express in the kitchen?

The solution

The ingredients:

  • Mplayer built with support for RealAudio/COOK (an exercise for the reader)
  • LAME mp3 encoder
  • Python!

The rudimentary solution

Mplayer, when built properly, can stream and decode RealAudio over RTSP. In the simplest form, this looks something like:

url="rtsp://stream2.rbb-online.de/encoder/antenne-live.ra"
mplayer -cache 48 -vo null -vc null -ao pcm:waveheader -ao pcm:file=foo.wav "${url}"

This is nice, but ideally we’d like to transcode a stream on the fly. Named pipes to the rescue!

url="rtsp://stream2.rbb-online.de/encoder/antenne-live.ra"
mkfifo dump.pipe
mkfifo transcode.pipe
mplayer -cache 48 -vo null -vc null -ao pcm:waveheader -ao pcm:file=dump.pipe "${url}" &
lame -r dump.pipe transcode.pipe

The transcoded MP3 can be read from transcode.pipe. Now, to wrap the whole kit an caboodle up in Python.

The full solution

This all gets wrapped up in a Python HTTP request handler based on SimpleHTTPRequestHandler. The handler forks instances of mplayer and lame and creates the named pipes on the fly. When the client closes the stream, the child processes are cleaned up automatically.

from subprocess import *
from SimpleHTTPServer import SimpleHTTPRequestHandler
from BaseHTTPServer import HTTPServer
import os,tempfile,signal,urlparse

class StreamHandler(SimpleHTTPRequestHandler):
  dumper = None
  default_url = "rtsp://stream2.rbb-online.de/encoder/antenne-live.ra"

  def parse_qs(self,query_string):
    values = [pair.split('=') for pair in query_string.split('&')]
    return dict(values)

  def do_GET(self):
    """Serve a GET request."""
    f = self.send_head(True)
      if f:
      try:
        self.copyfile(f, self.wfile)
        f.close()
      except:
        self.cleanup()
    self.cleanup()

  def __del__(self):
    self.cleanup()

  def cleanup(self):
    if self.dumper is not None:
      self.log_message('Stream terminated, cleaning up...')
      # mplayer forks a child, so we should go all Agamemnon via SIGINT 
      os.kill(self.dumper.pid,signal.SIGINT)
      os.kill(self.transcoder.pid,signal.SIGINT)
      # reap dead children
      self.dumper.wait()
      self.transcoder.wait()
      # remove tempfiles
      os.remove(self.dump_pipe)
      os.remove(self.trans_pipe)
      os.rmdir(self.tmpdir)
      self.fnull.close()
      # unset instance variables
      self.dumper = None
      self.transcoder = None
      self.dump_pipe = None
      self.trans_pipe = None
      self.tmpdir = None

  def send_head(self,is_get=False):
    if is_get:
      f = open(self.make_stream(),'rb')
    else:
      f = open(os.devnull,'r')
      self.send_response(200)
      self.send_header("Content-type", "application/octet-stream")
      self.end_headers()
      return f

  def make_stream(self):
    qs = urlparse.urlparse(self.path).query
    if len(qs) > 0:
      query = self.parse_qs(qs)
    else:
      query = dict()
    url = query.get('url',self.default_url)
    self.log_message('Making a stream for %s',url)
    self.tmpdir = tempfile.mkdtemp()
    self.dump_pipe = os.path.join(self.tmpdir,'dump.pipe')
    self.trans_pipe = os.path.join(self.tmpdir,'transcode.pipe')
    os.mkfifo(self.dump_pipe)
    os.mkfifo(self.trans_pipe)
    self.fnull = open(os.devnull, 'w')
    self.dumper = Popen(['mplayer','-cache','64','-vc','null','-vo','null','-ao','pcm:file=%s' % self.dump_pipe,url],stdout=self.fnull,stderr=self.fnull)
    self.transcoder = Popen(['lame','-r','--preset','fast','standard',self.dump_pipe,self.trans_pipe],stdout=self.fnull,stderr=self.fnull)
    return self.trans_pipe

def run():
  httpd = HTTPServer(('',8001), StreamHandler)
  sa = httpd.socket.getsockname()
  print "Serving HTTP on", sa[0], "port", sa[1], "..."
  httpd.serve_forever()

if __name__ == "__main__":
  run()

To add a transcoded radio stream, make an M3U playlist like this:

#EXTM3U
#EXTINF:0,Antenne Brandenburg Frankenstream
http://localhost:8001/?url=rtsp://stream2.rbb-online.de/encoder/antenne-live.ra

and drag it into iTunes. Voila, (fake) RealAudio/RTSP support in iTunes!

Subtleties

There are a few potential stumbling blocks to be aware of:

  • When streaming unlimited data over HTTP, you should not set the Content-Length header, as this will cause the client to close the connection after reading a set number of bytes rather than streaming forever.
  • When streaming and transcoding, Mplayer forks a child process to handle some of the work. If killed with SIGINT, the parent process will kill the child, but if killed with SIGKILL, it will exit immediately, leaving a zombie Mplayer every time the stream is stopped and restarted.

No comments: