forked from sent/waves
422 lines
15 KiB
JavaScript
422 lines
15 KiB
JavaScript
// hi hello this is the code for actually preloading and playing sequences
|
|
|
|
let MAX_BPM_LIMIT = 20_000 // wow this seems suspiciously easy to redefine
|
|
|
|
// convert sequence to json
|
|
function getSequenceData() {
|
|
let sequenceData = $('#sequence div').map(function (index) {
|
|
return {
|
|
index,
|
|
element: $(this),
|
|
sound: $(this).attr("sound"),
|
|
pitch: $(this).attr("pitch"),
|
|
volume: $(this).attr("vol"),
|
|
action: $(this).attr("action"),
|
|
icon: $(this).attr("img"),
|
|
group: +$(this).parent().attr("group") || 0,
|
|
amount: $(this).attr("amount"),
|
|
operator: $(this).attr("num"),
|
|
dualVal: [$(this).attr("val1"), $(this).attr("val2")]
|
|
}
|
|
}).toArray()
|
|
return sequenceData
|
|
}
|
|
|
|
function beatLength(bpm) {
|
|
return 60 / bpm * 1000
|
|
}
|
|
|
|
function modifyNumber(num, newNum, operator) {
|
|
switch (operator) {
|
|
case "add": return num + newNum
|
|
case "multiply": return num * newNum
|
|
default: return newNum
|
|
}
|
|
}
|
|
|
|
// fetches any uncached sounds in the sequence to prevent lag spikes
|
|
async function fetchRequiredSounds(sequence=getSequenceData()) {
|
|
// for loops are better for async stuff
|
|
let soundKeys = Object.keys(sounds)
|
|
for (let i=0; i < sequence.length; i++) {
|
|
let x = sequence[i]
|
|
if (x.sound && x.sound != "sounds/_pause.wav" && !soundKeys.includes(x.sound)) {
|
|
await fetchSound(x.sound)
|
|
soundKeys.push(x.sound)
|
|
}
|
|
}
|
|
}
|
|
|
|
function preloadSequence(sequence=getSequenceData()) {
|
|
let order = []
|
|
|
|
let bpm = defaultTempo
|
|
let volume = defaultVolume
|
|
let loopTarget = 0
|
|
let transposition = 0
|
|
|
|
let index = 0
|
|
let timer = 0
|
|
let scrubPos = 0
|
|
let pulseID = 0
|
|
|
|
let startPositions = sequence.filter(x => x.action == "startpos" && (selectedDivider >= 0 ? x.group == selectedDivider : true))
|
|
if (startPositions.length) scrubPos = startPositions[startPositions.length - 1].index
|
|
else if (selectedDivider >= 0) scrubPos = (sequence.find(x => x.group == selectedDivider) || {}).index || 0
|
|
|
|
let scrubbing = scrubPos > 0
|
|
|
|
function untrigger(idx, except=[]) {
|
|
let untriggered = []
|
|
sequence.slice(idx + 1).map((x, y) => {
|
|
if (!except.includes(x.action)) {
|
|
if (x.triggered) { x.triggered = false; untriggered.push(idx + 1 + y) }
|
|
if (x.remaining <= 0) { delete x.remaining; untriggered.push(idx + 1 + y) }
|
|
}
|
|
return x;
|
|
})
|
|
return untriggered
|
|
}
|
|
|
|
while (index < sequence.length) {
|
|
let x = sequence[index]
|
|
let incrementTimer = false
|
|
|
|
if (scrubbing && index == scrubPos) scrubbing = false
|
|
|
|
// if the event is a sound...
|
|
if (x.sound) {
|
|
let vol = !isNaN(+x.volume) ? +x.volume : 100 // dead certain theres a browser that wont support ??
|
|
// construct the sound data
|
|
let soundObj = {
|
|
index,
|
|
sound: x.sound,
|
|
volume: volume * clamp((vol) / 100, 0, 4),
|
|
pitch: clamp((+x.pitch || 0) + transposition, -72, 72),
|
|
time: timer / 1000,
|
|
element: x.element,
|
|
img: x.element.find("img")
|
|
}
|
|
|
|
let nextSound = sequence[index + 1] || {}
|
|
if (nextSound.action != "combine") incrementTimer = true
|
|
if (!scrubbing) order.push(soundObj)
|
|
}
|
|
|
|
// if the event is an action...
|
|
else if (x.action) {
|
|
|
|
let val = Number(x.amount) || 0
|
|
|
|
let actionObj = {
|
|
index,
|
|
action: x.action,
|
|
pulse: true,
|
|
trigger: true,
|
|
scrub: scrubbing,
|
|
time: timer / 1000,
|
|
element: x.element,
|
|
img: x.element.find("img")
|
|
}
|
|
|
|
// do something different for each action
|
|
switch (x.action) {
|
|
|
|
// ⏩ change BPM
|
|
case "speed":
|
|
bpm = modifyNumber(bpm, val, x.operator)
|
|
bpm = Number(clamp(bpm, 5, MAX_BPM_LIMIT).toFixed(4))
|
|
actionObj.bpm = bpm
|
|
break;
|
|
|
|
// 🔊 change volume
|
|
case "volume":
|
|
volume = modifyNumber(volume, val, x.operator)
|
|
volume = Number(clamp(volume, 0, 600).toFixed(4))
|
|
actionObj.volume = volume
|
|
break;
|
|
|
|
// ⏸ pause for duration
|
|
case "stop":
|
|
let beatsRemaining = isNaN(x.remaining) ? val : x.remaining // i could have used ?? but i'm sure theres a browser out there that doesnt support it. fuck webdev
|
|
if (!scrubbing && beatsRemaining > 0) {
|
|
let timeToRemove = Math.min(1, beatsRemaining)
|
|
timer += beatLength(bpm) * timeToRemove
|
|
|
|
beatsRemaining -= 1
|
|
if (beatsRemaining < 0) beatsRemaining = 0
|
|
|
|
actionObj.remaining = beatsRemaining
|
|
sequence[index].remaining = beatsRemaining
|
|
index--
|
|
actionObj.trigger = false
|
|
}
|
|
else {
|
|
actionObj.finished = true
|
|
actionObj.duration = val
|
|
}
|
|
break;
|
|
|
|
// 🔁 multiple loops
|
|
case "loopmany":
|
|
let loopsRemaining = isNaN(x.remaining) ? val : x.remaining
|
|
if (!scrubbing && loopsRemaining > 0) {
|
|
loopsRemaining--
|
|
actionObj.remaining = loopsRemaining
|
|
sequence[index].remaining = loopsRemaining
|
|
index = loopTarget - 1
|
|
if (loopsRemaining < 1) actionObj.pulse = false
|
|
else actionObj.trigger = false
|
|
|
|
// untrigger: single loops, targets
|
|
actionObj.untriggered = untrigger(index, ["loopmany"])
|
|
}
|
|
else actionObj.skip = true;
|
|
break;
|
|
|
|
// 🔂 single loop
|
|
case "loop":
|
|
if (!x.triggered) {
|
|
sequence[index].triggered = true
|
|
index = loopTarget - 1
|
|
|
|
// untrigger: targets
|
|
actionObj.untriggered = untrigger(index, ["loop", "loopmany"])
|
|
}
|
|
else actionObj.skip = true;
|
|
break;
|
|
|
|
// ◇ loop target
|
|
case "looptarget":
|
|
loopTarget = index
|
|
break;
|
|
|
|
// ❎ stop sounds
|
|
case "cut":
|
|
break; // nothing, actually
|
|
|
|
// 📍 startpos
|
|
case "startpos":
|
|
break; // handled earlier
|
|
|
|
// ↔ combine
|
|
case "combine":
|
|
if (scrubbing) actionObj.skip = true
|
|
break; // combining is checked for sounds
|
|
|
|
// ⏺ go to target
|
|
case "jump":
|
|
if (!x.triggered) {
|
|
let foundTarget = sequence.findIndex(e => e.action == "target" && !e.triggered && e.amount == x.amount)
|
|
if (foundTarget >= 0) {
|
|
sequence[index].triggered = true
|
|
// sequence[foundTarget].triggered = true ???
|
|
actionObj.target = foundTarget
|
|
index = foundTarget
|
|
}
|
|
|
|
actionObj.untriggered = untrigger(index, ["loop", "loopmany", "jump", "target"])
|
|
}
|
|
else actionObj.skip = true;
|
|
break;
|
|
|
|
// ⭕ target
|
|
case "target":
|
|
actionObj.trigger = false
|
|
actionObj.pulse = false
|
|
break; // handled by jump action
|
|
|
|
// 🔼 raise or lower pitch of all future sounds
|
|
case "transpose":
|
|
transposition = modifyNumber(transposition, val, x.operator)
|
|
transposition = Number(clamp(transposition, -60, 60).toFixed(4))
|
|
break;
|
|
|
|
// ⚡ flash
|
|
case "flash":
|
|
break; // nothing here, go figure
|
|
|
|
// ⛶ pulse screen
|
|
case "pulse":
|
|
if (scrubbing) actionObj.skip = true // disable pulse scrubbing, will fix eventually
|
|
else {
|
|
actionObj.count = Math.floor(clamp(+x.dualVal[0], 0, 1000))
|
|
actionObj.frequency = clamp(Number(x.dualVal[1]).toFixed(4), 0, 1000)
|
|
actionObj.trigger = false
|
|
actionObj.pulseID = pulseID
|
|
if (!actionObj.frequency) actionObj.skip = true
|
|
|
|
if (actionObj.count > 1 && actionObj.frequency > 0) {
|
|
for (let i=1; i < actionObj.count; i++) {
|
|
let pulseTime = timer + ((beatLength(bpm) * actionObj.frequency * i))
|
|
if (!order.find(e => e.action == x.action && e.time == pulseTime)) order.push({
|
|
index,
|
|
action: x.action,
|
|
pulse: true,
|
|
pulseID,
|
|
trigger: i == actionObj.count - 1,
|
|
time: pulseTime / 1000,
|
|
element: x.element, img: actionObj.img
|
|
})
|
|
}
|
|
pulseID++
|
|
}
|
|
else actionObj.trigger = true
|
|
if (actionObj.count < 1) actionObj.stopPulses = true
|
|
}
|
|
break;
|
|
|
|
// 🎨 background color
|
|
case "bg":
|
|
actionObj.bgColor = x.dualVal[0].match(colorRegex) ? x.dualVal[0] : defaultBG
|
|
actionObj.fadeTime = scrubbing ? 0.1 : clamp(Number(x.dualVal[1]).toFixed(4), 0, 200)
|
|
break;
|
|
}
|
|
|
|
if (!actionObj.skip) order.push(actionObj)
|
|
|
|
}
|
|
|
|
index++
|
|
if (!scrubbing && incrementTimer) timer += beatLength(bpm)
|
|
}
|
|
return order.sort((a, b) => a.time - b.time)
|
|
}
|
|
|
|
let lastPos = -200 // last scroll position
|
|
let lastPulse = 0 // last pulse action
|
|
let startTime = 0 // time when sequence started
|
|
|
|
let playingSequence = [];
|
|
let nextSoundToQueue = 0;
|
|
let nextAction = 0;
|
|
|
|
function playSequence(sequence) {
|
|
updateTempo(defaultTempo)
|
|
setVolume(defaultVolume)
|
|
lastPulse = 0
|
|
startTime = soundcloud.currentTime
|
|
|
|
playingSequence = sequence;
|
|
nextSoundToQueue = 0;
|
|
nextAction = 0;
|
|
queueSounds();
|
|
checkActions();
|
|
}
|
|
|
|
// how many seconds of sound to queue ahead.
|
|
const queueAhead = 5;
|
|
|
|
function queueSounds() {
|
|
for ("tempooptimizer wtf is this for loop -colon"; nextSoundToQueue < playingSequence.length; nextSoundToQueue++) {
|
|
let x = playingSequence[nextSoundToQueue];
|
|
if (startTime + x.time > soundcloud.currentTime + queueAhead) break;
|
|
|
|
// this is either a sound or a cut
|
|
if (x.action === "cut") cutSounds(startTime + x.time);
|
|
else if (x.sound !== undefined) {
|
|
let pitch = semitonesToPercent(clamp(x.pitch, -72, 72))
|
|
playSound(x.sound, { pitch, playAt: startTime + x.time, index: x.index, volume: x.volume / 200 })
|
|
}
|
|
}
|
|
if (nextSoundToQueue < playingSequence.length) {
|
|
// use setTimeout for background playback, requestAnimationFrame for less lag spikes -TempoOptimizer
|
|
// works for me -Colon
|
|
setTimeout(queueSounds, 1000);
|
|
}
|
|
}
|
|
|
|
function checkActions() {
|
|
if (!active) return;
|
|
|
|
// run for each action where time > current time
|
|
for ("ksdjfsdf8sdkfjsdkfjak"; nextAction < playingSequence.length; nextAction++) {
|
|
let x = playingSequence[nextAction];
|
|
if (startTime + x.time > soundcloud.currentTime) break; // stops the loop
|
|
if (x.triggered) continue; // does not stop the loop
|
|
|
|
x.triggered = true
|
|
let icon = x.element
|
|
let img = x.img
|
|
|
|
// sounds just bounce!
|
|
if (x.sound) {
|
|
icon.runAnimation('bounce')
|
|
}
|
|
|
|
else if (x.action && !settings.noAnimations) {
|
|
|
|
if (x.pulse) icon.runAnimation('pulse');
|
|
if (x.trigger) img.runAnimation('triggered');
|
|
|
|
if (x.untriggered && x.untriggered.length) {
|
|
x.untriggered.forEach(n => {
|
|
let foundUntrigger = $('#sequence div').eq(n)
|
|
foundUntrigger.find('img').removeClass('triggered')
|
|
})
|
|
}
|
|
|
|
// here we go again!
|
|
switch (x.action) {
|
|
|
|
case "speed":
|
|
updateTempo(x.bpm)
|
|
break;
|
|
|
|
case "volume":
|
|
setVolume(x.volume);
|
|
break;
|
|
|
|
case "stop":
|
|
let beatsLeft = icon.find('p')
|
|
beatsLeft.attr("triggeredCountdown", true)
|
|
if (x.finished) beatsLeft.text(x.duration)
|
|
else beatsLeft.text(x.remaining + 1)
|
|
break;
|
|
|
|
case "loopmany":
|
|
let loopsLeft = icon.find('p')
|
|
loopsLeft.attr("triggeredCountdown", true)
|
|
if (x.remaining <= 0) loopsLeft.text("")
|
|
else loopsLeft.text(x.remaining)
|
|
break;
|
|
|
|
case "jump":
|
|
let jumpTarget = $('#sequence div').eq(x.target)
|
|
jumpTarget.runAnimation('pulse')
|
|
jumpTarget.find('img').runAnimation('triggered')
|
|
break;
|
|
|
|
case "flash":
|
|
if (!x.scrub) $('#everything').runAnimation('screenflash');
|
|
break;
|
|
|
|
case "pulse":
|
|
if (x.pulseID > lastPulse) lastPulse = x.pulseID
|
|
if (!x.scrub && !x.stopPulses && (lastPulse == x.pulseID)) $('body').runAnimation('screenpulse');
|
|
break;
|
|
|
|
case "bg":
|
|
if (!x.ignore) $('html').css('transition-duration', x.fadeTime + "s").css('background-color', x.bgColor)
|
|
}
|
|
|
|
}
|
|
|
|
if (settings.autoScroll) {
|
|
let pixelsBeforeScroll = 600
|
|
let newPos = icon.prop("offsetTop") - 150
|
|
if (Math.abs(newPos - lastPos) > pixelsBeforeScroll) {
|
|
$('#everything').stop().animate({ scrollTop: newPos }, 350)
|
|
lastPos = newPos
|
|
}
|
|
if (x.action == "divider") lastPos = -1000 // dividers always trigger autoscroll
|
|
}
|
|
}
|
|
|
|
if (nextAction < playingSequence.length) {
|
|
requestAnimationFrame(checkActions);
|
|
} else {
|
|
cancel({ keepAnimations: true });
|
|
}
|
|
}
|