Refactoring

a hi-fi music player

in JavaScript

Andrea Coiutti (@andreacoiutti)

Universal JS Day - Ferrara, 20/04/18

Andrea Coiutti

HIGH FIDELITY

DIY

SPEAKER

SPEAKER

AMPLIFIER

DAC

DIGITAL SOURCE

WEB RADIO

NAS

USB DRIVE

SPEAKER

SPEAKER

AMPLIFIER

DAC

DIGITAL SOURCE

Bit perfect playback

sending the audio file unaltered to the audio device

no DSP (volume control, sample rate conversion, dither)

1
0
1
1
0
1
0
0
0
1
1

Music Player Daemon (MPD)

WEB RADIO

NAS

USB DRIVE

SPEAKER

SPEAKER

AMPLIFIER

DAC

DIGITAL SOURCE

CLIENT

Wife Acceptance Factor

WAF

Raspberry Pi

INEXPENSIVE

INEXPENSIVE

SMALL

INEXPENSIVE

SMALL

SILENT

INEXPENSIVE

SMALL

SILENT

GREEN

INEXPENSIVE

SMALL

SILENT

GREEN

HEADLESS

WEB RADIO

NAS

USB DRIVE

SPEAKER

SPEAKER

AMPLIFIER

DAC

DIGITAL SOURCE

CLIENT

+

RuneUI

RuneOS

Why? Why not!

"Any application that can be written in JavaScript, will eventually be written in JavaScript."

- Atwood's Law

Why? Performance!

Why? Maintainance!

Why? Usability!

API

SPA

mithril.js.org

 

 

A modern client-side JavaScript framework
for building Single Page Applications

 

Virtual DOM, components, batteries included

MITHRIL

Download size

Vue + Vue-Router + Vuex + fetch (40kb)

React + React-Router + Redux + fetch (64kb)

Angular (135kb)

Mithril (8kb)

(source: mithril.js.org)

API methods

Performance

Vue (9.8ms)

React (12.1ms)

Angular (11.5ms)

Mithril (6.4ms)

first render time, less is better (source: mithril.js.org)

The playback queue issue

  • thousands of elements in the DOM
  • UI freeze
  • browser crash!

Thousands of entries in queue

Occlusion culling

Occlusion culling

Occlusion culling

viewport

real entries in DOM

offset from top

viewport

real entries in DOM

offset from top

scrolled distance

viewport

real entries in DOM

offset from top

The coverwall issue

  • high load on the (not so powerful) server
  • waste of data
  • waste of processing time, battery, and other system resources on the client

Tons of coverart pictures

Lazy loading

defer loading of non-critical resources at page load time,
usually using event handlers (scroll or resize)

IntersectionObserver

document.addEventListener('DOMContentLoaded', () => {
  const lazyImages = [].slice.call(document.querySelectorAll('img.lazy'))

  const lazyImageObserver = new IntersectionObserver((entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const lazyImage = entry.target
        lazyImage.src = lazyImage.dataset.src
        lazyImage.classList.remove('lazy')
        lazyImageObserver.unobserve(lazyImage)
      }
    });
  });

  lazyImages.forEach((lazyImage) => {
    lazyImageObserver.observe(lazyImage)
  });
});
<img class="lazy" src="placeholder-image.jpg" data-src="real-image.jpg">

IntersectionObserver

(source: caniuse.com)

not fully supported yet, polyfill is needed

IntersectionObserver

1. Install polyfill

npm install intersection-observer
import 'intersection-observer'

2. Use polyfill

SERVER

SPA

The responsiveness issue

filesystem

database

network

process

other

register
callback

operation
complete

 

trigger
callback

(single thread)

EVENT LOOP

requests
(event queue)

thread pool

SPA

API

STATIC

WS

Express

socket.io

fastify.io

 

 

Fast and low overhead web framework, for Node.js

 

brought by Matteo Collina (@matteocollina)
and Tomas Della Vedova (@delvedor)

(source: fastify.io)

const fastify = require('fastify')()
const serveStatic = require('fastify-static')
const { publicRoutes, routes } = require('./api/routes.js')

// Enable CORS - Cross Origin Resource Sharing
fastify.use(cors())

// Public routes
fastify.register(publicRoutes, { prefix: '/api' })
// All the other routes
fastify.register(routes, { prefix: '/api' })

// Static content
fastify.register(serveStatic, { root: '/opt/frontend/dist' })

// Start server
fastify.listen(80, '0.0.0.0', err => {
  if (err) throw err
  console.log(`Server listening on port 80`)
})

Start the server

const apiAuth = require('../auth') // auth hook
const audio = require('./methods/audio.js')
const auth = require('./methods/auth.js')
// ...

const publicRoutes = (fastify, options, next) => {
  fastify.post('/auth/login', auth.login)
  fastify.post('/auth/reset-password', auth.resetPassword)
  // ...
  next()
}

const routes = (fastify, options, next) => {
  // Add hook on this set of routes
  fastify.addHook('preHandler', apiAuth.check)

  fastify.get('/audio/cards', audio.getCards)
  fastify.get('/audio/config', audio.getConfig)
  // ...
  next()
}

Routes

Validation

const auth = require('./methods/auth.js')
const joi = require('joi')

const validate = rule => ({
  schema: rule,
  schemaCompiler: schema => data => joi.validate(data, schema)
})

const loginSchema = {
  body: joi
    .object()
    .keys({
      email: joi.string().email().required()
      password: joi.string().required()
    })
    .required()
}

fastify.post('/auth/login', validate(loginSchema), auth.login)

Error handling

fastify.setNotFoundHandler((req, res) => {
  res.code(404)
    .type('application/json')
    .send({
      statusCode: 404,
      error: 'Not Found',
      message: 'Not found'
    })
})

fastify.setErrorHandler((error, req, res) => {
  if (error.isJoi) {
    res.code(400)
      .type('application/json')
      .send({
        statusCode: 400,
        error: 'Bad Request',
        message: error.message
      })
  } else { /* 500 Server Error */ }
})

The slow media issue

Caching

request
content

available
in cache?

serve it
from cache

fetch or compute it

store and cache it

Caching

request
content

available
in cache?

serve it
from cache

fetch or compute it

store and cache it

Caching

request
content

available
in cache?

serve it
from cache

fetch or compute it

store and cache it

add job

operation
complete

 

Job queue

Job queue

"A simple, fast, robust job/task queue
for Node.js, backed by Redis"

Bundler

<html>
<body>
  <script src="./index.js"></script>
</body>
</html>
// import another component
import main from './main'

main()
// import a CSS module
import classes from './main.css'

export default () => {
  console.log(classes.main)
}
.main {
  /* Reference an image file */
  background: url('./img/bg.png');
  color: red;
}

index.html

index.js

main.js

main.css

>  parcel index.html

Benchmarks

Webpack (20.71s)

Parcel (9.98s)

Parcel with cache (2.64s)

Browserify (22.98s)

Bundle time, less is better (source: parceljs.org)

Formatting and linting

+

+

THANK YOU!

Refactoring a hi-fi music player in JavaScript

By Andrea Coiutti

Refactoring a hi-fi music player in JavaScript

In the last few months I've worked on the refactor of RuneAudio, an open source software that transforms inexpensive, silent and low-powered mini-PCs into hi-fi music players. The result (version 2.0) is a stack that speaks the same language: API + websocket in Node.js for the backend, SPA for the frontend. The work was also a playground to experiment with modern solutions and to push the performance to the limits: we'll touch topics as occlusion culling, lazy loading, caching, job queues, Mithril and Fastify.

  • 3,006