commit 0024f44c80fe2c23b6d3c79e8d124a4ec4d09673 Author: Franz Heinzmann (Frando) Date: Mon Jun 7 16:22:13 2021 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f0ce85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +yarn* +package-lock.json +node_modules +SANDBOX +etc diff --git a/README.md b/README.md new file mode 100644 index 0000000..14d4b47 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# studiox-streamer + +Stream audio from a Raspberry Pi or other device. + +### Installation + +Requirements are: + +- Node.js v14 +- baresip +- darkice +- ffmpeg + +See [`docs/installation.md`] for detailed setup instructions on Raspberry Pi OS. + +Then clone this repo and run the following commands: + +```bash +yarn +yarn build +``` + +### Usage + + diff --git a/backend/baresip.mjs b/backend/baresip.mjs new file mode 100644 index 0000000..c56dec0 --- /dev/null +++ b/backend/baresip.mjs @@ -0,0 +1,103 @@ +import p from 'path' +import split2 from 'split2' +import { Readable } from 'streamx' +import { spawn } from 'child_process' +import { fileURLToPath } from 'url'; + +import BaresipWrapper from './lib/baresip-wrapper.mjs' + +const __dirname = p.dirname(fileURLToPath(import.meta.url)) + +import { action, STREAM_STATUS, LOG_MESSAGE } from './state.mjs' + +export class Baresip { + constructor (opts = {}) { + this.command = opts.command || 'baresip' + this.config = opts.config || p.join(__dirname, '..', 'etc', 'baresip') + this.stream = new Readable() + this.log = [] + this.stream.on('data', row => this.log.push(row)) + this.id = 'baresip' + this.destinationNumber = opts.destination || 901 + } + + restart () { + if (!this.baresip) return this.start() + this.stop(() => { + process.nextTick(() => { + this.start() + }) + }) + } + + stop (cb) { + // this.process.kill() + if (!this.baresip) return + this.baresip.kill(() => { + this.stream.push(action(STREAM_STATUS, { id: this.id, status: 'stopped' })) + if (cb) cb() + }) + } + + _onready () { + console.log('baresip onready') + } + + _oncallestablished (number) { + console.log('call established to', number) + this.stream.push(action(STREAM_STATUS, { id: this.id, status: 'started' })) + } + + _onhangup (number) { + console.log('remote hangup', number) + this.stream.push(action(STREAM_STATUS, { id: this.id, status: 'stopped' })) + } + + _onserverconnected (...args) { + console.log('onserverconnected', args) + setTimeout(() => { + this.baresip.dial(this.destinationNumber) + }, 1000) + } + + start () { + const args = ['-f', this.config] + this.baresip = new BaresipWrapper({ + command: this.command, + args, + callbacks: { + ready: this._onready.bind(this), + hangUp: this._onhangup.bind(this), + callEstablished: this._oncallestablished.bind(this), + serverConnected: this._onserverconnected.bind(this) + } + }) + this.baresip.stream.on('data', message => { + // console.log('BARESIP', message) + this.stream.push(action(LOG_MESSAGE, { id: this.id, type: 'stdout', message })) + }) + this.baresip.connect() + // if (this.process) return + // const args = ['-c', this.config, '-v', this.logLevel] + // this.process = spawn(this.command, args) + // this.process.stdout.pipe(split2()).on('data', message => { + // this.stream.push(action(LOG_MESSAGE, { id: this.id, type: 'stdout', message })) + // // this.stream.push({ type: 'stdout', data }) + // }) + // this.process.stderr.pipe(split2()).on('data', message => { + // this.stream.push(action(LOG_MESSAGE, { id: this.id, type: 'stdout', message })) + // // this.stream.push({ type: 'stderr', data }) + // }) + + // this.process.on('close', () => { + // this.stream.push(action(STREAM_STATUS, { id: this.id, status: 'stopped' })) + // this.process = null + // }) + + // this.stream.push(action(STREAM_STATUS, { id: this.id, status: 'started' })) + + // return new Promise((resolve, reject) => { + // resolve() + // }) + } +} diff --git a/backend/bin.mjs b/backend/bin.mjs new file mode 100755 index 0000000..1289135 --- /dev/null +++ b/backend/bin.mjs @@ -0,0 +1,32 @@ +import minimist from 'minimist' +import { run } from './server.mjs' +import createDebug from 'debug' + +const debug = createDebug('streamer') + +main().catch(onerror) + +async function main () { + const args = minimist(process.argv.slice(2), { + default: { + port: 3030, + host: '0.0.0.0', + input: 'alsa:default', + output: 'alsa:default' + }, + alias: { + p: 'port', + h: 'host', + i: 'input', + o: 'output' + } + }) + + await run(args) +} + +function onerror (err) { + if (err) console.error(err instanceof Error ? err.message : String(err)) + debug(err) + process.exit(1) +} diff --git a/backend/darkice.mjs b/backend/darkice.mjs new file mode 100644 index 0000000..2c93395 --- /dev/null +++ b/backend/darkice.mjs @@ -0,0 +1,60 @@ +import p from 'path' +import split2 from 'split2' +import { Readable } from 'streamx' +import { spawn } from 'child_process' +import { fileURLToPath } from 'url'; + +const __dirname = p.dirname(fileURLToPath(import.meta.url)) + +import { action, STREAM_STATUS, LOG_MESSAGE } from './state.mjs' + +export class Darkice { + constructor (opts = {}) { + this.command = opts.command || 'darkice' + this.config = opts.config || p.join(__dirname, 'etc', '..', 'darkice.cfg') + this.logLevel = 5 + this.stream = new Readable() + this.log = [] + this.stream.on('data', row => this.log.push(row)) + this.id = 'darkice' + } + + restart () { + if (!this.process) return this.start() + this.process.once('close', () => { + process.nextTick(() => { + this.start() + }) + }) + this.stop() + } + + stop () { + this.process.kill() + } + + start () { + if (this.process) return + const args = ['-c', this.config, '-v', this.logLevel] + this.process = spawn(this.command, args) + this.process.stdout.pipe(split2()).on('data', message => { + this.stream.push(action(LOG_MESSAGE, { id: this.id, type: 'stdout', message })) + // this.stream.push({ type: 'stdout', data }) + }) + this.process.stderr.pipe(split2()).on('data', message => { + this.stream.push(action(LOG_MESSAGE, { id: this.id, type: 'stdout', message })) + // this.stream.push({ type: 'stderr', data }) + }) + + this.process.on('close', () => { + this.stream.push(action(STREAM_STATUS, { id: this.id, status: 'stopped' })) + this.process = null + }) + + this.stream.push(action(STREAM_STATUS, { id: this.id, status: 'started' })) + + return new Promise((resolve, reject) => { + resolve() + }) + } +} diff --git a/backend/lib/baresip-wrapper.mjs b/backend/lib/baresip-wrapper.mjs new file mode 100644 index 0000000..a26e84c --- /dev/null +++ b/backend/lib/baresip-wrapper.mjs @@ -0,0 +1,104 @@ +import { spawn } from 'child_process' +import split2 from 'split2' +import { get } from 'http' +import { Readable } from 'streamx' +import { fixPath } from 'os-dependent-path-delimiter' +import kill from 'tree-kill' + +const eventRegexps = { + callEstablished: /Call established: (.+)/, + callReceived: /Incoming call from: ([\+\w]+ )?(\S+) -/, + hangUp: /(.+): session closed/, + ready: /baresip is ready/, + serverConnected: /\[\d+ bindings?\]/ +} + +const options = { host: '127.0.0.1', port: '8000', agent: false } +const nop = () => {} + +const executeCommand = (command) => { + options.path = `/?${command}` + get(options, nop) +} + +export default class Baresip { + constructor (opts) { + const { command, args, callbacks } = opts + this.connected = false + this.processPath = fixPath(command) + this.args = args + this.callbacks = {} + this.stream = new Readable() + + Object.keys(eventRegexps).forEach((event) => { + this.on(event, callbacks[event] === undefined ? () => {} : callbacks[event]) + }); + + [ + 'on', + 'connect', + 'kill', + 'reload' + ].forEach((method) => { + this[method] = this[method].bind(this) + }) + } + + accept () { + executeCommand('a') + } + + dial (phoneNumber) { + executeCommand(`d${phoneNumber}`) + } + + hangUp () { + executeCommand('b') + } + + toggleCallMuted () { + executeCommand('m') + } + + on (event, callback) { + this.callbacks[event] = callback + } + + kill (callback) { + kill(this.process.pid, 'SIGKILL', (err) => { + if (!err) { + this.connected = false + + if (callback !== undefined) { + callback() + } + } + }) + } + + reload () { + this.kill(() => this.connect()) + } + + connect () { + this.connected = true + this.process = spawn(this.processPath, this.args) + + this.process.stdout.pipe(split2()).on('data', (data) => { + const parsedData = `${data}` + + Object.keys(eventRegexps).forEach((event) => { + const matches = parsedData.match(eventRegexps[event]) + + if ((matches !== null) && (matches.length > 0)) { + this.callbacks[event](matches[matches.length - 1]) + } + }) + + this.stream.push(parsedData) + // console.log(parsedData) + }) + + // this.process.stderr.pipe(split2()).on('data', (data) => this.stream.push(data)) + } +} diff --git a/backend/lib/ffmpeg-meter.mjs b/backend/lib/ffmpeg-meter.mjs new file mode 100644 index 0000000..5df6c80 --- /dev/null +++ b/backend/lib/ffmpeg-meter.mjs @@ -0,0 +1,100 @@ +import { spawn } from 'child_process' +import createDebug from 'debug' +import { EventEmitter } from 'events' +import process from 'process' + +const debug = createDebug('meter') + +export class AlsaMeter extends EventEmitter { + constructor (opts = {}) { + super() + if (!opts.input) this.input = { host: 'alsa', device: 'default' } + else if (typeof opts.input === 'string') this.input = { host: 'alsa', input: opts.input } + else this.input = opts.input + // setInterval(() => console.log(this._lastMessage), 1000) + } + + open () { + if (!this._opening) this._opening = this._open() + return this._opening + } + + async _open () { + const cmd = 'ffmpeg' + let args = [ + '-nostats', + '-f', + this.input.host, + '-i', + this.input.device, + '-filter_complex', + 'ebur128=peak=true', + '-f', + 'null', + '-' + ] + debug('spawn: ' + `${cmd} ${args.map(a => `"${a}"`).join(' ')}`) + this.proc = spawn(cmd, args) + + // const onclose = (ev) => { + // this._closed = true + // this.proc.kill() + // } + // process.on('exit', onclose.bind('exit')) + // process.on('SIGINT', onclose.bind('SIGINT')) + // process.on('SIGTERM', onclose.bind('SIGTERM')) + + this.proc.stderr.on('data', msg => { + this.parseMessage(msg) + }) + + return new Promise((resolve, reject) => { + this.proc.stderr.once('data', msg => { + this._open = true + resolve() + }) + }) + } + + parseMessage (msg) { + let str = msg.toString('utf-8') + let match = str.match(/\[.*\](.*)/) + if (!match || match.length !== 2) return null + str = match[1].trim() + let parts = str.split(/([a-zA-Z]+:)/).filter(f => f) + let last = null + let map = parts.reduce((agg, token, i) => { + token = token.trim() + if (last) { + agg[last] = token + last = null + } else { + last = token.replace(/[:\s]/g, '') + } + return agg + }, {}) + + let res = {} + if (map.LRA) res.LRA = parseFloat(map.LRA.replace('LU', '')) + if (map.t) res.t = parseFloat(map.t) + if (map.TARGET) res.TARGET = parseFloat(map.TARGET.replace('LUFS', '')) + if (map.I) res.I = parseFloat(map.I.replace('LUFS', '')) + if (map.M) res.M = parseFloat(map.M) + if (map.S) res.S = parseFloat(map.S) + for (let key of ['FTPK', 'TPK']) { + if (map[key]) { + let parts = map[key].split(/\s+/) + if (parts.length >= 2) { + res[key] = [parseFloat(parts[0]), parseFloat(parts[1])] + } else { + res[key] = null + } + } + } + if (Object.keys(res).length) { + // debug(Object.entries(res).map(([k, v]) => `${k}: ${v}`).join(' | ')) + this._lastMessage = res + this.emit('meter', res) + } + } +} diff --git a/backend/lib/is-online.mjs b/backend/lib/is-online.mjs new file mode 100644 index 0000000..5218179 --- /dev/null +++ b/backend/lib/is-online.mjs @@ -0,0 +1,34 @@ +import isOnline from 'is-online' +import { Readable } from 'streamx' + +import { action, CONNECTIVITY } from '../state.mjs' + +export class ConnectivityCheck extends Readable { + constructor (opts = {}) { + super() + this.opts = opts + this.retryInterval = opts.retryInterval || 2000 + this._action({ internet: false }) + this._update() + } + + async _update () { + try { + const hasInternet = await isOnline(this.opts) + this._action({ internet: hasInternet }) + } catch (err) { + this._action({ internet: false }) + } finally { + this._timeout = setTimeout(() => this._update(), this.retryInterval) + } + } + + _destroy () { + if (this._timeout) clearTimeout(this._timeout) + } + + _action (data) { + if (this.destroyed) return + this.push(action(CONNECTIVITY, data)) + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..e3188b8 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,37 @@ +{ + "name": "streamer", + "version": "1.0.0", + "main": "server.mjs", + "type": "module", + "license": "MIT", + "bin": "bin.mjs", + "scripts": { + "start": "node bin.mjs" + }, + "dependencies": { + "baresip-wrapper": "^1.0.10", + "debug": "^4.3.1", + "fastify": "^3.15.1", + "fastify-cors": "^6.0.1", + "fastify-sse-v2": "^2.0.4", + "fastify-static": "^4.0.1", + "is-online": "^9.0.0", + "minimist": "^1.2.5", + "split2": "^3.2.2", + "streamx": "^2.10.3" + }, + "pkg": { + "assets": [ + "etc/**", + "frontend/dist/**" + ], + "targets": [ + "node14-linux-arm64", + "node14-linux-x64" + ], + "outputPath": "dist" + }, + "devDependencies": { + "caxa": "^1.0.0" + } +} diff --git a/backend/server.mjs b/backend/server.mjs new file mode 100644 index 0000000..90cc187 --- /dev/null +++ b/backend/server.mjs @@ -0,0 +1,125 @@ +import fastify from 'fastify' +import debug from 'debug' +import { EventEmitter } from 'events' +import p from 'path' +import { fileURLToPath } from 'url' +import { spawn } from 'child_process' +import minimist from 'minimist' +import { Transform, Readable } from 'streamx' +import fastifyStatic from 'fastify-static' +import fastifyCors from 'fastify-cors' +import fastifySSE from 'fastify-sse-v2' + +import { Darkice } from './darkice.mjs' +import { Baresip } from './baresip.mjs' +import { AlsaMeter } from './lib/ffmpeg-meter.mjs' +import { ConnectivityCheck } from './lib/is-online.mjs' +import { reducer, action, CONNECTION_STATE, STATE_RESET, PING, METER, INITIAL_STATE } from './state.mjs' + +const __dirname = p.dirname(fileURLToPath(import.meta.url)) + +function deviceToFFMPEG (device) { + const [host, ...rest] = device.split(':') + return { host, device: rest.join(':') } +} + +export async function run (config = {}) { + const app = fastify() + const darkice = new Darkice() + const baresip = new Baresip() + const isOnline = new ConnectivityCheck() + + const meter = new AlsaMeter({ + input: deviceToFFMPEG(config.input) + }) + + meter.open().catch(console.error) + + const actions = new Readable() + actions.setMaxListeners(1000) + + const state = { + current: INITIAL_STATE, + dispatch: action => (state.current = reducer(state.current, action)), + } + + actions.on('data', action => { + state.dispatch(action) + // console.log('action', action) + }) + + darkice.stream.on('data', action => actions.push(action)) + baresip.stream.on('data', action => actions.push(action)) + isOnline.on('data', action => actions.push(action)) + meter.on('meter', data => actions.push(action(METER, data))) + + const streams = { darkice, baresip } + // const state = new StateContainer(reducer, INITIAL_STATE) + // state.ingestActionStream(actions) + + app.register(fastifyStatic, { + root: p.join(__dirname, '..', 'frontend', 'dist'), + prefix: '/' + }) + + app.register(fastifyCors, { + origin: '*' + }) + + app.register(fastifySSE) + + app.get('/sse', async (req, reply) => { + console.log('sse req') + const stream = new Transform({ + transform (action, cb) { + if (!this._id) this._id = 0 + this.push({ data: JSON.stringify(action), id: ++this._id }) + cb() + } + }) + process.nextTick(() => { + stream.write(action(STATE_RESET, state.current)) + stream.write(action(CONNECTION_STATE, { connected: true })) + actions.on('data', action => stream.write(action)) + }) + reply.sse(stream) + }) + + class InvalidIdError extends Error { + constructor (message, statusCode) { + super(message || 'Invalid Stream ID') + this.statusCode = statusCode || 400 + } + } + app.post('/command', async (req, reply) => { + const { command, args } = req.body + const { id } = args + if (!streams[id]) throw new InvalidIdError() + const handler = streams[id] + switch (command) { + case 'start': + await handler.start() + break + case 'stop': + await handler.stop() + break + case 'restart': + await handler.restart() + break + } + return { + id, + error: false + } + }) + + app.setErrorHandler(function (error, request, reply) { + console.error('error', error) + if (!error || typeof error !== 'object') error = { message: error } + reply.code(error.statusCode || 500) + reply.send({ error: error.message || 'Internal server error' }) + }) + + await app.listen(config.port, config.host) + console.log(`Server running at http://${config.host}:${config.port}/`) +} diff --git a/backend/state.mjs b/backend/state.mjs new file mode 100644 index 0000000..4bcdcb6 --- /dev/null +++ b/backend/state.mjs @@ -0,0 +1 @@ +export * from '../common/state.mjs' diff --git a/common/state.mjs b/common/state.mjs new file mode 100644 index 0000000..6aae066 --- /dev/null +++ b/common/state.mjs @@ -0,0 +1,70 @@ +export const STATE_RESET = 'state-reset' +export const STREAM_STATUS = 'stream-status' +export const LOG_MESSAGE = 'log-message' +export const PING = 'ping' +export const METER = 'meter' +export const CONNECTION_STATE = 'connection-state' +export const CONNECTIVITY = 'connectivity' + +export function action (action, data = {}) { + return { action, data } +} + +export const INITIAL_STATE = { + connected: false, + streams: {}, + log: {}, + pings: 0, + meter: {}, + connectionState: { connected: false, error: null }, + connectivity: { internet: false } +} + +export function reducer (state, event) { + const { action, data } = event + if (action === STATE_RESET) { + return { ...INITIAL_STATE, ...data } + } + if (action === PING) { + return { + ...state, + ping: Date.now() + } + } + if (action === STREAM_STATUS) { + const { id, status } = data + return { + ...state, + streams: { + ...(state.streams || {}), + [id]: { + ...state.streams[id], + status + } + } + } + } + if (action === LOG_MESSAGE) { + const { id, type, message } = data + return { + ...state, + log: { + ...state.log, + [id]: [ + ...(state.log[id] || []), + { type, message } + ] + } + } + } + if (action === METER) { + return { ...state, meter: data } + } + if (action === CONNECTION_STATE) { + return { ...state, connectionState: data } + } + if (action === CONNECTIVITY) { + return { ...state, connectivity: data } + } + return state +} diff --git a/etc-template/baresip/accounts b/etc-template/baresip/accounts new file mode 100644 index 0000000..f92ce6f --- /dev/null +++ b/etc-template/baresip/accounts @@ -0,0 +1,38 @@ +# +# SIP accounts - one account per line +# +# Displayname ;addr-params +# +# uri-params: +# ;transport={udp,tcp,tls} +# +# addr-params: +# ;answermode={manual,early,auto} +# ;audio_codecs=opus/48000/2,pcma,... +# ;audio_source=alsa,default +# ;audio_player=alsa,default +# ;auth_user=username +# ;auth_pass=password +# ;call_transfer=no +# ;mediaenc={srtp,srtp-mand,srtp-mandf,dtls_srtp,zrtp} +# ;medianat={stun,turn,ice} +# ;mwi=no +# ;outbound="sip:primary.example.com;transport=tcp" +# ;outbound2=sip:secondary.example.com +# ;ptime={10,20,30,40,...} +# ;regint=3600 +# ;pubint=0 (publishing off) +# ;regq=0.5 +# ;sipnat={outbound} +# ;stunuser=STUN/TURN/ICE-username +# ;stunpass=STUN/TURN/ICE-password +# ;stunserver=stun:[user:pass]@host[:port] +# ;video_codecs=h264,h263,... +# +# Examples: +# +# ;auth_pass=secret +# ;auth_pass=secret +# ;auth_pass=secret +# +#;auth_pass=PASSWORD diff --git a/etc-template/baresip/config b/etc-template/baresip/config new file mode 100644 index 0000000..dc4bb2c --- /dev/null +++ b/etc-template/baresip/config @@ -0,0 +1,242 @@ +# +# baresip configuration +# + +#------------------------------------------------------------------------------ + +# Core +poll_method epoll # poll, select, epoll .. + +# SIP +#sip_listen 0.0.0.0:5060 +#sip_certificate cert.pem +#sip_cafile /etc/ssl/certs/ca-certificates.crt + +# Call +call_local_timeout 120 +call_max_calls 4 + +# Audio +#audio_path /usr/share/baresip +audio_player alsa,default +audio_source alsa,default +audio_alert alsa,default +#ausrc_srate 48000 +#auplay_srate 48000 +#ausrc_channels 0 +#auplay_channels 0 +#audio_txmode poll # poll, thread +audio_level no +ausrc_format s16 # s16, float, .. +auplay_format s16 # s16, float, .. +auenc_format s16 # s16, float, .. +audec_format s16 # s16, float, .. +audio_buffer 20-160 # ms + +# Video +#video_source v4l2,/dev/video0 +#video_display x11,nil +video_size 352x288 +video_bitrate 500000 +video_fps 25.00 +video_fullscreen no +videnc_format yuv420p + +# AVT - Audio/Video Transport +rtp_tos 184 +#rtp_ports 10000-20000 +#rtp_bandwidth 512-1024 # [kbit/s] +rtcp_mux no +jitter_buffer_delay 5-10 # frames +rtp_stats no +#rtp_timeout 60 + +# Network +#dns_server 1.1.1.1:53 +#dns_server 1.0.0.1:53 +#dns_fallback 8.8.8.8:53 +#net_interface wlp3s0 + +#------------------------------------------------------------------------------ +# Modules + +module_path /usr/lib/baresip/modules + +# UI Modules +#module stdio.so +#module cons.so +#module evdev.so +module httpd.so + +# Audio codec Modules (in order) +module opus.so +#module amr.so +#module g7221.so +#module g722.so +#module g726.so +module g711.so +#module gsm.so +#module l16.so +#module mpa.so +#module codec2.so +#module ilbc.so +#module isac.so + +# Audio filter Modules (in encoding order) +#module vumeter.so +#module sndfile.so +#module speex_pp.so +#module plc.so +#module webrtc_aec.so + +# Audio driver Modules +module alsa.so +#module pulse.so +module jack.so +#module portaudio.so +#module aubridge.so +#module aufile.so +#module ausine.so + +# Video codec Modules (in order) +#module avcodec.so +#module vp8.so +#module vp9.so + +# Video filter Modules (in encoding order) +#module selfview.so +#module snapshot.so +#module swscale.so +#module vidinfo.so +#module avfilter.so + +# Video source modules +#module v4l2.so +#module v4l2_codec.so +#module x11grab.so +#module cairo.so +#module vidbridge.so + +# Video display modules +#module directfb.so +#module x11.so +#module sdl.so +#module fakevideo.so + +# Audio/Video source modules +#module avformat.so +#module rst.so +#module gst.so +#module gst_video.so + +# Compatibility modules +#module ebuacip.so + +# Media NAT modules +module stun.so +module turn.so +module ice.so +#module natpmp.so +#module pcp.so + +# Media encryption modules +#module srtp.so +#module dtls_srtp.so +#module zrtp.so + + +#------------------------------------------------------------------------------ +# Temporary Modules (loaded then unloaded) + +module_tmp uuid.so +module_tmp account.so + + +#------------------------------------------------------------------------------ +# Application Modules + +module_app auloop.so +#module_app b2bua.so +module_app contact.so +module_app debug_cmd.so +#module_app echo.so +#module_app gtk.so +module_app menu.so +#module_app mwi.so +#module_app presence.so +#module_app syslog.so +#module_app mqtt.so +#module_app ctrl_tcp.so +module_app vidloop.so + + +#------------------------------------------------------------------------------ +# Module parameters + + +# UI Modules parameters +cons_listen 0.0.0.0:5555 # cons + +http_listen 0.0.0.0:8000 # httpd - server + +ctrl_tcp_listen 0.0.0.0:4444 # ctrl_tcp + +evdev_device /dev/input/event0 + +# Opus codec parameters +opus_bitrate 96000 # 6000-510000 +#opus_stereo yes +#opus_sprop_stereo yes +#opus_cbr no +#opus_inbandfec no +#opus_dtx no +#opus_mirror no +#opus_complexity 10 +#opus_application audio # {voip,audio} +#opus_samplerate 48000 +#opus_packet_loss 10 # 0-100 percent + +# Opus Multistream codec parameters +#opus_ms_channels 2 #total channels (2 or 4) +#opus_ms_streams 2 #number of streams +#opus_ms_c_streams 2 #number of coupled streams + +# vumeter_stderr yes +vumeter_stderr yes + +#jack_connect_ports yes + +# Selfview +video_selfview window # {window,pip} +#selfview_size 64x64 + +# ZRTP +#zrtp_hash no # Disable SDP zrtp-hash (not recommended) + +# Menu +#menu_bell yes +#redial_attempts 0 # Num or +#redial_delay 5 # Delay in seconds +#ringback_disabled no +#statmode_default off + +# avcodec +#avcodec_h264enc libx264 +#avcodec_h264dec h264 +#avcodec_h265enc libx265 +#avcodec_h265dec hevc +#avcodec_hwaccel vaapi + +# mqtt +#mqtt_broker_host 127.0.0.1 +#mqtt_broker_port 1883 +#mqtt_broker_clientid baresip01 +#mqtt_broker_user user +#mqtt_broker_password pass +#mqtt_basetopic baresip/01 + +# sndfile +#snd_path /tmp + +# EBU ACIP +#ebuacip_jb_type fixed # auto,fixed diff --git a/etc-template/baresip/contacts b/etc-template/baresip/contacts new file mode 100644 index 0000000..bfd1764 --- /dev/null +++ b/etc-template/baresip/contacts @@ -0,0 +1,18 @@ +# +# SIP contacts +# +# Displayname ;addr-params +# +# addr-params: +# ;presence={none,p2p} +# ;access={allow,block} +# + + +"Echo Server" +"bit" ;presence=p2p + +# Access rules +#"Catch All" ;access=block +"Good Friend" ;access=allow + diff --git a/etc-template/baresip/current_contact b/etc-template/baresip/current_contact new file mode 100644 index 0000000..eae9700 --- /dev/null +++ b/etc-template/baresip/current_contact @@ -0,0 +1 @@ +sip:echo@creytiv.com \ No newline at end of file diff --git a/etc-template/baresip/uuid b/etc-template/baresip/uuid new file mode 100644 index 0000000..f22827d --- /dev/null +++ b/etc-template/baresip/uuid @@ -0,0 +1 @@ +e29053d0-68c9-bcb8-da90-0168fb6368fe \ No newline at end of file diff --git a/etc-template/darkice.cfg b/etc-template/darkice.cfg new file mode 100644 index 0000000..e7a9d39 --- /dev/null +++ b/etc-template/darkice.cfg @@ -0,0 +1,35 @@ +# this section describes general aspects of the live streaming session +[general] +duration = 0 # duration of encoding, in seconds. 0 means forever +bufferSecs = 5 # size of internal slip buffer, in seconds +reconnect = yes # reconnect to the server(s) if disconnected +realtime = yes # run the encoder with POSIX realtime priority +rtprio = 3 # scheduling priority for the realtime threads + +# this section describes the audio input that will be streamed +[input] +device = alsa # OSS DSP soundcard device for the audio input +sampleRate = 48000 # sample rate in Hz. try 11025, 22050 or 44100 +bitsPerSample = 16 # bits per sample. try 16 +channel = 2 # channels. 1 = mono, 2 = stereo + +# this section describes a streaming connection to an IceCast2 server +# there may be up to 8 of these sections, named [icecast2-0] ... [icecast2-7] +# these can be mixed with [icecast-x] and [shoutcast-x] sections +[icecast2-0] +bitrateMode = cbr # average bit rate +format = mp3 # format of the stream: ogg vorbis +bitrate = 320 # bitrate of the stream sent to the server +server = {{icecast.server}} + # host name of the server +port = {{icecast.port}} # port of the IceCast2 server, usually 8000 +password = {{icecast.password}} +mountPoint = {{icecast.mountpoint}} # mount point of this stream on the IceCast2 server +name = {{icecast.name}} # name of the stream +description = {{icecast.description}} + # description of the stream +url = {{icecast.url}} + # URL related to the stream +genre = music # genre of the stream +public = no # advertise this stream? +# localDumpFile = dump.mp3 # local dump file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..53f7466 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,5 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..384e81d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + studiox streamer + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..690459b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "frontend", + "version": "0.0.0", + "scripts": { + "dev": "vite", + "build": "vite build", + "serve": "vite preview" + }, + "dependencies": { + "@react-nano/use-event-source": "^0.10.0", + "react": "^17.0.0", + "react-dom": "^17.0.0", + "swr": "^0.5.6" + }, + "devDependencies": { + "@vitejs/plugin-react-refresh": "^1.3.1", + "sass": "^1.34.0", + "vite": "^2.3.0" + }, + "standard": { + "env": { + "browser": true + } + } +} diff --git a/frontend/src/app.jsx b/frontend/src/app.jsx new file mode 100644 index 0000000..99ca391 --- /dev/null +++ b/frontend/src/app.jsx @@ -0,0 +1,118 @@ +import React from 'react' + +import { useRemoteState, useCommand } from './state' +import { EbuMeter } from './meter' + +import './css/index.scss' + +function App () { + const { state } = useRemoteState() + return ( +
+
+

