Skip to content

Commit

Permalink
drum machine
Browse files Browse the repository at this point in the history
  • Loading branch information
sethbrasile committed Sep 24, 2024
1 parent f02fb26 commit e9aba71
Show file tree
Hide file tree
Showing 10 changed files with 276 additions and 64 deletions.
26 changes: 26 additions & 0 deletions src/app/components/loading-spinner/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export default {
setup() {
return this
},
html: `
<div id="loading" class="spinner hidden">
<div class="rect1"></div>
<div class="rect2"></div>
<div class="rect3"></div>
<div class="rect4"></div>
<div class="rect5"></div>
</div>
`,
toString() {
return this.html
},
getSpinner() {
return document.getElementById('loading')! as HTMLDivElement
},
show() {
this.getSpinner().classList.remove('hidden')
},
hide() {
this.getSpinner().classList.add('hidden')
},
}
9 changes: 2 additions & 7 deletions src/app/components/mp3-player/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { setupIndicators, setupMp3Buttons } from '@components/mp3-player/setup'
import LoadingSpinner from '../loading-spinner'

export default {
setup() {
Expand Down Expand Up @@ -37,13 +38,7 @@ export default {
</div>
</div>
<div id="loading" class="spinner hidden">
<div class="rect1"></div>
<div class="rect2"></div>
<div class="rect3"></div>
<div class="rect4"></div>
<div class="rect5"></div>
</div>
${LoadingSpinner}
<div id="player" class="audioplayer hidden">
<div role="button" class="play-pause" id="play"><a></a></div>
Expand Down
2 changes: 1 addition & 1 deletion src/app/pages/sound-fonts/piano-buttons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export async function setupPiano(element: HTMLOListElement): Promise<void> {
// piano.js is a soundfont created with MIDI.js' Ruby-based soundfont converter
const piano = await createFont('/ez-web-audio/piano.js')
// Slicing just so the whole keyboard doesn't show up on the screen
const notes = piano.notes.slice(39, 51)
const notes = piano.notes.slice(43, 55)

notes.forEach((note: Note) => {
const key = document.createElement('li')
Expand Down
129 changes: 122 additions & 7 deletions src/app/pages/timing/drum-machine.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,137 @@
import { observable, observe } from '@nx-js/observer-util'
import nav from './nav'
import LoadingSpinner from '@/app/components/loading-spinner'
import PermissionBanner from '@/app/components/permission-banner'
import type { BeatTrack } from '@/beat-track'
import { createBeatTrack } from '@/index'
import { codeBlock } from '@/app/utils'

function loadBeatTrackFor(name: string): Promise<BeatTrack> {
const urls = [1, 2, 3].map(num => `/ez-web-audio/drum-samples/${name}${num}.wav`)
// default is 4 beats, but we're going to use 8 beats for this example
return createBeatTrack(urls, { name, numBeats: 8, wrapWith: observable })
}

const Content = {
setup() {
async setup() {
const spinner = LoadingSpinner.setup()
PermissionBanner.setup()

spinner.show()

const beatTracks = await Promise.all([
loadBeatTrackFor('kick'),
loadBeatTrackFor('snare'),
loadBeatTrackFor('hihat'),
])

beatTracks.forEach((beatTrack) => {
const { name } = beatTrack
// snare and hihat are a little louder than kick, so we'll turn down the gain a bit
if (name !== 'kick') {
beatTrack.gain = 0.5
}
// and let's pan the hihat a little to the left
if (name === 'hihat') {
beatTrack.pan = -0.4
}

// get the lane for the beat track
const lane = document.getElementById(`${name}-lane`)
// add beat pads to the lane
beatTrack.beats.forEach((beat) => {
// to simplify showing when a given beat is playing, we will track currentTimeIsPlaying with an observer from nx-js

const beatPad = document.createElement('div')
beatPad.role = 'button'
beatPad.classList.add('beat-pad')

const pad = document.createElement('span')
pad.classList.add('pad')

observe(() => {
if (beat.isPlaying) {
pad.classList.add('playing')
}
else {
pad.classList.remove('playing')
}

if (beat.currentTimeIsPlaying) {
pad.classList.add('highlighted')
}
else {
pad.classList.remove('highlighted')
}

if (beat.active) {
pad.classList.add('active')
}
else {
pad.classList.remove('active')
}
})

lane?.appendChild(beatPad)
beatPad.appendChild(pad)

beatPad.addEventListener('click', () => {
beat.active = !beat.active
beat.playIfActive()
})
})
})

// hide the loading spinner
spinner.hide()
// show the beat machine
document.getElementById('beat-machine')!.classList.remove('hidden')

// set up the play button
const playButton = document.getElementById('play-button')!
const tempoInput = document.getElementById('tempo-input')! as HTMLInputElement
playButton.addEventListener('click', () => {
beatTracks.forEach((beatTrack) => {
beatTrack.playActiveBeats(tempoInput.valueAsNumber, 1 / 8)
})
})
},
html: `
${nav}
<h1>Timing</h1>
<h1>Multisampled Drum Machine</h1>
<div class="docs">
<p>
Below is an example of a drum machine that loads up three samples for each lane and allows you to program a drum beat.
The sample is automatically alternated so you never hear the same sample back-to-back.
</p>
${PermissionBanner}
<p>
Below is an example of a drum machine that loads up three samples for each lane and allows you to program a drum beat.
The sample is automatically alternated so you never hear the same sample back-to-back.
</p>
${LoadingSpinner}
<div id="beat-machine" class="hidden">
<div class="controls">
<button id="play-button">PLAY</button>
<div class="space">
<label>Tempo <Input value="120" id="tempo-input" type="number" /></label>
</div>
</div>
<div id="kick-lane" class="lane">
<p>Kick</p>
</div>
<div id="snare-lane" class="lane">
<p>Snare</p>
</div>
<div id="hihat-lane" class="lane">
<p>HiHat</p>
</div>
</div>
<div class="docs">
${codeBlock('// code is [WIP]!')}
</div>
`,
}
Expand Down
39 changes: 39 additions & 0 deletions src/app/public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -93,18 +93,57 @@ button.green {
padding: 2rem 0;
}

.space {
margin-top: 1em;
}

.beat-pad {
display: inline-block;
height: 100px;
width: 100px;
text-align: center;
margin-right: 4px;
}

.beat-pad .pad {
background-color: #393939;
display: block;
height: 100%;
}
.controls {
text-align: center;
}
.beat-machine {
display: table;
margin: 0 auto;
}
.beat-lane {
margin: 1em 0;
}
.beat-lane .text {
display: block;
}
.beat-pad {
display: inline-block;
height: 100px;
width: 100px;
text-align: center;
}
.beat-pad .pad {
background-color: #2a2a2a;
display: block;
height: 100%;
}
.beat-pad .pad.highlighted {
background-color: #393939;
}
.beat-pad .pad.active {
background-color: #900101;
}
.beat-pad .pad.playing {
background-color: #000;
}


[role="button"] {
cursor: pointer;
Expand Down
11 changes: 10 additions & 1 deletion src/base-sound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import type { Connectable, Connection } from '@interfaces/connectable'
import type { ControlType, ParamController, RampType, RatioType } from '@controllers/base-param-controller'
import audioContextAwareTimeout from '@utils/timeout'

interface BaseSoundOptions {
name?: string
setTimeout?: (fn: () => void, delayMillis: number) => number
}

export abstract class BaseSound implements Connectable, Playable {
protected _isPlaying = false
protected gainNode: GainNode
Expand All @@ -21,13 +26,17 @@ export abstract class BaseSound implements Connectable, Playable {
public abstract audioSourceNode: OscillatorNode | AudioBufferSourceNode
public abstract duration: TimeObject

constructor(protected audioContext: AudioContext, opts?: any) {
public name: string

constructor(protected audioContext: AudioContext, opts?: BaseSoundOptions) {
const gainNode = audioContext.createGain()
const pannerNode = audioContext.createStereoPanner()

this.gainNode = gainNode
this.pannerNode = pannerNode

this.name = opts?.name || ''

if (opts?.setTimeout) {
this.setTimeout = opts.setTimeout
}
Expand Down
23 changes: 20 additions & 3 deletions src/beat-track.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { Beat } from './beat'
import type { Connectable } from './interfaces/connectable'
import type { Playable } from './interfaces/playable'
import type { SamplerOptions } from './sampler'
import { Sampler } from './sampler'

const beatBank = new WeakMap()

export interface BeatTrackOptions extends SamplerOptions {
numBeats?: number
duration?: number
wrapWith?: (beat: Beat) => Beat
}

/**
* An instance of this class has an array of "sounds" (comprised of one or multiple
* audio sources, if multiple are provided, they are played in a round-robin fashion)
Expand All @@ -19,16 +26,21 @@ const beatBank = new WeakMap()
* queue?
*/
export class BeatTrack extends Sampler {
constructor(private audioContext: AudioContext, sounds: (Playable & Connectable)[], opts?: { numBeats?: number, duration?: number }) {
super(sounds)
constructor(private audioContext: AudioContext, sounds: (Playable & Connectable)[], opts?: BeatTrackOptions) {
super(sounds, opts)
if (opts?.numBeats) {
this.numBeats = opts.numBeats
}
if (opts?.duration) {
this.duration = opts.duration
}
if (opts?.wrapWith) {
this.wrapWith = opts.wrapWith
}
}

private wrapWith?: (beat: Beat) => Beat

/**
* @property numBeats
*
Expand Down Expand Up @@ -73,7 +85,12 @@ export class BeatTrack extends Sampler {
play: this.play.bind(this),
})

beats.push(beat)
if (this.wrapWith) {
beats.push(this.wrapWith(beat))
}
else {
beats.push(beat)
}
}

if (existingBeats) {
Expand Down
12 changes: 6 additions & 6 deletions src/beat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ export class Beat {
}
}

private parentPlayIn: ((time: number) => void) | undefined
private parentPlay: (() => void) | undefined
private parentPlayIn: ((time: number) => void)
private parentPlay: (() => void)
private setTimeout: (fn: () => void, delayMillis: number) => number

/**
Expand Down Expand Up @@ -105,7 +105,7 @@ export class Beat {
public playIn(offset = 0): void {
const msOffset = offset * 1000

this.parentPlayIn!(offset)
this.parentPlayIn(offset)

this.setTimeout(() => {
this.isPlaying = true
Expand All @@ -131,7 +131,7 @@ export class Beat {
const msOffset = offset * 1000

if (this.active) {
this.parentPlayIn!(offset)
this.parentPlayIn(offset)
this.setTimeout(() => this.markPlaying(), msOffset)
}

Expand All @@ -146,7 +146,7 @@ export class Beat {
* `isPlaying` and `currentTimeIsPlaying` are both immediately marked true.
*/
public play(): void {
this.parentPlay!()
this.parentPlay()
this.markPlaying()
this.markCurrentTimePlaying()
}
Expand All @@ -163,7 +163,7 @@ export class Beat {
*/
public playIfActive(): void {
if (this.active) {
this.parentPlay!()
this.parentPlay()
this.markPlaying()
}

Expand Down
Loading

0 comments on commit e9aba71

Please sign in to comment.