├── .gitignore ├── LICENSE.md ├── README.md ├── index.html ├── index.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Andrew Faden 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BabelPod 2 | 3 | Add line-in and Bluetooth input to the HomePod (or other AirPlay speakers). Intended to run on Raspberry Pi. 4 | 5 | ## Getting Started 6 | [Instructions to set up and use](http://faden.me/2018/03/18/babelpod.html) 7 | 8 | ## Built With 9 | 10 | ## Contributing 11 | 12 | ## Author 13 | 14 | - [**Andrew Faden**](https://github.com/afaden) - Initial version 15 | 16 | ## License 17 | 18 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. 19 | 20 | ## Acknowledgments 21 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | BabelPod 5 | 6 | 45 | 46 | 47 |

BabelPod

48 | 49 | 52 | 53 | 56 |
57 | 58 | 59 | 60 | 72 |
73 | 74 | 146 | 147 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var app = require('express')(); 2 | var http = require('http').Server(app); 3 | var io = require('socket.io')(http); 4 | var spawn = require('child_process').spawn; 5 | var util = require('util'); 6 | var stream = require('stream'); 7 | var mdns = require('mdns-js'); 8 | var fs = require('fs'); 9 | var AirTunes = require('airtunes2'); 10 | 11 | var airtunes = new AirTunes(); 12 | 13 | // Create ToVoid and FromVoid streams so we always have somewhere to send to and from. 14 | util.inherits(ToVoid, stream.Writable); 15 | function ToVoid () { 16 | if (!(this instanceof ToVoid)) return new ToVoid(); 17 | stream.Writable.call(this); 18 | } 19 | ToVoid.prototype._write = function (chunk, encoding, cb) { 20 | } 21 | 22 | util.inherits(FromVoid, stream.Readable); 23 | function FromVoid () { 24 | if (!(this instanceof FromVoid)) return new FromVoid(); 25 | stream.Readable.call(this); 26 | } 27 | FromVoid.prototype._read = function (chunk, encoding, cb) { 28 | } 29 | 30 | var currentInput = "void"; 31 | var currentOutput = "void"; 32 | var inputStream = new FromVoid(); 33 | var outputStream = new ToVoid(); 34 | var airplayDevice = null; 35 | var arecordInstance = null; 36 | var aplayInstance = null; 37 | var volume = 20; 38 | var availableOutputs = []; 39 | var availablePcmOutputs = [] 40 | var availableAirplayOutputs = []; 41 | var availableInputs = []; 42 | var availableBluetoothInputs = []; 43 | var availablePcmInputs = []; 44 | 45 | // Search for new PCM input/output devices 46 | function pcmDeviceSearch(){ 47 | try { 48 | var pcmDevicesString = fs.readFileSync('/proc/asound/pcm', 'utf8'); 49 | } catch (e) { 50 | console.log("audio input/output pcm devices could not be found"); 51 | return; 52 | } 53 | var pcmDevicesArray = pcmDevicesString.split("\n").filter(line => line!=""); 54 | var pcmDevices = pcmDevicesArray.map(device => {var splitDev = device.split(":");return {id: "plughw:"+splitDev[0].split("-").map(num => parseInt(num, 10)).join(","), name:splitDev[2].trim(), output: splitDev.some(part => part.includes("playback")), input: splitDev.some(part => part.includes("capture"))}}); 55 | availablePcmOutputs = pcmDevices.filter(dev => dev.output); 56 | availablePcmInputs = pcmDevices.filter(dev => dev.input); 57 | updateAllInputs(); 58 | updateAllOutputs(); 59 | } 60 | // Perform initial search for PCM devices 61 | pcmDeviceSearch(); 62 | 63 | // Watch for new PCM input/output devices every 10 seconds 64 | var pcmDeviceSearchLoop = setInterval(pcmDeviceSearch, 10000); 65 | 66 | // Watch for new Bluetooth devices 67 | /*blue.Bluetooth(); 68 | blue.on(blue.bluetoothEvents.Device, function (devices) { 69 | console.log('devices:' + JSON.stringify(devices,null,2)); 70 | availableBluetoothInputs = []; 71 | for (var device of blue.devices){ 72 | availableBluetoothInputs.push({ 73 | 'name': 'Bluetooth: '+device.name, 74 | 'id': 'bluealsa:HCI=hci0,DEV='+device.mac+',PROFILE=a2dp,DELAY=10000' 75 | }); 76 | } 77 | updateAllInputs(); 78 | })*/ 79 | 80 | function updateAllInputs(){ 81 | var defaultInputs = [ 82 | { 83 | 'name': 'None', 84 | 'id': 'void' 85 | } 86 | ]; 87 | availableInputs = defaultInputs.concat(availablePcmInputs, availableBluetoothInputs); 88 | // todo only emit if updated 89 | io.emit('available_inputs', availableInputs); 90 | } 91 | updateAllInputs(); 92 | 93 | function updateAllOutputs(){ 94 | var defaultOutputs = [ 95 | { 96 | 'name': 'None', 97 | 'id': 'void', 98 | 'type': 'void' 99 | } 100 | ]; 101 | availableOutputs = defaultOutputs.concat(availablePcmOutputs, availableAirplayOutputs); 102 | // todo only emit if updated 103 | io.emit('available_outputs', availableOutputs); 104 | } 105 | updateAllOutputs(); 106 | 107 | var browser = mdns.createBrowser(mdns.tcp('raop')); 108 | browser.on('ready', function () { 109 | browser.discover(); 110 | }); 111 | browser.on('update', function (data) { 112 | // console.log("service up: ", data); 113 | // console.log(service.addresses); 114 | // console.log(data.fullname); 115 | if (data.fullname){ 116 | var splitName = /([^@]+)@(.*)\._raop\._tcp\.local/.exec(data.fullname); 117 | if (splitName != null && splitName.length > 1){ 118 | var id = 'airplay_'+data.addresses[0]+'_'+data.port; 119 | 120 | if (!availableAirplayOutputs.some(e => e.id === id)) { 121 | availableAirplayOutputs.push({ 122 | 'name': 'AirPlay: ' + splitName[2], 123 | 'id': id, 124 | 'type': 'airplay' 125 | // 'address': service.addresses[1], 126 | // 'port': service.port, 127 | // 'host': service.host 128 | }); 129 | updateAllOutputs(); 130 | } 131 | } 132 | } 133 | // console.log(airplayDevices); 134 | }); 135 | // browser.on('serviceDown', function(service) { 136 | // console.log("service down: ", service); 137 | // }); 138 | 139 | function cleanupCurrentInput(){ 140 | inputStream.unpipe(outputStream); 141 | if (arecordInstance !== null){ 142 | arecordInstance.kill(); 143 | arecordInstance = null; 144 | } 145 | } 146 | 147 | function cleanupCurrentOutput(){ 148 | console.log("inputStream", inputStream); 149 | console.log("outputStream", outputStream); 150 | inputStream.unpipe(outputStream); 151 | if (airplayDevice !== null) { 152 | airplayDevice.stop(function(){ 153 | console.log('stopped airplay device'); 154 | }) 155 | airplayDevice = null; 156 | } 157 | if (aplayInstance !== null){ 158 | aplayInstance.kill(); 159 | aplayInstance = null; 160 | } 161 | } 162 | 163 | app.get('/', function(req, res){ 164 | res.sendFile(__dirname + '/index.html'); 165 | }); 166 | 167 | let logPipeError = function(e) {console.log('inputStream.pipe error: ' + e.message)}; 168 | 169 | io.on('connection', function(socket){ 170 | console.log('a user connected'); 171 | // set current state 172 | socket.emit('available_inputs', availableInputs); 173 | socket.emit('available_outputs', availableOutputs); 174 | socket.emit('switched_input', currentInput); 175 | socket.emit('switched_output', currentOutput); 176 | socket.emit('changed_output_volume', volume); 177 | 178 | socket.on('disconnect', function(){ 179 | console.log('user disconnected'); 180 | }); 181 | 182 | socket.on('change_output_volume', function(msg){ 183 | console.log('change_output_volume: ', msg); 184 | volume = msg; 185 | if (airplayDevice !== null) { 186 | airplayDevice.setVolume(volume, function(){ 187 | console.log('changed airplay volume'); 188 | }); 189 | } 190 | if (aplayInstance !== null){ 191 | console.log('todo: update correct speaker based on currentOutput device ID'); 192 | console.log(currentOutput); 193 | var amixer = spawn("amixer", [ 194 | '-c', "1", 195 | '--', "sset", 196 | 'Speaker', volume+"%" 197 | ]); 198 | } 199 | io.emit('changed_output_volume', msg); 200 | }); 201 | 202 | socket.on('switch_output', function(msg){ 203 | console.log('switch_output: ' + msg); 204 | currentOutput = msg; 205 | cleanupCurrentOutput(); 206 | 207 | // TODO: rewrite how devices are stored to avoid the array split thingy 208 | if (msg.startsWith("airplay")){ 209 | var split = msg.split("_"); 210 | var host = split[1]; 211 | var port = split[2]; 212 | console.log('adding device: ' + host + ':' + port); 213 | airplayDevice = airtunes.add(host, {port: port, volume: volume}); 214 | airplayDevice.on('status', function(status) { 215 | console.log('airplay status: ' + status); 216 | if(status === 'ready'){ 217 | outputStream = airtunes; 218 | inputStream.pipe(outputStream).on('error', logPipeError); 219 | 220 | // at this moment the rtsp setup is not fully done yet and the status 221 | // is still SETVOLUME. There's currently no way to check if setup is 222 | // completed, so we just wait a second before setting the track info. 223 | // Unfortunately we don't have the fancy input name here. Will get fixed 224 | // with a better way of storing devices. 225 | setTimeout(() => { airplayDevice.setTrackInfo(currentInput, 'BabelPod', '') }, 1000); 226 | } 227 | }); 228 | } 229 | if (msg.startsWith("plughw:")){ 230 | aplayInstance = spawn("aplay", [ 231 | '-D', msg, 232 | '-c', "2", 233 | '-f', "S16_LE", 234 | '-r', "44100" 235 | ]); 236 | 237 | outputStream = aplayInstance.stdin; 238 | inputStream.pipe(outputStream).on('error', logPipeError); 239 | } 240 | if (msg === "void"){ 241 | outputStream = new ToVoid(); 242 | inputStream.pipe(outputStream).on('error', logPipeError); 243 | } 244 | io.emit('switched_output', msg); 245 | }); 246 | 247 | socket.on('switch_input', function(msg){ 248 | console.log('switch_input: ' + msg); 249 | currentInput = msg; 250 | cleanupCurrentInput(); 251 | if (msg === "void"){ 252 | inputStream = new FromVoid(); 253 | inputStream.pipe(outputStream).on('error', logPipeError); 254 | } 255 | if (msg !== "void"){ 256 | arecordInstance = spawn("arecord", [ 257 | '-D', msg, 258 | '-c', "2", 259 | '-f', "S16_LE", 260 | '-r', "44100" 261 | ]); 262 | inputStream = arecordInstance.stdout; 263 | 264 | inputStream.pipe(outputStream).on('error', logPipeError); 265 | } 266 | io.emit('switched_input', msg); 267 | }); 268 | }); 269 | 270 | http.listen(3000, function(){ 271 | console.log('listening on *:3000'); 272 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "babelpod", 3 | "version": "0.0.1", 4 | "description": "Add line-in and Bluetooth input to the HomePod (or other AirPlay speakers). Intended to run on Raspberry Pi.", 5 | "dependencies": { 6 | "airtunes2": "git://github.com/ciderapp/node_airtunes2.git#a8df031a3500f3577733cea8badeb136e5362f49", 7 | "express": "^4.17.1", 8 | "mdns-js": "^1.0.3", 9 | "socket.io": "^2.2.0" 10 | } 11 | } --------------------------------------------------------------------------------