initial commit
This commit is contained in:
commit
0024f44c80
34 changed files with 1991 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
yarn*
|
||||
package-lock.json
|
||||
node_modules
|
||||
SANDBOX
|
||||
etc
|
||||
25
README.md
Normal file
25
README.md
Normal 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
103
backend/baresip.mjs
Normal 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
32
backend/bin.mjs
Executable 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
60
backend/darkice.mjs
Normal 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
104
backend/lib/baresip-wrapper.mjs
Normal file
104
backend/lib/baresip-wrapper.mjs
Normal 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))
|
||||
}
|
||||
}
|
||||
100
backend/lib/ffmpeg-meter.mjs
Normal file
100
backend/lib/ffmpeg-meter.mjs
Normal 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
34
backend/lib/is-online.mjs
Normal 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
37
backend/package.json
Normal 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
125
backend/server.mjs
Normal 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
1
backend/state.mjs
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '../common/state.mjs'
|
||||
70
common/state.mjs
Normal file
70
common/state.mjs
Normal 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
|
||||
}
|
||||
38
etc-template/baresip/accounts
Normal file
38
etc-template/baresip/accounts
Normal 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
242
etc-template/baresip/config
Normal 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
|
||||
18
etc-template/baresip/contacts
Normal file
18
etc-template/baresip/contacts
Normal 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
|
||||
|
||||
1
etc-template/baresip/current_contact
Normal file
1
etc-template/baresip/current_contact
Normal file
|
|
@ -0,0 +1 @@
|
|||
sip:echo@creytiv.com
|
||||
1
etc-template/baresip/uuid
Normal file
1
etc-template/baresip/uuid
Normal file
|
|
@ -0,0 +1 @@
|
|||
e29053d0-68c9-bcb8-da90-0168fb6368fe
|
||||
35
etc-template/darkice.cfg
Normal file
35
etc-template/darkice.cfg
Normal 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
5
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
25
frontend/package.json
Normal 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
118
frontend/src/app.jsx
Normal 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
105
frontend/src/css/app.scss
Normal 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;
|
||||
}
|
||||
2
frontend/src/css/index.scss
Normal file
2
frontend/src/css/index.scss
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
@import './reset.css';
|
||||
@import './app.scss';
|
||||
30
frontend/src/css/reset.css
Normal file
30
frontend/src/css/reset.css
Normal 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
15
frontend/src/favicon.svg
Normal 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
13
frontend/src/index.css
Normal 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
7
frontend/src/logo.svg
Normal 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
11
frontend/src/main.jsx
Normal 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')
|
||||
)
|
||||
271
frontend/src/meter.backup.jsx
Normal file
271
frontend/src/meter.backup.jsx
Normal 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
254
frontend/src/meter.jsx
Normal 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
76
frontend/src/state.js
Normal 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
7
frontend/vite.config.js
Normal 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
8
package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"private": true,
|
||||
"workspaces": ["backend", "frontend", "common"],
|
||||
"scripts": {
|
||||
"build": "cd frontend && yarn build",
|
||||
"start": "cd backend && yarn start"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue