diff --git a/index.html b/index.html index 578ff8a..229a9c5 100644 --- a/index.html +++ b/index.html @@ -50,7 +50,7 @@

BabelPod

-
diff --git a/index.js b/index.js index 1dc100c..78a073e 100644 --- a/index.js +++ b/index.js @@ -4,9 +4,11 @@ var io = require('socket.io')(http); var spawn = require('child_process').spawn; var util = require('util'); var stream = require('stream'); -var mdns = require('mdns-js'); +var mdns = require('dnssd2'); var fs = require('fs'); var AirTunes = require('airtunes2'); +var blue = require('bluetoothctl'); +const { error } = require('console'); var airtunes = new AirTunes(); @@ -31,13 +33,14 @@ var currentInput = "void"; var currentOutput = "void"; var inputStream = new FromVoid(); var outputStream = new ToVoid(); -var airplayDevice = null; +var airplayDevices = []; var arecordInstance = null; var aplayInstance = null; var volume = 20; var availableOutputs = []; var availablePcmOutputs = [] var availableAirplayOutputs = []; +var availableAirplayStereoOutputs = {} var availableInputs = []; var availableBluetoothInputs = []; var availablePcmInputs = []; @@ -57,25 +60,33 @@ function pcmDeviceSearch(){ updateAllInputs(); updateAllOutputs(); } -// Perform initial search for PCM devices -pcmDeviceSearch(); -// Watch for new PCM input/output devices every 10 seconds -var pcmDeviceSearchLoop = setInterval(pcmDeviceSearch, 10000); +// Only if PCM devices are enabled through env variable +if (process.env.PCM) { + // Perform initial search for PCM devices + pcmDeviceSearch(); + + // Watch for new PCM input/output devices every 10 seconds + var pcmDeviceSearchLoop = setInterval(pcmDeviceSearch, 10000); +} // Watch for new Bluetooth devices -/*blue.Bluetooth(); +blue.Bluetooth(); +setTimeout(() => blue.getPairedDevices(), 5000) + blue.on(blue.bluetoothEvents.Device, function (devices) { - console.log('devices:' + JSON.stringify(devices,null,2)); + // ('devices:' + JSON.stringify(devices,null,2)); availableBluetoothInputs = []; for (var device of blue.devices){ availableBluetoothInputs.push({ 'name': 'Bluetooth: '+device.name, - 'id': 'bluealsa:HCI=hci0,DEV='+device.mac+',PROFILE=a2dp,DELAY=10000' + 'id': 'bluealsa:SRV=org.bluealsa,DEV='+device.mac+',PROFILE=a2dp', + 'mac': device.mac, + 'connected': device.connected == 'yes' }); } updateAllInputs(); -})*/ +}) function updateAllInputs(){ var defaultInputs = [ @@ -95,46 +106,110 @@ function updateAllOutputs(){ { 'name': 'None', 'id': 'void', - 'type': 'void' + 'type': 'void', + 'stereo': 'void' } ]; - availableOutputs = defaultOutputs.concat(availablePcmOutputs, availableAirplayOutputs); + availableOutputs = defaultOutputs.concat(availablePcmOutputs, Object.values(availableAirplayStereoOutputs), availableAirplayOutputs); // todo only emit if updated io.emit('available_outputs', availableOutputs); } updateAllOutputs(); -var browser = mdns.createBrowser(mdns.tcp('raop')); -browser.on('ready', function () { - browser.discover(); -}); -browser.on('update', function (data) { + +var browser = mdns.Browser(mdns.tcp('airplay')); + +browser.on('serviceUp', function (data) { // console.log("service up: ", data); // console.log(service.addresses); // console.log(data.fullname); if (data.fullname){ - var splitName = /([^@]+)@(.*)\._raop\._tcp\.local/.exec(data.fullname); + var splitName = /(.*)\._airplay\._tcp\.local/.exec(data.fullname); if (splitName != null && splitName.length > 1){ var id = 'airplay_'+data.addresses[0]+'_'+data.port; - - if (!availableAirplayOutputs.some(e => e.id === id)) { + var stereoName = false; + + stereoName = data.txt.gpn || false + + if (stereoName) { + if (!availableAirplayStereoOutputs[stereoName]) + availableAirplayStereoOutputs[stereoName] = { + 'id': 'stereoAirplay_' + stereoName, + 'name': 'AirPlay: ' + stereoName, + 'type': 'stereoAirplay', + 'devices': [] + } + availableAirplayStereoOutputs[stereoName].devices.push({ + 'name': 'AirPlay: ' + splitName[1], + 'id': id, + 'type': 'airplay', + 'host': data.addresses[0], + 'port': data.port + // 'host': service.host + }) + } else availableAirplayOutputs.push({ - 'name': 'AirPlay: ' + splitName[2], + 'name': 'AirPlay: ' + splitName[1], 'id': id, - 'type': 'airplay' - // 'address': service.addresses[1], - // 'port': service.port, + 'type': 'airplay', + 'host': data.addresses[0], + 'port': data.port // 'host': service.host - }); - updateAllOutputs(); - } + }) + updateAllOutputs() } } // console.log(airplayDevices); }); -// browser.on('serviceDown', function(service) { -// console.log("service down: ", service); -// }); +browser.on('serviceChanged', function(data) { + if (data.fullname) { + var splitName = /(.*)\._airplay\._tcp\.local/.exec(data.fullname); + if (splitName != null && splitName.length > 1) { + var id = 'airplay_'+data.addresses[0]+'_'+data.port; + var stereoName = false; + + stereoName = data.txt.gpn || false + + if (stereoName) { + var device = availableAirplayStereoOutputs[stereoName].devices.find(dev => dev.id === id) + device.name = 'AirPlay: ' + splitName[1] + device.host = data.addresses[0] + device.port = data.port + } else { + var device = availableAirplayOutputs.find(dev => dev.id === id) + device.name = 'AirPlay: ' + splitName[1] + device.host = data.addresses[0] + device.port = data.port + } + + updateAllOutputs() + } + } +}) + +browser.on('serviceDown', function(data) { + if (data.fullname){ + var splitName = /(.*)\._airplay\._tcp\.local/.exec(data.fullname); + if (splitName != null && splitName.length > 1){ + var id = 'airplay_'+data.addresses[0]+'_'+data.port; + var stereoName = false; + + stereoName = data.txt.gpn || false + + if (stereoName && availableAirplayStereoOutputs[stereoName] && availableAirplayStereoOutputs[stereoName].devices) { + availableAirplayStereoOutputs[stereoName].devices = availableAirplayStereoOutputs[stereoName].devices.filter(e => e.id !== id) + if (availableAirplayStereoOutputs[stereoName].devices.length <= 0) { + delete availableAirplayStereoOutputs[stereoName] + } + } else if (!stereoName) + availableAirplayOutputs = availableAirplayOutputs.filter(e => e.id !== id) + + updateAllOutputs() + } + } +}); + +browser.start() function cleanupCurrentInput(){ inputStream.unpipe(outputStream); @@ -144,16 +219,40 @@ function cleanupCurrentInput(){ } } +function statHandler(status) { + console.log('airplay status: ' + status); + if(status === 'ready'){ + + // at this moment the rtsp setup is not fully done yet and the status + // is still SETVOLUME. There's currently no way to check if setup is + // completed, so we just wait a second before setting the track info. + // Unfortunately we don't have the fancy input name here. Will get fixed + // with a better way of storing devices. + setTimeout(() => { this.setTrackInfo(currentInput, 'BabelPod', '') }, 1000); + } +} + +function errorHandler(error) { + console.log('airplay error: ' + error); + this.stop(function() { + console.log('device was stopped') + }) +} + function cleanupCurrentOutput(){ - console.log("inputStream", inputStream); - console.log("outputStream", outputStream); inputStream.unpipe(outputStream); - if (airplayDevice !== null) { - airplayDevice.stop(function(){ - console.log('stopped airplay device'); - }) - airplayDevice = null; - } + airplayDevices.forEach(airplayDevice => { + if (airplayDevice !== null) { + airplayDevice.stop(function(){ + console.log('stopped airplay device'); + }) + airplayDevice.off('status', statHandler) + airplayDevice = null; + } + }) + + airplayDevices = [] + if (aplayInstance !== null){ aplayInstance.kill(); aplayInstance = null; @@ -166,6 +265,8 @@ app.get('/', function(req, res){ let logPipeError = function(e) {console.log('inputStream.pipe error: ' + e.message)}; + + io.on('connection', function(socket){ console.log('a user connected'); // set current state @@ -182,11 +283,14 @@ io.on('connection', function(socket){ socket.on('change_output_volume', function(msg){ console.log('change_output_volume: ', msg); volume = msg; - if (airplayDevice !== null) { - airplayDevice.setVolume(volume, function(){ - console.log('changed airplay volume'); - }); - } + airplayDevices.forEach(airplayDevice => { + if (airplayDevice !== null) { + airplayDevice.setVolume(volume, function(){ + console.log('changed airplay volume'); + }); + } + }) + if (aplayInstance !== null){ console.log('todo: update correct speaker based on currentOutput device ID'); console.log(currentOutput); @@ -198,33 +302,42 @@ io.on('connection', function(socket){ } io.emit('changed_output_volume', msg); }); - + socket.on('switch_output', function(msg){ console.log('switch_output: ' + msg); currentOutput = msg; cleanupCurrentOutput(); // TODO: rewrite how devices are stored to avoid the array split thingy - if (msg.startsWith("airplay")){ - var split = msg.split("_"); - var host = split[1]; - var port = split[2]; - console.log('adding device: ' + host + ':' + port); - airplayDevice = airtunes.add(host, {port: port, volume: volume}); - airplayDevice.on('status', function(status) { - console.log('airplay status: ' + status); - if(status === 'ready'){ - outputStream = airtunes; - inputStream.pipe(outputStream).on('error', logPipeError); - - // at this moment the rtsp setup is not fully done yet and the status - // is still SETVOLUME. There's currently no way to check if setup is - // completed, so we just wait a second before setting the track info. - // Unfortunately we don't have the fancy input name here. Will get fixed - // with a better way of storing devices. - setTimeout(() => { airplayDevice.setTrackInfo(currentInput, 'BabelPod', '') }, 1000); - } - }); + if (msg.startsWith("stereoAirplay")) { + selectedOutput = availableAirplayStereoOutputs[msg.substring(14)]; + selectedOutput.devices.forEach(device => { + console.log('adding device: ' + device.host + ':' + device.port); + var airplayDevice = airtunes.add(device.host, {port: device.port, volume: volume, stereo: true}) + airplayDevice.on('error', errorHandler) + + + airplayDevice.on('status', statHandler); + airplayDevices.push(airplayDevice) + }) + + + outputStream = airtunes; + inputStream.pipe(outputStream, {end: false}).on('error', logPipeError); + } + if (msg.startsWith("airplay")) { + selectedOutput = availableAirplayOutputs.find(output => output.id === msg); + + console.log('adding device: ' + selectedOutput.host + ':' + selectedOutput.port); + var airplayDevice = airtunes.add(selectedOutput.host, {port: selectedOutput.port, volume: volume, stereo: false}) + airplayDevice.on('error', errorHandler) + + + airplayDevice.on('status', statHandler); + airplayDevices.push(airplayDevice) + + outputStream = airtunes; + inputStream.pipe(outputStream, {end: false}).on('error', logPipeError); } if (msg.startsWith("plughw:")){ aplayInstance = spawn("aplay", [ @@ -250,9 +363,33 @@ io.on('connection', function(socket){ cleanupCurrentInput(); if (msg === "void"){ inputStream = new FromVoid(); - inputStream.pipe(outputStream).on('error', logPipeError); + if (outputStream === airtunes) + inputStream.pipe(outputStream, {end: false}).on('error', logPipeError); + else + inputStream.pipe(outputStream).on('error', logPipeError); } if (msg !== "void"){ + if (msg.includes('bluealsa')) { + let theOutput = availableBluetoothInputs.find(object => object.id === msg); + if (theOutput.connected == false) { + blue.connect(theOutput.mac) + setTimeout(function() { + blue.info(theOutput.mac) + arecordInstance = spawn("arecord", [ + '-D', msg, + '-c', "2", + '-f', "S16_LE", + '-r', "44100" + ]); + inputStream = arecordInstance.stdout; + + inputStream.pipe(outputStream).on('error', logPipeError); + + io.emit('switched_input', msg); + }, 5000) + return; + } + } arecordInstance = spawn("arecord", [ '-D', msg, '-c', "2", @@ -262,6 +399,7 @@ io.on('connection', function(socket){ inputStream = arecordInstance.stdout; inputStream.pipe(outputStream).on('error', logPipeError); + } io.emit('switched_input', msg); }); @@ -269,4 +407,4 @@ io.on('connection', function(socket){ http.listen(3000, function(){ console.log('listening on *:3000'); -}); \ No newline at end of file +}); diff --git a/package.json b/package.json index 164f70d..c9b05d1 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,10 @@ "version": "0.0.1", "description": "Add line-in and Bluetooth input to the HomePod (or other AirPlay speakers). Intended to run on Raspberry Pi.", "dependencies": { - "airtunes2": "git://github.com/ciderapp/node_airtunes2.git#a8df031a3500f3577733cea8badeb136e5362f49", + "airtunes2": "git://github.com/ciderapp/node_airtunes2.git#v2.4.9", + "bluetoothctl": "https://github.com/DerDaku/node-bluetoothctl.git", "express": "^4.17.1", - "mdns-js": "^1.0.3", + "dnssd2": "^1.0.0", "socket.io": "^2.2.0" } -} \ No newline at end of file +}