Skip to content

Commit

Permalink
Config and Demo player
Browse files Browse the repository at this point in the history
  • Loading branch information
DrSnuggles committed Jan 24, 2024
1 parent c584023 commit 685757e
Show file tree
Hide file tree
Showing 6 changed files with 265 additions and 13 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Modernized ES6 module version with libopenmpt AudioWorklet backend

See: https://DrSnuggles.github.io/chiptune

Modland demo player: https://DrSnuggles.github.io/chiptune/demo.html

Drop in your favorite songs.

## Build
Expand All @@ -22,6 +24,7 @@ If you know the answer please let me know.
- build/rollup to make it a single .js request

## History
- 2024-01-24: Added config object, Modland player
- 2024-01-23: Drag'n'Drop files. Build library using Docker.
- 2024-01-22: Libopenmpt 0.7.3 compiled with Emscripten 3.1.51

Expand Down
24 changes: 15 additions & 9 deletions chiptune3.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,36 @@
/*
chiptune3 (worklet version)
based on: https://deskjet.github.io/chiptune2.js/
2 ways to use:
- new ChiptuneJsPlay() : Uses new AudioContext() and outputs to gain and speakers
- new ChiptuneJsPlay(ctx) : Uses given ctx and only outputs to gain
*/

const defaultCfg = {
repeatCount: -1, // -1 = play endless, 0 = play once, do not repeat
stereoSeparation: 100, // percents
interpolationFilter: 0, // https://lib.openmpt.org/doc/group__openmpt__module__render__param.html
context: false,
}

