initial commit

This commit is contained in:
Franz Heinzmann (Frando) 2021-06-07 16:22:13 +02:00
commit 0024f44c80
34 changed files with 1991 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
yarn*
package-lock.json
node_modules
SANDBOX
etc

25
README.md Normal file
View file

@ -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

103
backend/baresip.mjs Normal file
View file

@ -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()
// })
}
}

32
backend/bin.mjs Executable file
View file

@ -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)
}

60
backend/darkice.mjs Normal file
View file

@ -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()
})
}
}

View file

@ -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))
}
}

View file

@ -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)
}
}
}

34
backend/lib/is-online.mjs Normal file
View file

@ -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))
}
}

37
backend/package.json Normal file
View file

@ -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"
}
}

125
backend/server.mjs Normal file
View file

@ -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}/`)
}

1
backend/state.mjs Normal file
View file

@ -0,0 +1 @@
export * from '../common/state.mjs'

70
common/state.mjs Normal file
View file

@ -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
}

View file

@ -0,0 +1,38 @@
#
# SIP accounts - one account per line
#
# Displayname <sip:user@domain;uri-params>;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:
#
# <sip:user@domain.com;transport=tcp>;auth_pass=secret
# <sip:user@1.2.3.4;transport=tcp>;auth_pass=secret
# <sip:user@[2001:df8:0:16:216:6fff:fe91:614c]:5070;transport=tcp>;auth_pass=secret
#
#<sip:bit@domain>;auth_pass=PASSWORD

242
etc-template/baresip/config Normal file
View file

@ -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 <inf>
#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

View file

@ -0,0 +1,18 @@
#
# SIP contacts
#
# Displayname <sip:user@domain>;addr-params
#
# addr-params:
# ;presence={none,p2p}
# ;access={allow,block}
#
"Echo Server" <sip:echo@creytiv.com>
"bit" <sip:bit@domain>;presence=p2p
# Access rules
#"Catch All" <sip:*@*>;access=block
"Good Friend" <sip:good@friend.com>;access=allow

View file

@ -0,0 +1 @@
sip:echo@creytiv.com

View file

@ -0,0 +1 @@
e29053d0-68c9-bcb8-da90-0168fb6368fe

35
etc-template/darkice.cfg Normal file
View file

@ -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

5
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
.DS_Store
dist
dist-ssr
*.local

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>studiox streamer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

25
frontend/package.json Normal file
View file

@ -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
}
}
}

118
frontend/src/app.jsx Normal file
View file

@ -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 (
<div className='App'>
<div className='App-header'>
<h1>studiox streamer</h1>
</div>
<div className='App-main'>
<StreamControl id='darkice' state={state} />
<StreamControl id='baresip' state={state} />
</div>
<div className='App-side'>
<Meters value={state.meter} />
</div>
<div className='App-footer'>
<StatusBar state={state} />
</div>
</div>
)
}
function StatusBar (props) {
const { state } = props
return (
<div className='StatusBar'>
<ServerState state={state} />
<InternetState state={state} />
</div>
)
}
function ServerState (props) {
const { state } = props
const { connectionState } = state
const { error, connected } = connectionState
if (error) return <Error error={error} />
else if (connected) return <span>Connected to backend</span>
else return <span>Waiting for backend</span>
}
function InternetState (props) {
const { connectivity } = props.state
if (connectivity.internet) return <span>Internet connection OK</span>
else return <Error error='No internet connection' />
}
function Error (props) {
const { error } = props
let message
if (error && typeof error === 'object') message = error.message
else message = String(error)
return <span className='Error'><em>Error:</em> {message}</span>
}
function Meters (props) {
return (
<div className='Meters'>
<EbuMeter value={props.value} />
</div>
)
}
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 (
<div className='StreamControl'>
<h2>{id}</h2>
<ToggleButton active={active} disabled={disabled} onClick={onClick}>
{label}
</ToggleButton>
<Log messages={state.log[id]} />
</div>
)
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 <button {...props} className={className} />
}
function Log (props) {
let { messages = [] } = props
messages = messages.slice(0).reverse()
// const { messages } = useLog()
// const messages = []
return (
<ul className='Log'>
{messages.map((message, i) => (
<li key={i}>
<strong>{message.id} {message.type}</strong>: {message.message}
</li>
))}
</ul>
)
}
export default App

105
frontend/src/css/app.scss Normal file
View file

@ -0,0 +1,105 @@
.App {
background: black;
color: white;
font-size: 1.2rem;
display: grid;
grid-template-columns: auto 33%;
grid-template-rows: 3.5rem auto 0px;
grid-template-areas:
"header header"
"main side"
"main side"
"footer footer";
height: 100vh;
> div {
overflow: auto;
}
> .App-main {
grid-area: main;
}
> .App-side {
grid-area: side;
overflow: auto;
}
> .App-header {
grid-area: header;
}
> .App-footer {
grid-area: footer;
}
}
.App-header {
> h1 {
color: red;
}
}
.App-main {
display: flex;
}
.App-main > * {
width: 33%;
}
.StreamControl {
display: flex;
flex-direction: column;
/* width: 200px; */
}
.Meters {
padding: 10px;
display: flex;
max-height: 100%;
> svg {
}
}
.Log {
font-size: .9rem;
padding: 0;
margin: 0;
overflow-y: auto;
}
.Log li {
list-style-type: none;
margin: 0;
padding: 0;
}
.ToggleButton {
border: none;
outline: none;
background: #666;
padding: 2rem;
font-size: 3rem;
cursor: pointer;
color: #fff;
font-size: 2rem;
font-weight: bold;
margin: 5px;
&:hover {
background: #888;
color: #ffe;
}
&.ToggleButton-active {
background: #0c2;
}
}
.StatusBar {
display: flex;
> * {
display: block;
margin-right: 1rem;
}
}
.Error {
color: #f66;
}

View file

@ -0,0 +1,2 @@
@import './reset.css';
@import './app.scss';

View file

@ -0,0 +1,30 @@
html {
box-sizing: border-box;
font-size: 16px;
height: 100%;
}
*, *:before, *:after {
box-sizing: inherit;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100%;
}
body, h1, h2, h3, h4, h5, h6, p, ol, ul {
margin: 0;
padding: 0;
}
ol, ul {
list-style: none;
}
img {
max-width: 100%;
height: auto;
}

15
frontend/src/favicon.svg Normal file
View file

@ -0,0 +1,15 @@
<svg width="410" height="404" viewBox="0 0 410 404" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.378 198.228 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8058C397.439 43.2894 403.768 52.1434 399.641 59.5246Z" fill="url(#paint0_linear)"/>
<path d="M292.965 1.5744L156.801 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8664L144.395 174.33C144.198 177.662 147.258 180.248 150.51 179.498L188.42 170.749C191.967 169.931 195.172 173.055 194.443 176.622L183.18 231.775C182.422 235.487 185.907 238.661 189.532 237.56L212.947 230.446C216.577 229.344 220.065 232.527 219.297 236.242L201.398 322.875C200.278 328.294 207.486 331.249 210.492 326.603L212.5 323.5L323.454 102.072C325.312 98.3645 322.108 94.137 318.036 94.9228L279.014 102.454C275.347 103.161 272.227 99.746 273.262 96.1583L298.731 7.86689C299.767 4.27314 296.636 0.855181 292.965 1.5744Z" fill="url(#paint1_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="6.00017" y1="32.9999" x2="235" y2="344" gradientUnits="userSpaceOnUse">
<stop stop-color="#41D1FF"/>
<stop offset="1" stop-color="#BD34FE"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="194.651" y1="8.81818" x2="236.076" y2="292.989" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEA83"/>
<stop offset="0.0833333" stop-color="#FFDD35"/>
<stop offset="1" stop-color="#FFA800"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

13
frontend/src/index.css Normal file
View file

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

7
frontend/src/logo.svg Normal file
View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

11
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './app'
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
)

View file

@ -0,0 +1,271 @@
import React, { useState, useEffect } from 'react'
const SCALE_MIN = -60
const SCALE_MAX = 0
function toPx (val) {
return value2px(val, SCALE_MIN, SCALE_MAX, 0, 100)
}
function toY (val) {
return 100 - value2px(val)
}
function MeterDefs () {
let goodMax = toPx(-20)
let goodMin = toPx(-25)
let peakRed = toPx(-9)
return (
<defs>
<g id='ebumeterbg'>
<rect
y='0'
x='0'
height={100 - goodMax}
width='10'
{...style('red')}
/>
<rect
y={100 - goodMax}
x='0'
height={goodMax - goodMin}
width='10'
{...style('#00ff00')}
/>
<rect
y={100 - goodMin}
x='0'
height={goodMin}
width='10'
{...style('#fafafa')}
/>
</g>
<g id='peakbg'>
<rect
y='0'
x='10'
height={100 - peakRed}
width='10'
{...style('red')}
/>
<rect
y={100 - peakRed}
x='10'
height={peakRed}
width='10'
{...style('#00ff00')}
/>
</g>
<linearGradient id='fadeGrad' y2='0' x2='1'>
<stop offset='0' stopColor='white' stopOpacity='0.2' />
<stop offset='0.5' stopColor='white' stopOpacity='0.5' />
<stop offset='1' stopColor='white' stopOpacity='0.2' />
</linearGradient>
<mask id='bgfade' maskContentUnits='objectBoundingBox'>
<rect width='1' height='1' fill='url(#fadeGrad)' />
</mask>
</defs>
)
}
let max = 0
let last = 0
function getMax (time, val) {
// let newmax = Math.max(val, max)
if (val > max) {
max = val
last = time
} else if (time - last > 3) {
max = val
} else if (time - last > 1) {
max = max - 1
// max = val
}
return max
}
export function EbuMeter (props) {
const { value } = props
const val = value
// let [max, setMax] = useState(0)
let min = SCALE_MIN
// let min = -30 // mic
// let peakMax = -2
// let ebuMax = -5
let peakMax = SCALE_MAX
let ebuMax = SCALE_MAX
// Momentary peak
// let peakM = value2px(val.M, min, ebuMax, 0, 100)
// Short-term peak
let peakS = value2px(val.S, min, ebuMax, 0, 100)
let peakL = 0
let peakR = 0
if (val.FTPK) {
peakL = value2px(val.FTPK[0], min, peakMax, 0, 100)
peakR = value2px(val.FTPK[1], min, peakMax, 0, 100)
}
if (peakL > 100) peakL = 0
if (peakR > 100) peakR = 0
if (peakS > 100) peakS = 0
let max = getMax(val.t, Math.max(peakL, peakR))
// useEffect(() => {
// let run = true
// let clear = setInterval(() => {
// if (!run) return
// let now = Date.now()
// setMax(last => {
// const { time, val } = last
// let curval = Math.max(peakL, peakR)
// console.log(time, val, curval)
// if (curval > val) return { val: curval, time: now }
// else if (time - now > 1000) {
// return { val: val - 2, time }
// }
// return last
// })
// }, 500)
// return () => {
// run = false
// clearInterval(clear)
// }
// })
return (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 20 100'
preserveAspectRatio='xMaxYMax meet'
>
<MeterDefs />
<defs>
<mask id='ebu-S'>
<rect x='0' y={100 - peakS} width='10' height={peakS} fill='white' />
</mask>
{/*<mask id='ebu-M'>
<rect x='3' y={100 - peakM} width='4' height={peakM} fill='white' />
</mask>*/}
<mask id='peak-L'>
<rect x='11' y={100 - peakL} width='4' height={peakL} fill='white' />
</mask>
<mask id='peak-R'>
<rect x='16' y={100 - peakR} width='4' height={peakR} fill='white' />
</mask>
</defs>
{/*
<g>
<rect id='bg' y='0' x='0' width='10' height='100' bg='#777' />
<g mask='url(#bgfade)'>
<rect id='bg' y='0' x='0' width='10' height='100' bg='#777' />
</g>
<g opacity='0.0'>
<use xlinkHref='#ebumeterbg' />
</g>
</g>
*/}
<g
mask={`url(#ebu-S)`}
style={{ opacity: 0.9 }}
>
<use xlinkHref='#ebumeterbg' />
</g>
<g
mask={`url(#ebu-M)`}
style={{ opacity: 0.0 }}
>
<use xlinkHref='#ebumeterbg' />
</g>
<g
mask={`url(#peak-L)`}
style={{ opacity: 0.9 }}
>
<use xlinkHref='#peakbg' />
</g>
<g
mask={`url(#peak-R)`}
style={{ opacity: 0.9 }}
>
<use xlinkHref='#peakbg' />
</g>
{/*
<rect x='11' y={100 - peakL} width='4' height={peakL} fill='#22f' opacity='0.8' />
<rect x='16' y={100 - peakR} width='4' height={peakR} fill='#22f' opacity='0.8' />
*/}
<rect y={100 - max} x='10' width='10' height='0.2' fill='#00f' />
<rect x='10' width='20' height='0.2' fill='#ff0' y={100 - toPx(-9)} />
<rect x='0' width='10' height='0.2' fill='#ff0' y={100 - toPx(-23)} />
</svg>
)
}
function sigLog (n) {
return Math.log(Math.abs(n) + 1) / Math.log(10) * sig(n)
}
function sig (n) {
return n === 0 ? 0 : Math.abs(n) / n
}
function sigExp (n) {
return (Math.pow(10, Math.abs(n)) - 1) * sig(n)
}
function value2px (value, valueMin, valueMax, pxMin, pxMax) {
if (value < valueMin) value = valueMin
if (value > valueMax) value = valueMax
if (isNaN(value) || value === null) value = valueMin
const valueWidth = valueMax - valueMin
const pixelWidth = pxMax - pxMin
const ratio = pixelWidth / valueWidth
return ratio * (value - valueMin) + pxMin
// const ratio = value / (valueMax - valueMin)
// const scaled = ratio * (pxMax - pxMin)
// return pxMin + scaled
}
// This is the original function that display the meter in a log scale.
function value2pxLog (value, valueMin, valueMax, pxMin, pxMax) {
var valueWidth = sigLog(valueMax) - sigLog(valueMin)
var pixelWidth = pxMax - pxMin
var ratio = pixelWidth / valueWidth
return ratio * (sigLog(value) - sigLog(valueMin)) + pxMin
}
function px2value (px, valueMin, valueMax, pxMin, pxMax) {
var valueWidth = sigLog(valueMax) - sigLog(valueMin)
var pixelWidth = pxMax - pxMin
var ratio = pixelWidth / valueWidth
return sigExp((px - pxMin) / ratio + sigLog(valueMin))
}
function prettify (n) {
var exp = Math.round(Math.pow(10, Math.log(Math.abs(n)) / Math.log(10)))
return exp === 0 ? 0 : Math.round(n / exp) * exp
}
function style (color) {
return { style: { fill: color } }
}

