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