diff --git a/packages/frontend/src/components/SkModPlayer.vue b/packages/frontend/src/components/SkModPlayer.vue index 2317ad63a..db34c8d8b 100644 --- a/packages/frontend/src/components/SkModPlayer.vue +++ b/packages/frontend/src/components/SkModPlayer.vue @@ -40,7 +40,7 @@ @@ -58,7 +58,7 @@ import { ref, shallowRef, nextTick, onDeactivated, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; -import { ChiptuneJsPlayer, ChiptuneJsConfig } from '@/scripts/chiptune2.js'; +import { ChiptuneJsPlayer } from '@/scripts/chiptune2.js'; const props = defineProps<{ module: Misskey.entities.DriveFile @@ -80,32 +80,32 @@ const playing = ref(false); const modPattern = ref(); const progress = ref(); const position = ref(0); -const player = shallowRef(new ChiptuneJsPlayer(new ChiptuneJsConfig())); -const patData = shallowRef([] as ModRow[][]); +const player = shallowRef(new ChiptuneJsPlayer()); +const patData = shallowRef([]); const currentPattern = ref(0); const nbChannels = ref(0); const length = ref(1); -const loop = ref(0); +const loop = ref(false); const fetching = ref(true); const error = ref(false); const loading = ref(false); let currentRow = 0; let rowHeight = 0; -let buffer = null; +let buffer: ArrayBuffer|null = null; let isSeeking = false; -function load() { - player.value.load(props.module.url).then((result) => { - buffer = result; +async function load() { + try { + buffer = await player.value.load(props.module.url); available.value = true; error.value = false; fetching.value = false; - }).catch((err) => { + } catch (err) { console.error(err); error.value = true; fetching.value = false; - }); + } } onMounted(load); @@ -134,6 +134,10 @@ function getRowText(row: ModRow) { } function playPause() { + if (buffer === null) { + return; + } + player.value.addHandler('onRowChange', (i: { index: number }) => { currentRow = i.index; currentPattern.value = player.value.getPattern(); @@ -152,7 +156,7 @@ function playPause() { loading.value = true; player.value.play(buffer).then(() => { player.value.seek(position.value); - player.value.repeat(loop.value); + player.value.repeat(loop.value ? -1 : 0); playing.value = true; loading.value = false; }); @@ -163,6 +167,10 @@ function playPause() { } async function stop(noDisplayUpdate = false) { + if (buffer === null) { + return; + } + player.value.stop(); playing.value = false; if (!noDisplayUpdate) { @@ -180,8 +188,8 @@ async function stop(noDisplayUpdate = false) { } function toggleLoop() { - loop.value = loop.value === -1 ? 0 : -1; - player.value.repeat(loop.value); + loop.value = !loop.value; + player.value.repeat(loop.value ? -1 : 0); } function initSeek() { @@ -200,30 +208,27 @@ function toggleVisible() { } function isRowActive(i: number) { - if (i === currentRow) { - if (modPattern.value) { - if (rowHeight === 0 && initRow.value) rowHeight = initRow.value[0].getBoundingClientRect().height; - modPattern.value.scrollTop = currentRow * rowHeight; - } - return true; + if (i !== currentRow) { + return false; } - return false; + + if (modPattern.value) { + if (rowHeight === 0 && initRow.value) rowHeight = initRow.value[0].getBoundingClientRect().height; + modPattern.value.scrollTop = currentRow * rowHeight; + } + return true; } function indexText(i: number) { - let rowText = i.toString(16); - if (rowText.length === 1) { - rowText = '0' + rowText; - } - return rowText; + return i.toString(16).padStart(2, '0'); } -function getRow(pattern: number, rowOffset: number) { - let notes: string[] = [], - insts: string[] = [], - vols: string[] = [], - fxs: string[] = [], - ops: string[] = []; +function getRow(pattern: number, rowOffset: number): ModRow { + const notes: string[] = []; + const insts: string[] = []; + const vols: string[] = []; + const fxs: string[] = []; + const ops: string[] = []; for (let channel = 0; channel < nbChannels.value; channel++) { const part = player.value.getPatternRowChannel( @@ -252,15 +257,12 @@ function display(reset = false) { if (!patternShow.value) return; if (reset) { - const pattern = player.value.getPattern(); - currentPattern.value = pattern; + currentPattern.value = player.value.getPattern(); } if (patData.value.length === 0) { const nbPatterns = player.value.getNumPatterns(); - const pattern = player.value.getPattern(); - - currentPattern.value = pattern; + currentPattern.value = player.value.getPattern(); if (player.value.currentPlayingNode) { nbChannels.value = player.value.currentPlayingNode.nbChannels; diff --git a/packages/frontend/src/scripts/chiptune2.ts b/packages/frontend/src/scripts/chiptune2.ts index d6f6a4549..7fef2103d 100644 --- a/packages/frontend/src/scripts/chiptune2.ts +++ b/packages/frontend/src/scripts/chiptune2.ts @@ -1,375 +1,394 @@ -const ChiptuneAudioContext = window.AudioContext; +type HandlerFunction = Function; -export function ChiptuneJsConfig(repeatCount?: number, context?: AudioContext) { - this.repeatCount = repeatCount; - this.context = context; +interface Handler { + eventName: string, + handler: HandlerFunction, } -ChiptuneJsConfig.prototype.constructor = ChiptuneJsConfig; +export class ChiptuneJsPlayer { + libopenmpt; + audioContext: AudioContext; + context: GainNode; + currentPlayingNode: ChiptuneProcessorNode | null; + private handlers: Handler[]; + private touchLocked: boolean; -export function ChiptuneJsPlayer(config: object) { - this.libopenmpt = null; - this.config = config; - this.audioContext = config.context ?? new ChiptuneAudioContext(); - this.context = this.audioContext.createGain(); - this.currentPlayingNode = null; - this.handlers = []; - this.touchLocked = true; - this.volume = 1; -} + constructor() { + this.libopenmpt = null; + this.audioContext = new AudioContext(); + this.context = this.audioContext.createGain(); + this.currentPlayingNode = null; + this.handlers = []; + this.touchLocked = true; + } -ChiptuneJsPlayer.prototype.constructor = ChiptuneJsPlayer; - -ChiptuneJsPlayer.prototype.fireEvent = function (eventName: string, response) { - const handlers = this.handlers; - if (handlers.length > 0) { - for (const handler of handlers) { - if (handler.eventName === eventName) { - handler.handler(response); + fireEvent(eventName: string, response) { + const handlers = this.handlers; + if (handlers.length > 0) { + for (const handler of handlers) { + if (handler.eventName === eventName) { + handler.handler(response); + } } } } -}; -ChiptuneJsPlayer.prototype.addHandler = function ( - eventName: string, - handler: Function, -) { - this.handlers.push({ eventName, handler }); -}; + addHandler( + eventName: string, + handler: HandlerFunction, + ) { + this.handlers.push({ eventName, handler }); + } -ChiptuneJsPlayer.prototype.clearHandlers = function () { - this.handlers = []; -}; + clearHandlers() { + this.handlers = []; + } -ChiptuneJsPlayer.prototype.onEnded = function (handler: Function) { - this.addHandler('onEnded', handler); -}; + onEnded(handler: HandlerFunction) { + this.addHandler('onEnded', handler); + } -ChiptuneJsPlayer.prototype.onError = function (handler: Function) { - this.addHandler('onError', handler); -}; + onError(handler: HandlerFunction) { + this.addHandler('onError', handler); + } -ChiptuneJsPlayer.prototype.duration = function () { - return this.libopenmpt._openmpt_module_get_duration_seconds( - this.currentPlayingNode.modulePtr, - ); -}; + duration(): number { + if (!this.currentPlayingNode) { + return 0; + } -ChiptuneJsPlayer.prototype.position = function () { - return this.libopenmpt._openmpt_module_get_position_seconds( - this.currentPlayingNode.modulePtr, - ); -}; + return this.libopenmpt._openmpt_module_get_duration_seconds( + this.currentPlayingNode.modulePtr, + ); + } + + position(): number { + if (!this.currentPlayingNode) { + return 0; + } + + return this.libopenmpt._openmpt_module_get_position_seconds( + this.currentPlayingNode.modulePtr, + ); + } + + repeat(repeatCount: number) { + if (!this.currentPlayingNode) { + return; + } -ChiptuneJsPlayer.prototype.repeat = function (repeatCount: number) { - if (this.currentPlayingNode) { this.libopenmpt._openmpt_module_set_repeat_count( this.currentPlayingNode.modulePtr, repeatCount, ); } -}; -ChiptuneJsPlayer.prototype.seek = function (position: number) { - if (this.currentPlayingNode) { + seek(position: number) { + if (!this.currentPlayingNode) { + return; + } + this.libopenmpt._openmpt_module_set_position_seconds( this.currentPlayingNode.modulePtr, position, ); } -}; -ChiptuneJsPlayer.prototype.metadata = function () { - const data = {}; - const keys = this.libopenmpt - .UTF8ToString( - this.libopenmpt._openmpt_module_get_metadata_keys( - this.currentPlayingNode.modulePtr, - ), - ) - .split(';'); - let keyNameBuffer = 0; - for (const key of keys) { - keyNameBuffer = this.libopenmpt._malloc(key.length + 1); - this.libopenmpt.stringToUTF8(key, keyNameBuffer); - data[key] = this.libopenmpt.UTF8ToString( - this.libopenmpt._openmpt_module_get_metadata( - this.currentPlayingNode.modulePtr, - keyNameBuffer, - ), - ); - this.libopenmpt._free(keyNameBuffer); + metadata() { + if (this.currentPlayingNode == null) { + return null; + } + + const data: {[key: string]: string} = {}; + const keys = this.libopenmpt + .UTF8ToString( + this.libopenmpt._openmpt_module_get_metadata_keys( + this.currentPlayingNode.modulePtr, + ), + ) + .split(';'); + let keyNameBuffer = 0; + for (const key of keys) { + keyNameBuffer = this.libopenmpt._malloc(key.length + 1); + this.libopenmpt.stringToUTF8(key, keyNameBuffer); + data[key] = this.libopenmpt.UTF8ToString( + this.libopenmpt._openmpt_module_get_metadata( + this.currentPlayingNode.modulePtr, + keyNameBuffer, + ), + ); + this.libopenmpt._free(keyNameBuffer); + } + return data; } - return data; -}; -ChiptuneJsPlayer.prototype.unlock = function () { - const context = this.audioContext; - const buffer = context.createBuffer(1, 1, 22050); - const unlockSource = context.createBufferSource(); - unlockSource.buffer = buffer; - unlockSource.connect(this.context); - this.context.connect(context.destination); - unlockSource.start(0); - this.touchLocked = false; -}; + unlock() { + const buffer = this.audioContext.createBuffer(1, 1, 22050); + const unlockSource = this.audioContext.createBufferSource(); + unlockSource.buffer = buffer; + unlockSource.connect(this.context); + this.context.connect(this.audioContext.destination); + unlockSource.start(0); + this.touchLocked = false; + } -ChiptuneJsPlayer.prototype.load = function (input) { - return new Promise((resolve, reject) => { + async load(input: string): Promise { if (this.touchLocked) { this.unlock(); } - if (input instanceof File) { - const reader = new FileReader(); - reader.onload = () => { - resolve(reader.result); - }; - reader.readAsArrayBuffer(input); - } else { - window - .fetch(input) - .then((response) => { - response - .arrayBuffer() - .then((arrayBuffer) => { - resolve(arrayBuffer); - }) - .catch((error) => { - reject(error); - }); - }) - .catch((error) => { - reject(error); - }); - } - }); -}; -ChiptuneJsPlayer.prototype.play = async function (buffer: ArrayBuffer) { - this.unlock(); - this.stop(); - return this.createLibopenmptNode(buffer, this.buffer).then((processNode) => { - if (processNode === null) { - return; - } + const response = await fetch(input); + const arrayBuffer = await response.arrayBuffer(); + return arrayBuffer; + } + + async play(buffer: ArrayBuffer) { + this.unlock(); + this.stop(); + const processNode = await this.createLibopenmptNode(buffer); this.libopenmpt._openmpt_module_set_repeat_count( processNode.modulePtr, - this.config.repeatCount ?? 0, + 0, ); this.currentPlayingNode = processNode; - processNode.connect(this.context); + processNode.processNode.connect(this.context); this.context.connect(this.audioContext.destination); - }); -}; + } -ChiptuneJsPlayer.prototype.stop = function () { - if (this.currentPlayingNode != null) { - this.currentPlayingNode.disconnect(); + stop() { + if (this.currentPlayingNode == null) { + return; + } + + this.currentPlayingNode.processNode.disconnect(); this.currentPlayingNode.cleanup(); this.currentPlayingNode = null; } -}; -ChiptuneJsPlayer.prototype.togglePause = function () { - if (this.currentPlayingNode != null) { + togglePause() { + if (this.currentPlayingNode == null) { + return; + } + this.currentPlayingNode.togglePause(); } -}; -ChiptuneJsPlayer.prototype.getPattern = function () { - if (this.currentPlayingNode?.modulePtr) { - return this.libopenmpt._openmpt_module_get_current_pattern( - this.currentPlayingNode.modulePtr, - ); + getPattern() { + if (this.currentPlayingNode?.modulePtr) { + return this.libopenmpt._openmpt_module_get_current_pattern( + this.currentPlayingNode.modulePtr, + ); + } + return 0; } - return 0; -}; -ChiptuneJsPlayer.prototype.getRow = function () { - if (this.currentPlayingNode?.modulePtr) { - return this.libopenmpt._openmpt_module_get_current_row( - this.currentPlayingNode.modulePtr, - ); + getRow() { + if (this.currentPlayingNode?.modulePtr) { + return this.libopenmpt._openmpt_module_get_current_row( + this.currentPlayingNode.modulePtr, + ); + } + return 0; } - return 0; -}; -ChiptuneJsPlayer.prototype.getNumPatterns = function () { - if (this.currentPlayingNode?.modulePtr) { - return this.libopenmpt._openmpt_module_get_num_patterns( - this.currentPlayingNode.modulePtr, - ); + getNumPatterns() { + if (this.currentPlayingNode?.modulePtr) { + return this.libopenmpt._openmpt_module_get_num_patterns( + this.currentPlayingNode.modulePtr, + ); + } + return 0; } - return 0; -}; -ChiptuneJsPlayer.prototype.getPatternNumRows = function (pattern: number) { - if (this.currentPlayingNode?.modulePtr) { - return this.libopenmpt._openmpt_module_get_pattern_num_rows( - this.currentPlayingNode.modulePtr, - pattern, - ); - } - return 0; -}; - -ChiptuneJsPlayer.prototype.getPatternRowChannel = function ( - pattern: number, - row: number, - channel: number, -) { - if (this.currentPlayingNode?.modulePtr) { - return this.libopenmpt.UTF8ToString( - this.libopenmpt._openmpt_module_format_pattern_row_channel( + getPatternNumRows(pattern: number) { + if (this.currentPlayingNode?.modulePtr) { + return this.libopenmpt._openmpt_module_get_pattern_num_rows( this.currentPlayingNode.modulePtr, pattern, - row, - channel, - 0, - true, - ), + ); + } + return 0; + } + + getPatternRowChannel( + pattern: number, + row: number, + channel: number, + ) { + if (this.currentPlayingNode?.modulePtr) { + return this.libopenmpt.UTF8ToString( + this.libopenmpt._openmpt_module_format_pattern_row_channel( + this.currentPlayingNode.modulePtr, + pattern, + row, + channel, + 0, + true, + ), + ); + } + return ''; + } + + async createLibopenmptNode(buffer: ArrayBuffer) { + if (!this.libopenmpt) { + const libopenmpt = await import('libopenmpt-wasm'); + this.libopenmpt = await libopenmpt.default(); + } + + return new ChiptuneProcessorNode(this, buffer); + } +} + +class ChiptuneProcessorNode { + player: ChiptuneJsPlayer; + processNode: ScriptProcessorNode; + paused: boolean; + + nbChannels: number; + patternIndex: number; + + modulePtr: number; + leftBufferPtr: number; + rightBufferPtr: number; + + constructor(player: ChiptuneJsPlayer, buffer: ArrayBuffer) { + const maxFramesPerChunk = 4096; + + this.player = player; + this.processNode = this.player.audioContext.createScriptProcessor(2048, 0, 2); + + const libopenmpt = player.libopenmpt; + const byteArray = new Int8Array(buffer); + const ptrToFile = libopenmpt._malloc(byteArray.byteLength); + libopenmpt.HEAPU8.set(byteArray, ptrToFile); + + this.modulePtr = libopenmpt._openmpt_module_create_from_memory( + ptrToFile, + byteArray.byteLength, + 0, + 0, + 0, ); - } - return ''; -}; + this.nbChannels = libopenmpt._openmpt_module_get_num_channels( + this.modulePtr, + ); + this.patternIndex = -1; + this.paused = false; + this.leftBufferPtr = libopenmpt._malloc(4 * maxFramesPerChunk); + this.rightBufferPtr = libopenmpt._malloc(4 * maxFramesPerChunk); -ChiptuneJsPlayer.prototype.createLibopenmptNode = async function ( - buffer, - config: object, -) { - const maxFramesPerChunk = 4096; - const processNode = this.audioContext.createScriptProcessor(2048, 0, 2); - processNode.config = config; - processNode.player = this; + this.processNode.addEventListener('audioprocess', (ev) => { + const outputL = ev.outputBuffer.getChannelData(0); + const outputR = ev.outputBuffer.getChannelData(1); + let framesToRender = outputL.length; + if (this.modulePtr === 0) { + for (let i = 0; i < framesToRender; ++i) { + outputL[i] = 0; + outputR[i] = 0; + } + this.processNode.disconnect(); + this.cleanup(); + return; + } + if (this.paused) { + for (let i = 0; i < framesToRender; ++i) { + outputL[i] = 0; + outputR[i] = 0; + } + return; + } + let framesRendered = 0; + let ended = false; + let error = false; - if (!this.libopenmpt) { - const libopenmpt = await import('libopenmpt-wasm'); - this.libopenmpt = await libopenmpt.default(); + const currentPattern = + this.player.libopenmpt._openmpt_module_get_current_pattern( + this.modulePtr, + ); + const currentRow = + this.player.libopenmpt._openmpt_module_get_current_row( + this.modulePtr, + ); + if (currentPattern !== this.patternIndex) { + this.player.fireEvent('onPatternChange'); + } + this.player.fireEvent('onRowChange', { index: currentRow }); + + while (framesToRender > 0) { + const framesPerChunk = Math.min(framesToRender, maxFramesPerChunk); + const actualFramesPerChunk = + this.player.libopenmpt._openmpt_module_read_float_stereo( + this.modulePtr, + this.processNode.context.sampleRate, + framesPerChunk, + this.leftBufferPtr, + this.rightBufferPtr, + ); + if (actualFramesPerChunk === 0) { + ended = true; + // modulePtr will be 0 on openmpt: error: openmpt_module_read_float_stereo: ERROR: module * not valid or other openmpt error + error = !this.modulePtr; + } + const rawAudioLeft = this.player.libopenmpt.HEAPF32.subarray( + this.leftBufferPtr / 4, + this.leftBufferPtr / 4 + actualFramesPerChunk, + ); + const rawAudioRight = this.player.libopenmpt.HEAPF32.subarray( + this.rightBufferPtr / 4, + this.rightBufferPtr / 4 + actualFramesPerChunk, + ); + for (let i = 0; i < actualFramesPerChunk; ++i) { + outputL[framesRendered + i] = rawAudioLeft[i]; + outputR[framesRendered + i] = rawAudioRight[i]; + } + for (let i = actualFramesPerChunk; i < framesPerChunk; ++i) { + outputL[framesRendered + i] = 0; + outputR[framesRendered + i] = 0; + } + framesToRender -= framesPerChunk; + framesRendered += framesPerChunk; + } + if (ended) { + this.processNode.disconnect(); + this.cleanup(); + error + ? this.player.fireEvent('onError', { type: 'openmpt' }) + : this.player.fireEvent('onEnded'); + } + }); } - const byteArray = new Int8Array(buffer); - const ptrToFile = this.libopenmpt._malloc(byteArray.byteLength); - this.libopenmpt.HEAPU8.set(byteArray, ptrToFile); - processNode.modulePtr = this.libopenmpt._openmpt_module_create_from_memory( - ptrToFile, - byteArray.byteLength, - 0, - 0, - 0, - ); - processNode.nbChannels = this.libopenmpt._openmpt_module_get_num_channels( - processNode.modulePtr, - ); - processNode.patternIndex = -1; - processNode.paused = false; - processNode.leftBufferPtr = this.libopenmpt._malloc(4 * maxFramesPerChunk); - processNode.rightBufferPtr = this.libopenmpt._malloc(4 * maxFramesPerChunk); - processNode.cleanup = function () { + cleanup() { if (this.modulePtr !== 0) { - processNode.player.libopenmpt._openmpt_module_destroy(this.modulePtr); + this.player.libopenmpt._openmpt_module_destroy(this.modulePtr); this.modulePtr = 0; } if (this.leftBufferPtr !== 0) { - processNode.player.libopenmpt._free(this.leftBufferPtr); + this.player.libopenmpt._free(this.leftBufferPtr); this.leftBufferPtr = 0; } if (this.rightBufferPtr !== 0) { - processNode.player.libopenmpt._free(this.rightBufferPtr); + this.player.libopenmpt._free(this.rightBufferPtr); this.rightBufferPtr = 0; } - }; - processNode.stop = function () { - this.disconnect(); + } + + stop() { + this.processNode.disconnect(); this.cleanup(); - }; - processNode.pause = function () { + } + + pause() { this.paused = true; - }; - processNode.unpause = function () { + } + + unpause() { this.paused = false; - }; - processNode.togglePause = function () { + } + + togglePause() { this.paused = !this.paused; - }; - processNode.onaudioprocess = function (e) { - const outputL = e.outputBuffer.getChannelData(0); - const outputR = e.outputBuffer.getChannelData(1); - let framesToRender = outputL.length; - if (this.ModulePtr === 0) { - for (let i = 0; i < framesToRender; ++i) { - outputL[i] = 0; - outputR[i] = 0; - } - this.disconnect(); - this.cleanup(); - return; - } - if (this.paused) { - for (let i = 0; i < framesToRender; ++i) { - outputL[i] = 0; - outputR[i] = 0; - } - return; - } - let framesRendered = 0; - let ended = false; - let error = false; + } +} - const currentPattern = - processNode.player.libopenmpt._openmpt_module_get_current_pattern( - this.modulePtr, - ); - const currentRow = - processNode.player.libopenmpt._openmpt_module_get_current_row( - this.modulePtr, - ); - if (currentPattern !== this.patternIndex) { - processNode.player.fireEvent('onPatternChange'); - } - processNode.player.fireEvent('onRowChange', { index: currentRow }); - - while (framesToRender > 0) { - const framesPerChunk = Math.min(framesToRender, maxFramesPerChunk); - const actualFramesPerChunk = - processNode.player.libopenmpt._openmpt_module_read_float_stereo( - this.modulePtr, - this.context.sampleRate, - framesPerChunk, - this.leftBufferPtr, - this.rightBufferPtr, - ); - if (actualFramesPerChunk === 0) { - ended = true; - // modulePtr will be 0 on openmpt: error: openmpt_module_read_float_stereo: ERROR: module * not valid or other openmpt error - error = !this.modulePtr; - } - const rawAudioLeft = processNode.player.libopenmpt.HEAPF32.subarray( - this.leftBufferPtr / 4, - this.leftBufferPtr / 4 + actualFramesPerChunk, - ); - const rawAudioRight = processNode.player.libopenmpt.HEAPF32.subarray( - this.rightBufferPtr / 4, - this.rightBufferPtr / 4 + actualFramesPerChunk, - ); - for (let i = 0; i < actualFramesPerChunk; ++i) { - outputL[framesRendered + i] = rawAudioLeft[i]; - outputR[framesRendered + i] = rawAudioRight[i]; - } - for (let i = actualFramesPerChunk; i < framesPerChunk; ++i) { - outputL[framesRendered + i] = 0; - outputR[framesRendered + i] = 0; - } - framesToRender -= framesPerChunk; - framesRendered += framesPerChunk; - } - if (ended) { - this.disconnect(); - this.cleanup(); - error - ? processNode.player.fireEvent('onError', { type: 'openmpt' }) - : processNode.player.fireEvent('onEnded'); - } - }; - return processNode; -};