resources/assets/js/visualizers/default.ts

Summary

Maintainability
A
0 mins
Test Coverage
import Sketch from 'sketch-js'
import { audioService } from '@/services'
import { random, sample } from 'lodash'
import { noop } from '@/utils'

const NUM_PARTICLES = 128
const NUM_BANDS = 128
const SMOOTHING = 0.5
const SCALE = { MIN: 5.0, MAX: 80.0 }
const SPEED = { MIN: 0.2, MAX: 1.0 }
const ALPHA = { MIN: 0.8, MAX: 0.9 }
const SPIN = { MIN: 0.001, MAX: 0.005 }
const SIZE = { MIN: 0.5, MAX: 1.25 }
const TWO_PI = Math.PI * 2

const COLORS = [
  '#69D2E7',
  '#1B676B',
  '#BEF202',
  '#EBE54D',
  '#00CDAC',
  '#1693A5',
  '#F9D423',
  '#FF4E50',
  '#E7204E',
  '#0CCABA',
  '#FF006F'
] as const

type Color = typeof COLORS[number]

class AudioAnalyser {
  bandCount: number
  smoothing: number
  audio: HTMLMediaElement
  source: MediaElementAudioSourceNode
  analyser: AnalyserNode
  bands: Uint8Array
  onUpdate: Closure

  constructor (bandCount: number, smoothing: number, onUpdate: (bands: Uint8Array) => void) {
    this.bandCount = bandCount
    this.smoothing = smoothing
    this.onUpdate = onUpdate

    this.audio = audioService.element
    this.source = audioService.source

    this.analyser = audioService.analyzer
    this.analyser.smoothingTimeConstant = this.smoothing
    this.analyser.fftSize = this.bandCount * 2

    this.bands = new Uint8Array(this.analyser.frequencyBinCount)

    this.update()
  }

  update () {
    requestAnimationFrame(this.update.bind(this))

    if (!this.audio.paused) {
      this.analyser.getByteFrequencyData(this.bands)
      this.onUpdate(this.bands)
    }
  }
}

class Particle {
  x: number
  y: number
  level = 0
  scale = 0
  alpha = 0
  speed = 0
  color: Color = COLORS[0]
  size = 0
  spin = 0
  band = 0
  smoothedScale = 0
  smoothedAlpha = 0
  decayScale = 0
  decayAlpha = 0
  rotation = 0
  energy = 0

  constructor (x: number, y: number) {
    this.x = x
    this.y = y
    this.reset()
  }

  reset () {
    this.level = 1 + Math.floor(random(4))
    this.scale = random(SCALE.MIN, SCALE.MAX)
    this.alpha = random(ALPHA.MIN, ALPHA.MAX)
    this.speed = random(SPEED.MIN, SPEED.MAX)
    this.color = sample(COLORS)!
    this.size = random(SIZE.MIN, SIZE.MAX)
    this.spin = random(SPIN.MAX, SPIN.MAX)
    this.band = Math.floor(random(NUM_BANDS))

    if (Math.random() < 0.5) {
      this.spin = -this.spin
    }

    this.smoothedScale = 0.0
    this.smoothedAlpha = 0.0
    this.decayScale = 0.0
    this.decayAlpha = 0.0
    this.rotation = random(TWO_PI)
    this.energy = random(this.band / 256)
  }

  move () {
    this.rotation += this.spin
    this.y -= this.speed * this.level
  }

  draw (ctx: CanvasRenderingContext2D) {
    const power = Math.exp(this.energy)
    const scale = this.scale * power
    const alpha = this.alpha * this.energy * 2

    this.decayScale = Math.max(this.decayScale, scale)
    this.decayAlpha = Math.max(this.decayAlpha, alpha)

    this.smoothedScale += (this.decayScale - this.smoothedScale) * 0.3
    this.smoothedAlpha += (this.decayAlpha - this.smoothedAlpha) * 0.3

    this.decayScale *= 0.985
    this.decayAlpha *= 0.975

    ctx.save()
    ctx.beginPath()
    ctx.translate(this.x + Math.cos(this.rotation * this.speed) * 250, this.y)
    ctx.rotate(this.rotation)
    ctx.scale(this.smoothedScale * this.level, this.smoothedScale * this.level)
    ctx.moveTo(this.size * 0.5, 0)
    ctx.lineTo(this.size * -0.5, 0)
    ctx.lineWidth = 1
    ctx.lineCap = 'round'
    ctx.globalAlpha = this.smoothedAlpha / this.level
    ctx.strokeStyle = this.color
    ctx.stroke()
    ctx.restore()
  }
}

export const init = (container: HTMLElement) => {
  const particles: Particle[] = []

  Sketch.create({
    container,

    setup () {
      for (let i = 0; i < NUM_PARTICLES; ++i) {
        particles.push(new Particle(random(this.width), random(this.height)))
      }

      new AudioAnalyser(NUM_BANDS, SMOOTHING, bands => {
        // update particles based on fft transformed audio frequencies
        particles.forEach(particle => (particle.energy = bands[particle.band] / 256))
      })
    },

    draw () {
      this.globalCompositeOperation = 'lighter'

      particles.map(particle => {
        if (particle.y < (-particle.size * particle.level * particle.scale * 2)) {
          particle.reset()
          particle.x = random(this.width)
          particle.y = this.height + (particle.size * particle.scale * particle.level * 2)
        }

        particle.move()
        particle.draw(this as CanvasRenderingContext2D)
      })
    }
  })

  return noop
}