studiox streamer

+
+
+ + +
+
+ +
+
+ +
+
+ ) +} + +function StatusBar (props) { + const { state } = props + return ( +
+ + +
+ ) +} + +function ServerState (props) { + const { state } = props + const { connectionState } = state + const { error, connected } = connectionState + if (error) return + else if (connected) return Connected to backend + else return Waiting for backend +} + +function InternetState (props) { + const { connectivity } = props.state + if (connectivity.internet) return Internet connection OK + else return +} + +function Error (props) { + const { error } = props + let message + if (error && typeof error === 'object') message = error.message + else message = String(error) + return Error: {message} +} +function Meters (props) { + return ( +
+ +
+ ) +} + +function StreamControl (props = {}) { + const { id = 'darkice', state = {} } = props + const command = useCommand() + const status = (state && state.streams && state.streams[id]) ? state.streams[id].status : 'stopped' + const disabled = command.pending || status === 'unknown' + let label + switch (status) { + case 'stopped': label = 'start'; break + case 'started': label = 'stop'; break + case 'unknown': label = 'invalid' + } + const active = status === 'started' ? true : undefined + return ( +
+

{id}

+ + {label} + + +
+ ) + function onClick (e) { + command.dispatch(label, { id }) + } +} + +function ToggleButton (props = {}) { + let className = 'ToggleButton' + if (props.active) className += ' ToggleButton-active' + if (props.className) className += ' ' + props.className + return