export class ChiptuneJsPlayer {
constructor(ctx) {
constructor(cfg) {
this.config = {...defaultCfg, ...cfg}

if (ctx) {
if (!ctx.destination) {
if (this.config.context) {
if (!this.config.context.destination) {
//console.error('This is not an audio context.')
throw('ChiptuneJsPlayer: This is not an audio context')
}
this.context = ctx
this.context = this.config.context
this.destination = false
} else {
this.context = new AudioContext()
this.destination = this.context.destination // output to speakers
}
delete this.config.context // remove from config, just used here and after init not changeable

// make gainNode
this.gain = this.context.createGain()
this.gain.gain.value = 1

this.config = {repeatCount: -1}
this.handlers = []

// worklet
Expand All @@ -38,6 +43,7 @@ export class ChiptuneJsPlayer {
})
// message port
this.processNode.port.onmessage = this.handleMessage_.bind(this)
this.processNode.port.postMessage({cmd:'config', val:this.config})
this.fireEvent('onInitialized')

// audio routing
Expand Down
17 changes: 13 additions & 4 deletions chiptune3.worklet.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class MPT extends AudioWorkletProcessor {
this.port.onmessage = this.handleMessage_.bind(this)
this.paused = false
this.config = {
repeatCount: -1,
repeatCount: -1, // -1 = play endless, 0 = play once, do not repeat
stereoSeparation: 100, // percents
interpolationFilter: 0, // https://lib.openmpt.org/doc/group__openmpt__module__render__param.html
}
Expand Down Expand Up @@ -83,6 +83,9 @@ class MPT extends AudioWorkletProcessor {
//console.log('[Processor:Received]',msg.data)
const v = msg.data.val
switch (msg.data.cmd) {
case 'config':
this.config = v
break
case 'play':
this.play(v)
break
Expand Down Expand Up @@ -137,6 +140,12 @@ class MPT extends AudioWorkletProcessor {
libopenmpt.HEAPU8.set(byteArray, ptrToFile)
this.modulePtr = libopenmpt._openmpt_module_create_from_memory(ptrToFile, byteArray.byteLength, 0, 0, 0)

if(this.modulePtr === 0) {
// could not create module
this.port.postMessage({cmd:'err',val:'ptr'})
return
}

if (libopenmpt.stackSave) {
const stack = libopenmpt.stackSave()
libopenmpt._openmpt_module_ctl_set(this.modulePtr, asciiToStack('render.resampler.emulate_amiga'), asciiToStack('1'))
Expand All @@ -152,8 +161,8 @@ class MPT extends AudioWorkletProcessor {

// set config options on module
libopenmpt._openmpt_module_set_repeat_count(this.modulePtr, this.config.repeatCount)
//libopenmpt._openmpt_module_set_render_param(this.modulePtr, OPENMPT_MODULE_RENDER_STEREOSEPARATION_PERCENT, this.config.stereoSeparation)
//libopenmpt._openmpt_module_set_render_param(this.modulePtr, OPENMPT_MODULE_RENDER_INTERPOLATIONFILTER_LENGTH, this.config.interpolationFilter)
libopenmpt._openmpt_module_set_render_param(this.modulePtr, OPENMPT_MODULE_RENDER_STEREOSEPARATION_PERCENT, this.config.stereoSeparation)
libopenmpt._openmpt_module_set_render_param(this.modulePtr, OPENMPT_MODULE_RENDER_INTERPOLATIONFILTER_LENGTH, this.config.interpolationFilter)

// post back tracks metadata
this.meta()
Expand Down Expand Up @@ -183,7 +192,7 @@ class MPT extends AudioWorkletProcessor {
data.dur = libopenmpt._openmpt_module_get_duration_seconds(this.modulePtr)
if (data.dur == 0) {
// looks like an error occured reading the mod
this.port.postMessage({cmd:'err',val:'Duration'})
this.port.postMessage({cmd:'err',val:'dur'})
}
const keys = libopenmpt.UTF8ToString(libopenmpt._openmpt_module_get_metadata_keys(this.modulePtr)).split(';')
for (let i = 0; i < keys.length; i++) {
Expand Down
14 changes: 14 additions & 0 deletions css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,18 @@ pre {
opacity: 0;
z-index: -604;
}
}

#info {
width: calc(100% - 427px);
}
#myCanvas {
position: absolute;
top: 0;
right: 0;
height: 240px;
}
#songSel {
width: 100%;
height: calc(100vh - 260px);
}
12 changes: 12 additions & 0 deletions demo.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Chiptune Worklet Demo</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="description" content="Chiptune Audio Player"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2280%22>🔊</text></svg>"/>
<link href="./css/style.css" rel="stylesheet"/>
<script src="./demo.js" type="module"></script>
</head>
<body></body>
</html>
208 changes: 208 additions & 0 deletions demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import {LDR} from 'https://DrSnuggles.github.io/LDR/ldr-zip.min.js'
import {kkRows} from 'https://DrSnuggles.github.io/kkRows/js/kk-rows.min.js'
import {Visualizer} from 'https://DrSnuggles.github.io/visualizer/visualizer.min.js'
import {dnd} from './dnd.js'
import {ChiptuneJsPlayer} from './chiptune3.js'

let isLoading = false

function setMetadata() {
const metadata = player.meta
if(!metadata) return
document.getElementById('title').innerHTML = metadata['title']

var subsongs = player.meta.songs
document.getElementById('subsongs').style.display = (subsongs.length > 1) ? 'block' : 'none'
if(subsongs.length > 1) {
var select = document.getElementById('subsong')
// remove old
for (let i = select.options.length-1; i > -1; i--) select.removeChild(select.options[i])
var elt = document.createElement('option')
elt.textContent = 'Play all subsongs'
elt.value = -1
select.appendChild(elt)
for(var i = 0; i < subsongs.length; i++) {
var elt = document.createElement('option')
elt.textContent = subsongs[i]
elt.value = i
select.appendChild(elt)
}
select.selectedIndex = 0
player.selectSubsong(-1)
}

document.getElementById('seekbar').value = 0
updateDuration()

document.getElementById('library-version').innerHTML = 'Version: '+ player.meta.libopenmptVersion +' ('+ player.meta.libopenmptBuild +')'
}

function updateDuration() {
//var sec_num = player.duration()
var sec_num = player.meta.dur
var minutes = Math.floor(sec_num / 60)
var seconds = Math.floor(sec_num % 60)
if (seconds < 10) {seconds = '0' + seconds }
document.getElementById('duration').innerHTML = minutes + ':' + seconds
document.getElementById('seekbar').max = sec_num
}

// init ChiptunePlayer
function initPlayer() {
window.player = new ChiptuneJsPlayer({repeatCount: 0})
player.gain.gain.value = 0.5
window.viz = new Visualizer(player.gain, myCanvas, {fft:11})

// listen to events
player.onEnded((ev) => {
if(document.getElementById('autoplay').checked) {
nextSong()
}
})
player.onMetadata((meta) => {
player.meta = meta
setMetadata(document.getElementById('modfilename').innerHTML)
})
player.onProgress((pos) => {
document.getElementById('seekbar').value = pos
})
player.onError((err) => {
nextSong()
})

// need to wait till player finished init
function lateInit() {
if (!player.processNode) {
setTimeout(()=>{lateInit()},100)
return
}
// ready!
nextSong()
}
lateInit()

}

window.nextSong = (url) => {
if (isLoading) return
if (url == undefined) {
url = songSel.worker.postMessage({msg:'getRandom', callback:'songSelCallback'})
return
}
const parts = url.split('/')
document.getElementById('modfilename').innerText = parts[parts.length-1]

isLoading = true
LDR.loadURL(url, (o)=>{
if (!o.dat) return // not yet ready (damn, i need a 2nd callback both in one is not nice)
const buffer = o.dat

player.play(buffer)
isLoading = false

pitch.value = 1
tempo.value = 1
sizeInKB.innerText = (buffer.byteLength/1024).toFixed(2)
})
}

// stupid no audio till user interaction policy thingy
function userInteracted() {
removeEventListener('keydown', userInteracted)
removeEventListener('click', userInteracted)
removeEventListener('touchstart', userInteracted)
removeEventListener('contextmenu', userInteracted)

audioModal.classList.add('fadeOut')

initPlayer()

}
addEventListener('keydown', userInteracted)
addEventListener('click', userInteracted)
addEventListener('touchstart', userInteracted)
addEventListener('contextmenu', userInteracted)


init()
function init() {
const allowedExt = 'mptm mod s3m xm it 669 amf ams c67 dbm digi dmf dsm dsym dtm far fmt imf ice j2b m15 mdl med mms mt2 mtm mus nst okt plm psm pt36 ptm sfx sfx2 st26 stk stm stx stp symmod ult wow gdm mo3 oxm umx xpk ppm mmcmp'.split(' ')
let data = []

let url = 'https://modland.com/allmods.zip'
LDR.loadURL(url, (o)=>{
if (!o.dat) return // not finished yet
o.dat = o.dat['allmods.txt']
const decoder = new TextDecoder()
const txt = decoder.decode(o.dat)
let rows = txt.split('\n')
console.log(rows.length +' entries in '+ url)
let found = 0
for (let i = 0; i < rows.length; i++) {
const cols = rows[i].split('\t')
if (cols.length < 2) continue
const tmp = cols[1].split('.')
const ext = (tmp[tmp.length-1] == 'zip') ? tmp[tmp.length-2] : tmp[tmp.length-1] //last = ZIP
if (allowedExt.indexOf(ext) !== -1) {
const parts = cols[1].split('/')
const songname = parts[parts.length-1].replace('.zip','').replace('.'+ext,'') // songname is always the last part
let tracker, artist
// modland
tracker = parts[0]
artist = (parts.length == 5) ? parts[2] : parts[1]
data.push( ['https://modland.com/pub/modules/'+cols[1], tracker, artist, songname, (cols[0]/1024).toFixed(2)] )
found++
}
}
console.log(found +' ('+(found/rows.length*100).toFixed(2)+'%) entries can be played by libopenmpt')

data = data.sort()

// set html
document.body.innerHTML = `<div id="info">
<button onclick="player.togglePause()">Pause / Play</button>
<input id="autoplay" type="checkbox" checked="checked" onchange="player.setRepeatCount(this.checked ? 0 : -1)"/> <label for="autoplay">Automatically play random tune when finished</label>
<br/>
<small id="library-version">&nbsp;</small>
<br/>
Filename: <span id="modfilename"></span> (<span id="sizeInKB"></span> kB)
<br/>
Title: <span id="title"></span> (<span id="duration"></span>)
<br/>
Position: <input id="seekbar" title="Position" type="range" min="0" max="100" value="0" oninput="player.setPos(this.value)"/>
<br/>
Volume: <input id="volume" title="Volume" type="range" min="0" max="1" value="0.5" step="0.0001" oninput="player.setVol(this.value)" ondblclick="this.value = 0.5"/>
<br/>
Pitch: <input id="pitch" title="Pitch" type="range" min="0.0001" max="2" value="1" step="0.0001" oninput="player.setPitch(this.value)" ondblclick="this.value = 1"/>
<br/>
Tempo: <input id="tempo" title="Tempo" type="range" min="0.0001" max="2" value="1" step="0.0001" oninput="player.setTempo(this.value)" ondblclick="this.value = 1"/>
<br/>
<div id="subsongs" style="display:none">Subsongs: <select id="subsong" onchange="player.selectSubsong(this.value)"></select></div>
</div>
<canvas id="myCanvas"></canvas>
<kk-rows id="songSel" cb="songSelCallback" hide="0" head="Tracker|Artist/Year|Song|KB" css="td:nth-child(5){text-align:right}"></kk-rows>
<!-- for audio playback policy -->
<div id="audioModal">👉 💻 👂 🎶</div>`

document.getElementById('songSel').setAttribute('data', JSON.stringify(data) )
// kkRows clicked/getRandom callback
window.songSelCallback = (r) => {
nextSong( r.rng ? r.rng[0] : r[0] )
}

LDR.background = true

oncontextmenu = (ev) => {
nextSong()
ev.preventDefault()
}

// dnd
dnd(window, (aB) => {
modfilename.innerHTML = ''
sizeInKB.innerHTML = ''
player.play(aB)
setMetadata()
})
})
}

0 comments on commit 685757e

Please sign in to comment.