Monday, 11 February 2019

Multiroom audio using DLNA devices

The story so far

Initial investigations to stream audio from MPD to multiple DLNA devices using Linux centred on finding a Linux utility which already implemented the necessary functionality.  When that failed a node.js solution was investigated.  Again I came to a dead end - although it turned out in retrospect to be a working solution.  On the basis that a programmed solution is required it was sensible to concentrate on python as there is a huge amount of software available, it is easy to implement and generally easier to understand the programs.

Looking for a python solution

Initially I looked for linux-based python audio players to familiarise myself with considerations for playing files and streams using python.  There wasn't as much choice as I expected, pygame, pyaudio and pyglet looked more complicated than they needed to be but playsound was a good simple solution to play files.

Looking for a program to play on dlna devices didn't have many candidates but nanodlna seemed promising.  It is a command line utility written by Gabriel Magno which lists DLNA devices and plays a video.  It was installed using pip but didn't work initially.  I don't have a solid python programming environment so there wasn't much thought put into installation.  The source programs were simple and I adjusted them so I could run nanodlna using the python3 command.  For the first test I just specified a music track and no destination which causes nanodlna to pick a device at random.  I was surprised and excited that it worked perfectly and played good quality music via a jongo.

nanodlna investigation - request content

nanodlna comprises 4 source files:
cli.py the main program which parses the command and calls other sources.
devices.py identify dlna devices attached to local network
streaming.py reads a music file so it can be played
dlna.py sends instructions to DLNA device for playing music.

I hacked the code extensively to see how it works.  In particular I printed variables and replaced variables by their values in the program to see exactly what information was used. dlna.py sends 2 http instructions to a Jongo.  The first includes the URI of the the file to be played and the second is an instruction to start playing it.  The standard python http request urllib.request is used to send the information to the jongo.  It comprises an http header and an xml body formatted for SOAP.

    headers = {
        'Connection': 'close',
         'Content-Type': 'text/xml; charset="utf-8"',
         'Content-Length': '465',
         'SOAPACTION': '"urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI"'}
    action_data = b'<?xml version=\'1.0\' encoding=\'utf-8\'?>\n \
        <s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" \
        xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">\n \
        <s:Body>\n \
        <u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">\n \
        <InstanceID>0</InstanceID>\n \
        <CurrentURI>http://192.168.0.33:9000/file_video/kylie.mp3</CurrentURI>\n \
        <CurrentURIMetaData></CurrentURIMetaData>\n \
        </u:SetAVTransportURI>\n \
        </s:Body>\n</s:Envelope>\n'

    headers = {
        'Connection': 'close',
        'Content-Type': 'text/xml; charset="utf-8"',
        'SOAPACTION': '"urn:schemas-upnp-org:service:AVTransport:1#Play"',
        'Content-Length': '337'}
    action_data = b'<?xml version=\'1.0\' encoding=\'utf-8\'?>\n \
        <s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" \
            xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">\n \
          <s:Body>\n \
            <u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">\n \
              <InstanceID>0</InstanceID>\n \
              <Speed>1</Speed>\n \
            </u:Play>\n \
          </s:Body>\n \
        </s:Envelope>\n'

This is extremely helpful. I could have found the same information by looking at TCP traffic using tcpdump or wireshark but this is much easier to understand.  All requests to Jongos to play a track are identical, the only information which changes in these requests is the name of the track.
Previously I supposed that the python program streamed all chunks of a track to the Jongo. Clearly what actually happens is that the python program only has to send the name of the track to be played in a SetAVTransportURI request and then send a Play request.  It doesn't seem necessary to know any more about the requests but Gabriel Magno provides a link to the official spec.  The DLNA control device (remote/phone/RPI etc) doesn't need to communicate or even be switched on whilst the Media Renderer (Jongo) is playing music.  The controller is only required to change instructions.

The program streaming.py uses a framework called Twisted Web to load a file into an internal web server so that it can be streamed.  In practice I don't need that I have a lot of files already hosted on RPI lighttpd webservers which I can use.  I was thus able to dispense with al the Twisted Web functionality and choose a URL directly.

