├── .gitignore ├── requirements.txt ├── src ├── util.py ├── common.py ├── main.py ├── server.py ├── encoder.py ├── aircast.py ├── cast.py ├── broadcaster.py └── shairport.py ├── README.md ├── Vagrantfile ├── LICENSE └── provision.sh /.gitignore: -------------------------------------------------------------------------------- 1 | .vagrant 2 | .idea 3 | *.pyc -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tornado==4.4.1 2 | PyChromecast==0.7.4 3 | git+git://github.com/ains/python-flac.git#egg=flac -------------------------------------------------------------------------------- /src/util.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import fcntl 3 | import struct 4 | 5 | 6 | def get_ip_address(ifname): 7 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 8 | return socket.inet_ntoa( 9 | fcntl.ioctl(s.fileno(), 10 | 0x8915, # SIOCGIFADDR 11 | struct.pack('256s', ifname[:15]))[20:24]) 12 | -------------------------------------------------------------------------------- /src/common.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | 4 | class Config: 5 | def __init__(self, sample_rate, channels, bits_per_sample): 6 | self.sample_rate = sample_rate 7 | self.channels = channels 8 | self.bits_per_sample = bits_per_sample 9 | 10 | 11 | class StoppableThread(threading.Thread): 12 | """Thread class with a stop() method. The thread itself has to check 13 | regularly for the stopped() condition.""" 14 | 15 | def __init__(self): 16 | super(StoppableThread, self).__init__() 17 | self._stop = threading.Event() 18 | 19 | def stop(self): 20 | self._stop.set() 21 | 22 | def stopped(self): 23 | return self._stop.isSet() 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | AirCast 2 | ============ 3 | 4 | A bridge between AirPlay (via [shairport-sync](https://github.com/mikebrady/shairport-sync)) and Chromecast devices, allowing you to stream music seamlessly between your iDevices and your Chromecast or Chromecast Audio. 5 | 6 | Quick Start 7 | ------------ 8 | 9 | If you don't have dedicated hardware (e.g. a Rapsberry Pi) to run aircast on, you can run the bridge locally inside a VM 10 | on your machine using [Vagrant](https://www.vagrantup.com/). 11 | 12 | ``` 13 | $ git clone https://github.com/ains/aircast.git 14 | $ cd aircast 15 | $ vagrant up 16 | ``` 17 | 18 | The AirCast Airplay emulator should now be visible on your local network, with the same name as your chromecast device. 19 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | unless Vagrant.has_plugin?('vagrant-vbguest') 2 | puts 'Installing vagrant-vbguest...' 3 | system 'vagrant plugin install vagrant-vbguest' 4 | end 5 | 6 | 7 | Vagrant.configure(2) do |config| 8 | config.vm.box = 'ubuntu/trusty64' 9 | config.vm.network 'public_network', bridge: 'en0: Wi-Fi (AirPort)' 10 | config.vm.hostname = 'aircast' 11 | 12 | config.vm.provider 'virtualbox' do |v| 13 | v.memory = 1024 14 | v.cpus = 2 15 | end 16 | 17 | # enable the sound card on the vm 18 | config.vm.provider :virtualbox do |vb| 19 | vb.customize [ 20 | "modifyvm", :id, 21 | "--audio", "coreaudio", 22 | "--audiocontroller", "hda" 23 | ] 24 | end 25 | config.vm.provision "shell", path: "provision.sh", privileged: false 26 | end 27 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | import logging 4 | 5 | from util import get_ip_address 6 | from aircast import start_aircast 7 | 8 | logger = logging.getLogger(__name__) 9 | logging.getLogger().setLevel(logging.INFO) 10 | logging.getLogger().addHandler(logging.StreamHandler(stream=sys.stdout)) 11 | 12 | if __name__ == "__main__": 13 | parser = argparse.ArgumentParser(description='Process some integers.') 14 | parser.add_argument('--port', type=int, default=8000, 15 | help='Port on which the AirCast web server will be started') 16 | parser.add_argument('--iface', type=str, default="eth0", 17 | help='Interface on which the AirCast web server will be exposed') 18 | 19 | args = parser.parse_args() 20 | 21 | start_aircast(get_ip_address(args.iface), args.port) 22 | -------------------------------------------------------------------------------- /src/server.py: -------------------------------------------------------------------------------- 1 | import tornado.ioloop 2 | import tornado.web 3 | 4 | STREAM_ROUTE = "/stream.flac" 5 | 6 | 7 | class MainHandler(tornado.web.RequestHandler): 8 | def __init__(self, application, request, broadcaster, **kwargs): 9 | super(MainHandler, self).__init__(application, request, **kwargs) 10 | self.broadcaster = broadcaster 11 | 12 | @tornado.web.asynchronous 13 | def get(self): 14 | self.set_header("Content-Type", 'audio/flac') 15 | self.write(self.broadcaster.get_header()) 16 | self.flush() 17 | 18 | self.broadcaster.add_listener(self) 19 | 20 | def send_chunk(self, chunk): 21 | self.write(chunk) 22 | self.flush() 23 | 24 | def on_connection_close(self): 25 | self.broadcaster.remove_listener(self) 26 | 27 | on_finish = on_connection_close 28 | 29 | 30 | def make_app(broadcaster): 31 | return tornado.web.Application( 32 | [(STREAM_ROUTE, MainHandler, {"broadcaster": broadcaster})]) 33 | -------------------------------------------------------------------------------- /src/encoder.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | from flac.stream_encoder import * 4 | 5 | 6 | class BaseEncoder(StreamEncoderSetup): 7 | def __init__(self, config): 8 | kwargs = { 9 | 'sample_rate': config.sample_rate, 10 | 'channels': config.channels, 11 | 'bits_per_sample': config.bits_per_sample, 12 | 'compression_level': 8 13 | } 14 | 15 | self.channels = config.channels 16 | self.finish() 17 | StreamEncoderSetup.__init__(self, **kwargs) 18 | encoder_init(self.init_stream) 19 | 20 | def process_pcm(self, pcm_data): 21 | fmt = "%ih" % (len(pcm_data) / self.channels) 22 | self.process_interleaved(struct.unpack(fmt, pcm_data)) 23 | 24 | def __encoder_read__(self, bytes): 25 | return False 26 | 27 | def __encoder_write__(self, buffer, samples, current_frame): 28 | return False 29 | 30 | def __encoder_seek__(self, offset): 31 | return False 32 | 33 | def __encoder_tell__(self): 34 | return False 35 | 36 | def __del__(self): 37 | self.finish() 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Ainsley Escorce-Jones 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/aircast.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from Queue import Queue 3 | 4 | import tornado.ioloop 5 | 6 | from broadcaster import Broadcaster 7 | from cast import Caster 8 | from common import Config 9 | from server import make_app, STREAM_ROUTE 10 | from shairport import Shairport 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def start_aircast(hostname, port): 16 | sample_queue = Queue() 17 | io_loop = tornado.ioloop.IOLoop.current() 18 | 19 | stream_url = "http://{}:{}{}".format(hostname, port, STREAM_ROUTE) 20 | caster = Caster(stream_url) 21 | 22 | config = Config(sample_rate=44100, channels=2, bits_per_sample=16) 23 | broadcaster = Broadcaster(config, sample_queue, io_loop) 24 | shairport = Shairport(caster.device_name, config, sample_queue) 25 | app = make_app(broadcaster) 26 | 27 | def shairport_status_cb(event, _): 28 | if event == 'playing': 29 | caster.start_stream() 30 | 31 | shairport.add_callback(shairport_status_cb) 32 | 33 | broadcaster.start() 34 | shairport.start() 35 | app.listen(port) 36 | 37 | logger.info("AirCast ready. Advertising as '%s'", caster.device_name) 38 | try: 39 | io_loop.start() 40 | except KeyboardInterrupt: 41 | pass 42 | finally: 43 | io_loop.stop() 44 | shairport.stop() 45 | broadcaster.stop() 46 | 47 | shairport.join(5) 48 | broadcaster.join(5) 49 | -------------------------------------------------------------------------------- /src/cast.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pychromecast 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | class Caster: 8 | def __init__(self, stream_url): 9 | self.stream_url = stream_url 10 | 11 | logger.info("Searching for Chromecast devices...") 12 | chromecast_list = pychromecast.get_chromecasts_as_dict().keys() 13 | logger.debug("Found Chromecasts: %s", chromecast_list) 14 | 15 | if not chromecast_list: 16 | raise RuntimeError("Unable to find a Chromecast on the local network.") 17 | 18 | chromecast_name = chromecast_list[0] 19 | if len(chromecast_list) > 1: 20 | logger.warn("Multiple Chromecast devices detected, using defaulting to Chromecast '%s'", chromecast_name) 21 | 22 | logger.info("Connecting to Chromecast '%s'", chromecast_name) 23 | self.chromecast = pychromecast.get_chromecast( 24 | friendly_name=chromecast_name) 25 | self.chromecast.wait() 26 | logger.info("Connected to Chromecast '%s'", chromecast_name) 27 | 28 | def start_stream(self): 29 | logger.info("Starting stream of URL %s on Chromecast '%s'", 30 | self.stream_url, self.device_name) 31 | 32 | self.chromecast.quit_app() 33 | 34 | mc = self.chromecast.media_controller 35 | mc.play_media(self.stream_url, 'audio/flac', stream_type="LIVE") 36 | 37 | @property 38 | def device_name(self): 39 | return self.chromecast.device.friendly_name 40 | -------------------------------------------------------------------------------- /provision.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | #make sure the vagrant user is in the audio group 3 | sudo usermod -a -G audio vagrant 4 | 5 | #install the newest alsa kernel modules 6 | sudo apt-add-repository ppa:ubuntu-audio-dev/alsa-daily 7 | sudo apt-get update 8 | sudo apt-get install oem-audio-hda-daily-dkms 9 | 10 | #reload sound module 11 | sudo modprobe snd-hda-intel 12 | 13 | sudo apt-get install -yq git wget autoconf libtool libdaemon-dev libasound2-dev libpopt-dev libconfig-dev avahi-daemon libavahi-client-dev libssl-dev libsoxr-dev alsa-utils 14 | 15 | #also do https://wiki.ubuntuusers.de/Soundkarten_konfigurieren/HDA?redirect=no 16 | sudo apt-get -yq remove --purge alsa-base pulseaudio 17 | sudo apt-get -yq install alsa-base pulseaudio 18 | sudo alsa force-reload 19 | echo "options snd-hda-intel model=3stack" | sudo tee -a /etc/modprobe.d/alsa-base.conf 20 | 21 | #install shairport-sync 22 | git clone --depth=1 https://github.com/mikebrady/shairport-sync.git 23 | cd shairport-sync 24 | autoreconf -i -f 25 | ./configure --with-alsa --with-avahi --with-ssl=openssl --with-metadata --with-soxr --with-stdout 26 | make 27 | sudo make install 28 | 29 | #install python dependencies 30 | sudo apt-get -yq install python-dev python-pip 31 | sudo apt-get -yq install flac libflac-dev 32 | sudo pip install -r /vagrant/requirements.txt 33 | 34 | sudo apt-get -yq autoremove 35 | 36 | # Install supervisor to auto start Aircast 37 | sudo apt-get -yq install supervisor 38 | 39 | cat | sudo tee /etc/supervisor/conf.d/aircast.conf > /dev/null <<- EOM 40 | [program:aircast] 41 | command=python /vagrant/src/main.py --iface=eth1 42 | autostart=true 43 | autorestart=true 44 | stderr_logfile=/var/log/aircast.err.log 45 | stdout_logfile=/var/log/aircast.out.log 46 | username=vagrant 47 | EOM 48 | 49 | sudo service supervisor restart -------------------------------------------------------------------------------- /src/broadcaster.py: -------------------------------------------------------------------------------- 1 | import Queue 2 | import logging 3 | 4 | from common import StoppableThread 5 | from StringIO import StringIO 6 | 7 | from encoder import BaseEncoder 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class Broadcaster(StoppableThread): 13 | def __init__(self, config, sample_queue, io_loop): 14 | super(Broadcaster, self).__init__() 15 | self.listeners = [] 16 | self.daemon = True 17 | self.config = config 18 | self.sample_queue = sample_queue 19 | self.io_loop = io_loop 20 | 21 | # Will be initialised by the FLAC encoder 22 | self.header = StringIO() 23 | 24 | def add_listener(self, cb): 25 | self.listeners.append(cb) 26 | 27 | def remove_listener(self, cb): 28 | if cb in self.listeners: 29 | self.listeners.remove(cb) 30 | 31 | def get_header(self): 32 | return self.header.getvalue() 33 | 34 | def run(self): 35 | logger.info("Starting encoder") 36 | broadcaster = self 37 | 38 | class BroadcastEncoder(BaseEncoder): 39 | def __encoder_write__(self, buffer, samples, current_frame): 40 | # Check if this is a header write 41 | if samples == 0: 42 | broadcaster.header.write(buffer) 43 | 44 | for listener in broadcaster.listeners: 45 | broadcaster.io_loop.add_callback( 46 | lambda: listener.send_chunk(buffer)) 47 | 48 | return len(buffer) 49 | 50 | encoder = BroadcastEncoder(self.config) 51 | 52 | while True: 53 | if self.stopped(): 54 | break 55 | 56 | try: 57 | encoder.process_pcm(self.sample_queue.get()) 58 | except Queue.Empty: 59 | pass 60 | 61 | logger.info("Stopping encoder") 62 | encoder.finish() 63 | -------------------------------------------------------------------------------- /src/shairport.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import select 4 | import subprocess 5 | import sys 6 | import time 7 | 8 | from common import StoppableThread 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class ShairportStatus: 14 | IDLE = 1 15 | PLAYING = 2 16 | 17 | 18 | class Shairport(StoppableThread): 19 | BROADCAST_INTERVAL = 0.05 20 | IDLE_TIMEOUT = 5 21 | 22 | def __init__(self, advertised_name, config, sample_queue): 23 | super(Shairport, self).__init__() 24 | self.daemon = True 25 | self.advertised_name = advertised_name 26 | self.config = config 27 | self.sample_queue = sample_queue 28 | self.status = ShairportStatus.IDLE 29 | self.event_callbacks = [] 30 | 31 | def set_status_idle(self): 32 | self.status = ShairportStatus.IDLE 33 | self.send_event('idle', {}) 34 | 35 | def set_status_playing(self): 36 | self.status = ShairportStatus.PLAYING 37 | self.send_event('playing', {}) 38 | 39 | def add_callback(self, cb): 40 | self.event_callbacks.append(cb) 41 | 42 | def send_event(self, name, data): 43 | logger.debug("Shairport event: %s, %s", name, data) 44 | for cb in self.event_callbacks: 45 | cb(name, data) 46 | 47 | def run(self): 48 | n_bytes = int(self.config.sample_rate * self.config.channels * 49 | (self.config.bits_per_sample / 8) * 50 | self.BROADCAST_INTERVAL) 51 | 52 | args = ['shairport-sync', '--output=stdout', '-k', 53 | '--name={}'.format(self.advertised_name)] 54 | logger.debug("Starting shairport-sync with command '%s'", 55 | ''.join(args)) 56 | p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=sys.stderr) 57 | 58 | idle_start_time = int(time.time()) 59 | silence = True 60 | while True: 61 | if self.stopped(): 62 | break 63 | 64 | p.poll() 65 | if p.returncode is not None: 66 | raise RuntimeError("shairport-sync exited unexpectedly.") 67 | 68 | r, w, e = select.select([p.stdout], [], [], 0) 69 | if p.stdout in r: 70 | silence = False 71 | if self.status == ShairportStatus.IDLE: 72 | self.set_status_playing() 73 | 74 | read_bytes = os.read(p.stdout.fileno(), n_bytes * 2) 75 | self.sample_queue.put(read_bytes) 76 | else: 77 | if self.status == ShairportStatus.PLAYING: 78 | if not silence: 79 | silence = True 80 | idle_start_time = int(time.time()) + self.IDLE_TIMEOUT 81 | elif int(time.time()) >= idle_start_time: 82 | self.set_status_idle() 83 | 84 | time.sleep(self.BROADCAST_INTERVAL) 85 | 86 | p.terminate() 87 | --------------------------------------------------------------------------------