shithub: scrax

ref: 86a1bffad7063d1f51dfe44c2126bbdf8b7fe5c8
dir: /music.js/

View raw version
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}`})