353 lines
7.3 KiB
JavaScript
353 lines
7.3 KiB
JavaScript
let ctx = new AudioContext()
|
|
|
|
const Millisecond = 1
|
|
const Second = 1000 * Millisecond
|
|
const Minute = 60 * Second
|
|
|
|
class Track {
|
|
constructor() {
|
|
this.startedAt = 0
|
|
this.pausedAt = 0
|
|
window.track = this
|
|
}
|
|
|
|
async Load(url) {
|
|
this.filename = url.split("/").pop()
|
|
let resp = await fetch(url)
|
|
if (resp.ok) {
|
|
let buf = await resp.arrayBuffer()
|
|
this.abuf = await ctx.decodeAudioData(buf)
|
|
} else {
|
|
let options = {
|
|
length: 1,
|
|
sampleRate: 3000,
|
|
}
|
|
this.abuf = new AudioBuffer(options)
|
|
}
|
|
}
|
|
|
|
Duration() {
|
|
if (this.abuf) {
|
|
return this.abuf.duration
|
|
}
|
|
return 0
|
|
}
|
|
}
|
|
|
|
class Playlist {
|
|
constructor(base="./music") {
|
|
this.base = base
|
|
this.list = {}
|
|
this.current = null
|
|
this.Stop()
|
|
}
|
|
|
|
/**
|
|
* Preload a track
|
|
*
|
|
* @param {String} filename
|
|
* @returns {Track}
|
|
*/
|
|
async Add(filename) {
|
|
let track = new Track()
|
|
this.list[filename] = track
|
|
await track.Load(`${this.base}/${filename}`)
|
|
return track
|
|
}
|
|
|
|
/**
|
|
* Load a track by filename
|
|
*
|
|
* @param {String} filename
|
|
*/
|
|
async Load(filename) {
|
|
this.Stop()
|
|
this.current = this.list[filename]
|
|
if (!this.current) {
|
|
this.current = await this.add(filename)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns current position as a percentage (0.0-1.0)
|
|
*/
|
|
Position() {
|
|
let duration = this.Duration()
|
|
let pos = 0
|
|
if (!duration) {
|
|
return 0
|
|
}
|
|
if (this.Playing()) {
|
|
pos = ctx.currentTime - this.startedAt
|
|
pos = Math.min(pos, duration)
|
|
}
|
|
return pos / duration
|
|
}
|
|
|
|
Play(pos=null) {
|
|
let offset = this.pausedAt
|
|
if (pos) {
|
|
offset = this.current.abuf.duration * pos
|
|
}
|
|
this.Stop()
|
|
this.source = new AudioBufferSourceNode(ctx)
|
|
this.source.buffer = this.current.abuf
|
|
this.source.connect(ctx.destination)
|
|
this.source.start(0, offset)
|
|
this.startedAt = ctx.currentTime - offset
|
|
}
|
|
|
|
Pause() {
|
|
let pos = this.CurrentTime()
|
|
this.Stop()
|
|
this.pausedAt = pos
|
|
}
|
|
|
|
Stop() {
|
|
if (this.source) {
|
|
this.source.disconnect()
|
|
this.source.stop()
|
|
}
|
|
this.pausedAt = 0
|
|
this.startedAt = -1
|
|
}
|
|
|
|
Playing() {
|
|
if (this.startedAt > -1) {
|
|
let pos = ctx.currentTime - this.startedAt
|
|
return pos < this.Duration()
|
|
}
|
|
return false
|
|
}
|
|
|
|
PlayPause() {
|
|
if (this.Playing()) {
|
|
this.Pause()
|
|
} else {
|
|
this.Play()
|
|
}
|
|
}
|
|
|
|
Seek(pos) {
|
|
if (this.Playing()) {
|
|
this.Play(pos)
|
|
} else {
|
|
this.pausedAt = this.Duration() * pos
|
|
}
|
|
}
|
|
|
|
CurrentTime() {
|
|
if (this.Playing()) {
|
|
return Math.min(ctx.currentTime - this.startedAt, this.Duration())
|
|
}
|
|
if (this.pausedAt) {
|
|
return this.pausedAt
|
|
}
|
|
return 0
|
|
}
|
|
|
|
Duration() {
|
|
return this.current.Duration()
|
|
}
|
|
}
|
|
|
|
let playlist = new Playlist()
|
|
window.playlist = playlist
|
|
|
|
async function loadTrack(e) {
|
|
let li = e.target
|
|
|
|
playlist.Load(li.textContent)
|
|
|
|
// Update "current"
|
|
for (let cur of document.querySelectorAll(".current")) {
|
|
cur.classList.remove("current")
|
|
}
|
|
li.classList.add("current")
|
|
}
|
|
|
|
function clickOn(element) {
|
|
let e = new MouseEvent("click", {
|
|
view: window,
|
|
bubbles: true,
|
|
cancelable: true
|
|
})
|
|
element.dispatchEvent(e)
|
|
}
|
|
|
|
function prev() {
|
|
let cur = document.querySelector(".current")
|
|
let prev = cur.previousElementSibling
|
|
if (prev) {
|
|
cur = prev
|
|
}
|
|
clickOn(cur)
|
|
}
|
|
|
|
function next() {
|
|
let cur = document.querySelector(".current")
|
|
let next = cur.nextElementSibling
|
|
if (next) {
|
|
cur = next
|
|
}
|
|
clickOn(cur)
|
|
}
|
|
|
|
function ended() {
|
|
next()
|
|
}
|
|
|
|
function mmss(duration) {
|
|
let mm = Math.floor(duration / Minute)
|
|
let ss = Math.floor((duration / Second) % 60)
|
|
|
|
if (ss < 10) {
|
|
ss = "0" + ss
|
|
}
|
|
return mm + ":" + ss
|
|
}
|
|
|
|
function volumechange(e) {
|
|
document.querySelector("#vol").value = e.target.volume
|
|
}
|
|
|
|
|
|
function timeupdate() {
|
|
let currentTime = playlist.CurrentTime() * Second
|
|
let duration = playlist.Duration() * Second
|
|
let tgt = document.querySelector("#currentTime")
|
|
let pos = document.querySelector("#pos")
|
|
|
|
pos.value = currentTime / duration
|
|
|
|
tgt.textContent = mmss(currentTime)
|
|
if (duration - currentTime < 20 * Second) {
|
|
tgt.classList.add("fin")
|
|
} else {
|
|
tgt.classList.remove("fin")
|
|
}
|
|
}
|
|
|
|
function setPos(e) {
|
|
let val = e.target.value
|
|
playlist.Seek(val)
|
|
}
|
|
|
|
function setGain(e) {
|
|
let val = e.target.value
|
|
let audio = document.querySelector("#audio")
|
|
|
|
audio.volume = val
|
|
}
|
|
|
|
function keydown(e) {
|
|
let audio = document.querySelector("#audio")
|
|
|
|
switch (e.key) {
|
|
case " ": // space bar
|
|
playlist.PlayPause()
|
|
break
|
|
|
|
case "ArrowDown": // Next track
|
|
next()
|
|
break
|
|
|
|
case "ArrowUp": // Previous track
|
|
prev()
|
|
break
|
|
}
|
|
}
|
|
|
|
function midiMessage(e) {
|
|
let audio = document.querySelector("#audio")
|
|
let data = e.data
|
|
let ctrl = data[1]
|
|
let val = data[2]
|
|
if ((data[0] == 0xb0) || (data[0] == 0xbf)) {
|
|
switch (ctrl) {
|
|
case 0: // master volume slider
|
|
audio.volume = val / 127
|
|
document.querySelector("#vol").value = audio.volume
|
|
break
|
|
case 41: // play button
|
|
if (val == 127) {
|
|
// The first time, the browser will reject this,
|
|
// because it doesn't consider MIDI input user interaction,
|
|
// so it looks like an autoplaying video.
|
|
playlist.Play()
|
|
}
|
|
break
|
|
case 42: // stop button
|
|
if (val == 127) {
|
|
playlist.Pause()
|
|
}
|
|
break
|
|
case 58: // prev button
|
|
if (val == 127) {
|
|
prev()
|
|
}
|
|
break
|
|
case 59: // next button
|
|
if (val == 127) {
|
|
next()
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleMidiAccess(access) {
|
|
for (let input of access.inputs.values()) {
|
|
input.addEventListener("midimessage", midiMessage)
|
|
}
|
|
|
|
for (let output of access.outputs.values()) {
|
|
if (output.name == "nanoKONTROL2 MIDI 1") {
|
|
controller = output
|
|
output.send([0xf0, 0x42, 0x40, 0x00, 0x01, 0x13, 0x00, 0x00, 0x00, 0x01, 0xf7]); // Native Mode (lets us control LEDs, requires sysex privilege)
|
|
output.send([0xbf, 0x2a, 0x7f]); // Stop
|
|
output.send([0xbf, 0x29, 0x7f]); // Play
|
|
}
|
|
}
|
|
}
|
|
|
|
function run() {
|
|
let audio = document.querySelector("#audio")
|
|
|
|
// Set up events:
|
|
// - Prev/Next buttons
|
|
// - ended / timeupdate events on audio
|
|
// - Track items
|
|
document.querySelector("#prev").addEventListener("click", prev)
|
|
document.querySelector("#next").addEventListener("click", next)
|
|
document.querySelector("#pos").addEventListener("input", setPos)
|
|
document.querySelector("#vol").addEventListener("input", setGain)
|
|
audio.addEventListener("ended", ended)
|
|
audio.addEventListener("volumechange", volumechange)
|
|
for (let li of document.querySelectorAll("#playlist li")) {
|
|
li.classList.add("loading")
|
|
li.addEventListener("click", loadTrack)
|
|
playlist.Add(li.textContent)
|
|
.then(() => {
|
|
li.classList.remove("loading")
|
|
})
|
|
}
|
|
|
|
setInterval(() => timeupdate(), 250 * Millisecond)
|
|
|
|
document.querySelector("#vol").value = audio.volume
|
|
|
|
// Bind keypress events
|
|
// - space: play/pause
|
|
//
|
|
document.addEventListener("keydown", keydown)
|
|
|
|
// Load up first track
|
|
document.querySelector("#playlist li").classList.add("current")
|
|
prev()
|
|
|
|
navigator.requestMIDIAccess({sysex: true}).then(handleMidiAccess)
|
|
}
|
|
|
|
window.addEventListener("load", run)
|