254
frontend/src/meter.jsx Normal file
View file

@ -0,0 +1,254 @@
import React, { useState, useEffect } from 'react'
const SCALE_MIN = -60
const SCALE_MAX = 0
function toPx (val) {
return value2px(val, SCALE_MIN, SCALE_MAX, 0, 100)
}
function toY (val) {
return 100 - value2px(val)
}
function MeterDefs (props) {
const { width } = props
let green = [0, toPx(-8)]
let yellow = [toPx(-8), toPx(-5)]
let red = [toPx(-5), toPx(0)]
const common = { x: 0, width }
return (
<defs>
<g id='peakbg'>
<rect
y={100 - red[1]}
height={100 - red[0]}
{...common}
{...style('#ff2244')}
/>
<rect
y={100 - yellow[1]}
height={100 - yellow[0]}
{...common}
{...style('#eeee00')}
/>
<rect
y={100 - green[1]}
height={100 - green[0]}
{...common}
{...style('#88ff00')}
/>
</g>
<linearGradient id='fadeGrad' y2='0' x2='1'>
<stop offset='0' stopColor='white' stopOpacity='0.2' />
<stop offset='0.5' stopColor='white' stopOpacity='0.5' />
<stop offset='1' stopColor='white' stopOpacity='0.2' />
</linearGradient>
<mask id='bgfade' maskContentUnits='objectBoundingBox'>
<rect width='1' height='1' fill='url(#fadeGrad)' />
</mask>
</defs>
)
}
let max = 0
let last = 0
function getMax (time, val) {
// let newmax = Math.max(val, max)
if (val > max) {
max = val
last = time
} else if (time - last > 3) {
max = val
} else if (time - last > 1) {
max = max - 1
// max = val
}
return max
}
export function EbuMeter (props) {
const { value } = props
const val = value
// let [max, setMax] = useState(0)
const width = 40
const gutter = 1
let min = SCALE_MIN
// let min = -30 // mic
// let peakMax = -2
// let ebuMax = -5
let peakMax = SCALE_MAX
let ebuMax = SCALE_MAX
// Momentary peak
// let peakM = value2px(val.M, min, ebuMax, 0, 100)
// Short-term peak
let peakS = value2px(val.S, min, ebuMax, 0, 100)
let peakL = 0
let peakR = 0
if (val.FTPK) {
peakL = value2px(val.FTPK[0], min, peakMax, 0, 100)
peakR = value2px(val.FTPK[1], min, peakMax, 0, 100)
}
if (peakL > 100) peakL = 0
if (peakR > 100) peakR = 0
if (peakS > 100) peakS = 0
let max = getMax(val.t, Math.max(peakL, peakR))
const meterWidth = width / 2 - gutter / 2
const peakMeterL = {
id: 'peakL',
peak: peakL,
x: 0,
width: meterWidth
}
const peakMeterR = {
id: 'peakR',
peak: peakR,
x: meterWidth + gutter,
width: meterWidth
}
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='100%'
heigh='100%'
viewBox={`0 0 ${width} 100`}
preserveAspectRatio='xMaxYMax meet'
>
<MeterDefs width={width} />
<PeakMeter {...peakMeterL} />
<PeakMeter {...peakMeterR} />
{/* <rect y={100 - max} x='0' width={width} height='0.8' fill='#a9f' /> */}
</svg>
)
}
function PeakMeter (props = {}) {
const { peak, width, x, id } = props
const mask = {
y: 100 - peak,
height: peak,
x,
width
}
// const range = -60
// const gutter = 0.5
// const steps = 24
// const dbPerStep = range / steps
// const scale = new Array(steps).fill(null).map((_, i) => {
// const bottom = toPx(dbPerStep * (i)) - gutter
// const top = toPx(dbPerStep * (i + 1))
// let fill = '#fff'
// // if (bottom > peak) fill = '#444'
// return {
// fill,
// x,
// width,
// y: 100 - top,
// height: top - bottom
// }
// })
const height = 100
const gutter = 0.8
const steps = 24
const heightPerStep = height / steps
// const filledSteps = Math.ceil(peak / steps)
const scale = new Array(steps).fill(null).map((_, i) => {
const bottom = heightPerStep * i
const top = bottom + heightPerStep - gutter
let fill = 'white'
if (((top + bottom) / 2) > peak) fill = '#444'
// if ((steps * i) > peak) return
// const y = 100 - heightPerStep * i
return {
fill,
x,
width,
y: 100 - top,
height: top - bottom
}
}).filter(x => x)
return (
<>
<defs>
<mask id={id}>
<rect fill='#000' {...mask} />
{scale.map((rect, i) => <rect key={i} {...rect} />)}
</mask>
</defs>
<g
mask={`url(#${id})`}
style={{ opacity: 0.9 }}
>
<use xlinkHref='#peakbg' />
</g>
</>
)
}
function sigLog (n) {
return Math.log(Math.abs(n) + 1) / Math.log(10) * sig(n)
}
function sig (n) {
return n === 0 ? 0 : Math.abs(n) / n
}
function sigExp (n) {
return (Math.pow(10, Math.abs(n)) - 1) * sig(n)
}
function value2px (value, valueMin, valueMax, pxMin, pxMax) {
if (value < valueMin) value = valueMin
if (value > valueMax) value = valueMax
if (isNaN(value) || value === null) value = valueMin
const valueWidth = valueMax - valueMin
const pixelWidth = pxMax - pxMin
const ratio = pixelWidth / valueWidth
return ratio * (value - valueMin) + pxMin
// const ratio = value / (valueMax - valueMin)
// const scaled = ratio * (pxMax - pxMin)
// return pxMin + scaled
}
// This is the original function that display the meter in a log scale.
function value2pxLog (value, valueMin, valueMax, pxMin, pxMax) {
var valueWidth = sigLog(valueMax) - sigLog(valueMin)
var pixelWidth = pxMax - pxMin
var ratio = pixelWidth / valueWidth
return ratio * (sigLog(value) - sigLog(valueMin)) + pxMin
}
function px2value (px, valueMin, valueMax, pxMin, pxMax) {
var valueWidth = sigLog(valueMax) - sigLog(valueMin)
var pixelWidth = pxMax - pxMin
var ratio = pixelWidth / valueWidth
return sigExp((px - pxMin) / ratio + sigLog(valueMin))
}
function prettify (n) {
var exp = Math.round(Math.pow(10, Math.log(Math.abs(n)) / Math.log(10)))
return exp === 0 ? 0 : Math.round(n / exp) * exp
}
function style (color) {
return { style: { fill: color } }
}

