ref: 86a1bffad7063d1f51dfe44c2126bbdf8b7fe5c8
dir: /music.js/
import {toNumber, toInteger} from "./compile.js"
import {MakeBlock} from "./model.js"
import {audioContext as ctx} from "./stage.js"
import {PositiveSlot, EnumeratedSlot} from "./core.js"
function playInstrument(instrument, note, seconds, destination)
{
let sample = instrument.samples.findLast(sample => sample.note <= note) ?? instrument.samples[0]
if (!sample.buffer) return
let node = ctx.createBufferSource()
node.buffer = sample.buffer
node.playbackRate.value = 2 ** ((note - sample.note) / 12)
let gainNode = ctx.createGain()
let releaseTime = ctx.currentTime + seconds
gainNode.gain.setValueAtTime(1, releaseTime)
gainNode.gain.linearRampToValueAtTime(0, releaseTime + (instrument.release ?? 0))
node.connect(gainNode)
gainNode.connect(destination)
node.start()
}
function playDrum(drum, destination)
{
if (!drum.buffer) return
let node = ctx.createBufferSource()
node.buffer = drum.buffer
node.connect(destination)
node.start()
}
export function extendSprite(sprite, name)
{
return extend(sprite, sprite.stage, name)
}
export function extendStage(stage, name)
{
return extend(stage, stage, name)
}
function extend(sprite, stage, name)
{
let music = {}
let stageMusic = stage.extensions[name] ?? music
let blockFns = {
playDrum: (drum, beats) =>
{
playDrum(drums[toInteger(drum) - 1] ?? drums[0], sprite.audioDestination)
return blockFns.rest(beats)
},
playNote: (note, beats) =>
{
if (toNumber(beats) <= 0) return
playInstrument(music.instrument, toNumber(note), toNumber(beats) / stageMusic.tempo * 60, sprite.audioDestination)
return blockFns.rest(beats)
},
rest: beats => sleep(toNumber(beats) / stageMusic.tempo * 60000),
setInstrument: instrument => music.instrument = instruments[toInteger(instrument) - 1] ?? music.instrument,
setTempo: tempo => stageMusic.tempo = toNumber(tempo),
changeTempo: change => stageMusic.tempo += toNumber(change),
tempo: () => stageMusic.tempo,
}
let blocks = {}
for (let name of Object.keys(blockFns)) {
let upperCaseName = name[0].toUpperCase() + name.slice(1)
blocks[upperCaseName] = MakeBlock({
run: blockFns[name],
async: sync[name] === "async",
slots: slots[name] ?? [],
name: nameFns[name],
shape: name === "tempo" ? "reporter" : undefined,
defaults: defaults[name],
})
}
music.blocks = blocks
music.instrument = sprite.original?.extensions[name].instrument ?? instruments[0]
if (sprite === stage) music.tempo = 60
return music
}
function sleep(duration)
{
if (typeof Animation === "undefined") {
return new Promise(resolve => setTimeout(resolve, duration))
}
let animation = new Animation(new KeyframeEffect(null, null, {duration}), document.timeline)
animation.play()
return animation.finished
}
let icon = `<svg viewbox="0 0 24 24" fill="#48E" stroke="#444"><path d="M12 5v8.55c-.94-.54-2.1-.75-3.33-.32-1.34.48-2.37 1.67-2.61 3.07-.46 2.74 1.86 5.08 4.59 4.65 1.96-.31 3.35-2.11 3.35-4.1V7h2c1.1 0 2-.9 2-2s-.9-2-2-2h-2c-1.1 0-2 .9-2 2z"/></svg>`
function getIcon()
{
let span = document.createElement("span")
span.insertAdjacentHTML("beforeend", icon)
let svg = span.querySelector("svg")
svg.style.setProperty("height", "1.5em")
svg.style.setProperty("vertical-align", "middle")
return span
}
let sync = {
playDrum: "async",
playNote: "async",
rest: "async",
}
let nameFns = {
playDrum: (drum, beats) => [getIcon(), "play drum", drum, "for", beats, "beats"],
playNote: (note, beats) => [getIcon(), "play note", note, "for", beats, "beats"],
rest: beats => [getIcon(), "rest for", beats, "beats"],
setInstrument: instrument => [getIcon(), "set instrument to", instrument],
setTempo: tempo => [getIcon(), "set tempo to", tempo],
changeTempo: change => [getIcon(), "change tempo by", change],
tempo: () => [getIcon(), "tempo"],
}
let slots = {
playDrum: [undefined, PositiveSlot],
playNote: [PositiveSlot, PositiveSlot],
rest: [PositiveSlot],
setInstrument: [undefined],
setTempo: [PositiveSlot],
changeTempo: [PositiveSlot],
}
let defaults = {
playDrum: [1, 0.25],
playNote: [60, 0.25],
rest: [0.25],
setInstrument: [1],
setTempo: [60],
changeTempo: [20],
}
let instruments = [
{name: "Piano", release: 0.5, notes: [24, 36, 48, 60, 72, 84, 96, 108]},
{name: "Electric Piano", release: 0.5, notes: [60]},
{name: "Organ", release: 0.5, notes: [60]},
{name: "Guitar", release: 0.5, notes: [60]},
{name: "Electric Guitar", release: 0.5, notes: [60]},
{name: "Bass", release: 0.25, notes: [36, 48]},
{name: "Pizzicato", release: 0.25, notes: [60]},
{name: "Cello", release: 0.1, notes: [36, 48, 60]},
{name: "Trombone", notes: [36, 48, 60]},
{name: "Clarinet", notes: [48, 60]},
{name: "Saxophone", notes: [36, 60, 84]},
{name: "Flute", notes: [60, 72]},
{name: "Wooden Flute", notes: [60, 72]},
{name: "Bassoon", notes: [36, 48, 60]},
{name: "Choir", release: 0.25, notes: [48, 60, 72]},
{name: "Vibraphone", release: 0.5, notes: [60, 72]},
{name: "Music Box", release: 0.25, notes: [60]},
{name: "Steel Drum", release: 0.5, notes: [60]},
{name: "Marimba", notes: [60]},
{name: "Synth Lead", release: 0.1, notes: [60]},
{name: "Synth Pad", release: 0.25, notes: [60]},
]
let drums = [
{name: "Snare Drum"},
{name: "Bass Drum"},
{name: "Side Stick"},
{name: "Crash Cymbal"},
{name: "Open Hi-Hat"},
{name: "Closed Hi-Hat"},
{name: "Tambourine"},
{name: "Hand Clap"},
{name: "Claves"},
{name: "Wood Block"},
{name: "Cowbell"},
{name: "Triangle"},
{name: "Bongo"},
{name: "Conga"},
{name: "Cabasa"},
{name: "Guiro"},
{name: "Vibraslap"},
{name: "Cuica"},
]
let instrumentOptions = []
let drumOptions = []
for (let [i, instrument] of instruments.entries()) {
instrumentOptions.push(i + 1)
let name = instrument.name.replaceAll(" ", "-").toLowerCase()
let samples = instrument.notes.map(note => ({note}))
instrument.samples = samples
for (let [i, note] of instrument.notes.entries()) {
fetch(new URL(`assets/${name}-${note}.mp3`, import.meta.url))
.then(response => response.arrayBuffer())
.then(buffer => new Promise((resolve, reject) => ctx.decodeAudioData(buffer, resolve, reject)))
.then(buffer => samples[i].buffer = buffer)
}
}
for (let [i, drum] of drums.entries()) {
drumOptions.push(i + 1)
let name = drum.name.replaceAll(" ", "-").toLowerCase()
fetch(new URL(`assets/${name}.mp3`, import.meta.url))
.then(response => response.arrayBuffer())
.then(buffer => new Promise((resolve, reject) => ctx.decodeAudioData(buffer, resolve, reject)))
.then(buffer => drum.buffer = buffer)
}
slots.setInstrument[0] = EnumeratedSlot(instrumentOptions, {canFit: undefined, name: i => `(${i}) ${instruments[i - 1].name}`})
slots.playDrum[0] = EnumeratedSlot(drumOptions, {canFit: undefined, name: i => `(${i}) ${drums[i - 1].name}`})