├── .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 |
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 | }
--------------------------------------------------------------------------------