From ecf506fd91800fbeac8e91e24b7b865d87fc6190 Mon Sep 17 00:00:00 2001 From: Alexandre Storelli Date: Mon, 12 Nov 2018 21:49:11 +0100 Subject: [PATCH] playback for electron --- api/listen-electron.js | 103 +++++++++++++++++++++++++++++++++++++++ client/public/index.html | 12 +++++ client/src/audio.js | 79 +++++++++++++++++++++++++++++- 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 api/listen-electron.js diff --git a/api/listen-electron.js b/api/listen-electron.js new file mode 100644 index 0000000..240f31f --- /dev/null +++ b/api/listen-electron.js @@ -0,0 +1,103 @@ +const { config, getRadio } = require('../handlers/config'); +const cp = require("child_process"); +const { log } = require("abr-log")("listen-electron"); + +let transcoder, listenTimer; + +// play audio locally +function play(radio, delay, playToken, onData) { + try { + log.info("play " + radio + " at delay " + delay); + + const radioObj = getRadio(...radio.split("_")); + if (!radioObj) { + return "radio not found"; + } + + //let skipPcmBytes = Math.max(config.user.streamInitialBuffer - 0.5) * 44100 * 2 * 2; // 44100 Hz, stereo, 16 bit. + + var initialBuffer = radioObj.liveStatus.audioCache.readLast(+delay+config.user.streamInitialBuffer,config.user.streamInitialBuffer); + //log.debug("listen: readCursor set to " + radioObj.liveStatus.audioCache.readCursor); + + if (!initialBuffer) { + log.error("/listen/" + radio + "/" + delay + ": initialBuffer not available"); + return "buffer not available"; + } + + stop(); // shut down previous trancoder + + transcoder.stdout.on("data", function(data) { + onData(playToken, data); + }); + + log.info("listen: send initial buffer of " + initialBuffer.length + " bytes"); + + setImmediate(function() { + transcoder.stdin.write(initialBuffer); + }); + + var sendMore = function() { + /*if (listenRequestDate !== state.requestDate) { + log.warn("request canceled because another one has been initiated"); + return stop(); + }*/ + var radioObj = getRadio(...radio.split("_")); + if (!radioObj) { + log.error("/listen/" + radio + "/" + delay + ": radio not available"); + return stop(); + } + var audioCache = radioObj.liveStatus.audioCache; + if (!audioCache) { + log.error("/listen/" + radio + "/" + delay + ": audioCache not available"); + return stop(); + } + //var prevReadCursor = audioCache.readCursor; + transcoder.stdin.write(audioCache.readAmountAfterCursor(config.user.streamGranularity)); + //onData(audioCache.readAmountAfterCursor(config.user.streamGranularity)); + //log.debug("listen: readCursor=" + audioCache.readCursor); + } + + listenTimer = setInterval(sendMore, 1000*config.user.streamGranularity); + + } catch(e) { + log.error("play error=" + e); + log.error(e.stack); + } + return null; +} + +function newTranscoder() { + log.debug("new transcoder"); + transcoder = cp.spawn('ffmpeg', [ + '-i', 'pipe:0', + '-acodec', 'pcm_s16le', + '-ar', 44100, + '-ac', 2, + '-f', 'wav', + '-v', 'fatal', + 'pipe:1' + ], { stdio: ['pipe', 'pipe', process.stderr] }); + /*log.debug("stdin hWM: " + transcoder.stdin._writableState.highWaterMark); + transcoder.stdin._writableState.highWaterMark = 1024; + log.debug("stdin hWM: " + transcoder.stdin._writableState.highWaterMark); + log.debug("stdout hWM: " + transcoder.stdout._readableState.highWaterMark); + transcoder.stdout._readableState.highWaterMark = 1024; + log.debug("stdout hWM: " + transcoder.stdout._readableState.highWaterMark);*/ +} + +newTranscoder(); + +function stop() { + log.debug("stop"); + if (listenTimer) { + clearInterval(listenTimer); + if (transcoder && transcoder.stdin) { + transcoder.stdin.end(); + transcoder.kill(); + newTranscoder(); + } + } +} + +exports.play = play; +exports.stop = stop; \ No newline at end of file diff --git a/client/public/index.html b/client/public/index.html index 9a45a7e..6fd8e70 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -13,6 +13,18 @@ +
diff --git a/client/src/audio.js b/client/src/audio.js index 96bb394..60fc940 100644 --- a/client/src/audio.js +++ b/client/src/audio.js @@ -2,11 +2,85 @@ /* global Media */ /* global Android */ -var isCordovaApp = document.URL.indexOf('http://') === -1 && document.URL.indexOf('https://') === -1; +const isElectron = navigator.userAgent.toLowerCase().indexOf(' electron/') > -1; +const isCordovaApp = document.URL.indexOf('http://') === -1 && document.URL.indexOf('https://') === -1 && !isElectron; var audioElement, play, stop, setVolume; -if (isCordovaApp) { +if (isElectron) { + console.log("listen: detected Electron env"); + + let audioCtx, gainNode; + let source; + let startTime; //, startPlayback; + + function newContext() { + console.log("new context"); + audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + gainNode = audioCtx.createGain(); + gainNode.gain.value = 0.5; + gainNode.connect(audioCtx.destination); + } + + newContext(); + + play = function(url, callback) { + if (startTime) { + stop(); + return setTimeout(function() { + play(url, callback); + }, 50); + } + startTime = +new Date(); + let nextStartTime = null; //audioCtx.currentTime; + //audioElement = document.createElement('audio'); + const spl = decodeURIComponent(url).split('?')[0].split("/"); // assuming following URL format: "listen/" + encodeURIComponent(radio) + "/" + (delay/1000) + navigator.abrlisten.play(spl[1], spl[2], startTime, function(receivedStartTime, PCMAudioChunk) { + if (receivedStartTime !== startTime) { + console.log('received obsolete PCM chunk'); + return; + } + if (!nextStartTime) { + console.log('start playback'); + nextStartTime = audioCtx.currentTime; + } + //if (!startPlayback) startPlayback = new Date(); + const PCMAudioChunk2 = Int8Array.from(PCMAudioChunk); //); + const frames = PCMAudioChunk2.byteLength / 4; + //console.log(frames / 44100 + " s => cursor = " + nextStartTime + " buffer=" + (nextStartTime - ((+new Date() - startPlayback)/1000) + " s")); + const arrayBuffer = audioCtx.createBuffer(2, frames, 44100); + + const nowBufferingL = arrayBuffer.getChannelData(0); + const nowBufferingR = arrayBuffer.getChannelData(1); + for (var i = 0; i < frames; i++) { + nowBufferingL[i] = (PCMAudioChunk2[4*i] + 256 * PCMAudioChunk2[4*i + 1]) / 32768; + nowBufferingR[i] = (PCMAudioChunk2[4*i + 2] + 256 * PCMAudioChunk2[4*i + 3]) / 32768; + } + + source = audioCtx.createBufferSource(); + source.buffer = arrayBuffer; + source.connect(gainNode); + source.start(nextStartTime); + nextStartTime += frames / 44100; + }); + } + + stop = function() { + console.log("playback stop"); + navigator.abrlisten.stop(); + //source.stop(audioCtx.currentTime);//disconnect(gainNode); + audioCtx.close(); + startTime = null; + newContext(); + //setVolume(0); + } + + setVolume = function(vol) { + console.log("set volume = " + vol); + gainNode.gain.value = vol; + } + +} else if (isCordovaApp) { play = function(url, callback) { if (audioElement && audioElement.stop) audioElement.stop(); audioElement = new Media(url, function() { @@ -17,6 +91,7 @@ if (isCordovaApp) { console.log("stream status=" + status); }); audioElement.play(); + if (callback) setImmediate(callback); } stop = function() {