nanodlna investigation - addressing

These dlna requests need to be sent to one or more Jongos.  The address information discovered by nanodlna in devices.py is:

#    print("video_data");print(video_data);
#    deviceJon = {'st': 'urn:schemas-upnp-org:service:AVTransport:1', \
#         'friendly_name': 'Jon', 'hostname': '192.168.0.102', \
#         'action_url': 'http://192.168.0.102:48567/Control/org.mpris.MediaPlayer2'+ \
#        '.mansion/RygelAVTransport', \
#         'location': 'http://192.168.0.102:48567/93b2abac-cb6a-4857-b891-0019f5844dd8.xml'}
#    deviceJoe = {"st": "urn:schemas-upnp-org:service:AVTransport:1",
#       "action_url": "http://192.168.0.122:55746/Control/org.mpris.MediaPlayer2.mansion/RygelAVTransport",
#       "friendly_name": "Joe",
#       "hostname": "192.168.0.122",
#       "location": "http://192.168.0.122:55746/93b2abac-cb6a-4857-b891-0019f584c8f8.xml"
#       }

From this we can see that a "random" port is used on the Jongo as the destination and there is some sort of file structure within the jongo. The port number changes from time to time, perhaps when a Jongo is rebooted.  This information which is discovered using SSDP protocol.  It was a simple job to change the program to send requests to two Jongos and I ascertained that they synchronise quite well.  Listening to them in the same room gives a slight echo but was not irritating.

Streaming - curl

At this stage I stumbled across an excellent article which shows how to submit requests to uPnP devices using the Linux command line utility curl. The example provided closely resembles what I have found with python. I was quickly able to setup simple scripts / files assisted by this blog to allow me to send various requests to Jongos much more quickly than amending a python program each time.

In particular it seemed reasonable that the jongo should accept stream URLs.  I tried a radio station and it worked fine.  I tried an httpd stream from MPD and I had exactly the same problem as previously with node.js - the stream started for a moment then stopped.  This time around, I realised that MPD was the problem and I looked more closely at setting up MPD shout cast streaming.  I found a good RPI centric article and realised I needed to install icecast before MPD shout output would work.  Once I had done this jongos played my mpd out perfectly.  The solution doesn't require pulseaudio at all so it is simpler.  I did note that changing tracks or streams within the muse application requires Jongo streams to be restarted.

uPnP discovery  - udp multicast investigation

upnp hacks provides a good explanation of how uPnP devices find each other on a LAN.  A new device uses the UDP protocol to send a multicast request which all others must reply with a UDP unicast message.  Ideally we would like a linux command line tool to do this for us. netcat can easily send requests but I couldn't find an easy way to view the responses.
The most common solution appears to use Python UDP capabiilities to send UDP multicast and receive replies just like devices.py in nanodlna. The electric monk provides a lot more detail.

The best way to carry out a command line discovery is gssdp-discover from gupnp-tools:
gssdp-discover -i wlan0 --timeout=3 --target=urn:schemas-upnp-org:device:MediaRenderer:1
This will show media render devices on the LAN :
Using network interface wlan0
Scanning for resources matching urn:schemas-upnp-org:device:MediaRenderer:1
Showing "available" messages
resource available
  USN:      uuid:93b2abac-cb6a-4857-b891-0019f5844dd8::urn:schemas-upnp-org:device:MediaRenderer:1
  Location: http://192.168.0.102:48567/93b2abac-cb6a-4857-b891-0019f5844dd8.xml
resource available
  USN:      uuid:93b2abac-cb6a-4857-b891-0019f584c8f8::urn:schemas-upnp-org:device:MediaRenderer:1
  Location: http://192.168.0.122:55746/93b2abac-cb6a-4857-b891-0019f584c8f8.xml
resource available
  USN:      uuid:93b2abac-cb6a-4857-b891-0019f584dcf0::urn:schemas-upnp-org:device:MediaRenderer:1
  Location: http://192.168.0.118:56405/93b2abac-cb6a-4857-b891-0019f584dcf0.xml


No comments:

Post a Comment