76
frontend/src/state.js Normal file
View file

@ -0,0 +1,76 @@
import React from 'react'
import { reducer, action, CONNECTION_STATE, INITIAL_STATE } from '../../common/state.mjs'
function getConfig () {
return {
endpoint: process.env.NODE_ENV === 'development' ? 'http://localhost:3030' : ''
}
}
function useConfig () {
return getConfig()
}
export function useCommand (defaultCommand, defaultArgs = {}) {
const config = useConfig()
const url = config.endpoint + '/command'
const headers = { 'content-type': 'application/json' }
const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState(null)
const [success, setSuccess] = React.useState(null)
return { pending, error, success, dispatch }
async function dispatch (command = defaultCommand, args = defaultArgs) {
setPending(true)
try {
const res = await fetch(url, {
headers,
method: 'post',
body: JSON.stringify({ command, args })
})
const json = await res.json()
if (json.error) throw new Error('Remote error: ' + json.details)
setSuccess(json)
} catch (err) {
setError(error)
} finally {
setPending(false)
}
}
}
export function useRemoteState (opts = {}) {
const { retryInterval = 1000 } = opts
const config = useConfig()
const [retry, setRetry] = React.useState(0)
const url = config.endpoint + '/sse'
const [state, dispatch] = React.useReducer(reducer, INITIAL_STATE)
const es = React.useRef()
function reconnect () {
if (es.current) es.current.close()
es.current = null
setRetry(retry => retry + 1)
}
// setup event source
React.useEffect(() => {
if (es.current) return
es.current = new EventSource(url)
}, [retry])
// process event source
React.useEffect(() => {
if (!es.current) return
const eventSource = es.current
eventSource.onmessage = (event) => {
const action = JSON.parse(event.data)
dispatch(action)
}
eventSource.onerror = () => {
console.log('ONERROR!!')
dispatch(action(CONNECTION_STATE, { error: `Failed to connect to backend` }))
if (retryInterval) setTimeout(reconnect, retryInterval)
}
}, [es.current])
return { state }
}

7
frontend/vite.config.js Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import reactRefresh from '@vitejs/plugin-react-refresh'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [reactRefresh()]
})

8
package.json Normal file
View file

@ -0,0 +1,8 @@
{
"private": true,
"workspaces": ["backend", "frontend", "common"],
"scripts": {
"build": "cd frontend && yarn build",
"start": "cd backend && yarn start"
}
}