feat: Implement module player

This commit is contained in:
Essem 2023-09-22 03:48:54 +00:00 committed by Kainoa Kanter
parent 260f444fb8
commit 22ef74d92c
12 changed files with 2829 additions and 1461 deletions

View file

@ -28,6 +28,10 @@ Machine learning model for sensitive images by Infinite Red, Inc.
License: MIT
https://github.com/infinitered/nsfwjs/blob/master/LICENSE
Chiptune2.js by Simon Gündling
License: MIT
https://github.com/deskjet/chiptune2.js#license
Licenses for all softwares and software libraries installed via the Node Package Manager ("npm") can be found by running the following shell command in the root directory of this repository:
pnpm licenses list

View file

@ -132,6 +132,7 @@ rememberNoteVisibility: "Remember post visibility settings"
attachCancel: "Remove attachment"
markAsSensitive: "Mark as NSFW"
unmarkAsSensitive: "Unmark as NSFW"
clickToShowPatterns: "Click to show module patterns"
enterFileName: "Enter filename"
mute: "Mute"
unmute: "Unmute"

View file

@ -120,6 +120,7 @@ rememberNoteVisibility: "Lembrar das configurações de visibilidade de notas"
attachCancel: "Remover anexo"
markAsSensitive: "Marcar como sensível"
unmarkAsSensitive: "Desmarcar como sensível"
clickToShowPatterns: "Clique para mostrar os padrões do módulo"
enterFileName: "Digite o nome do ficheiro"
mute: "Silenciar"
unmute: "Dessilenciar"

View file

@ -68,6 +68,15 @@ export const FILE_TYPE_BROWSERSAFE = [
"audio/x-flac",
"audio/flac",
"audio/vnd.wave",
"audio/mod",
"audio/x-mod",
"audio/s3m",
"audio/x-s3m",
"audio/xm",
"audio/x-xm",
"audio/it",
"audio/x-it",
];
/*
https://github.com/sindresorhus/file-type/blob/main/supported.js

View file

@ -492,7 +492,7 @@ router.get("/notes/:note", async (ctx, next) => {
ctx.set("Cache-Control", "public, max-age=15");
ctx.set(
"Content-Security-Policy",
"default-src 'self' 'unsafe-inline'; img-src *; frame-ancestors *",
"default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src *; font-src 'self' data:; img-src *; media-src *; worker-src 'self'; frame-ancestors *",
);
return;

View file

@ -61,6 +61,7 @@
"insert-text-at-cursor": "0.3.0",
"json5": "2.2.3",
"katex": "0.16.8",
"libopenmpt-wasm": "github:TheEssem/libopenmpt-packaging#build",
"matter-js": "0.19.0",
"mfm-js": "0.23.3",
"photoswipe": "5.3.9",

View file

@ -12,16 +12,28 @@
:class="{ dmWidth: inDm }"
>
<div ref="gallery" @click.stop>
<XMedia
<template
v-for="media in mediaList.filter((media) =>
previewable(media),
)"
>
<XMedia
v-if="
media.type.startsWith('video') ||
media.type.startsWith('image')
"
:key="media.id"
:class="{ image: media.type.startsWith('image') }"
:data-id="media.id"
:media="media"
:raw="raw"
/>
<XModPlayer
v-else-if="isModule(media)"
:key="media.id"
:module="media"
/>
</template>
</div>
</div>
</div>
@ -35,8 +47,13 @@ import PhotoSwipe from "photoswipe";
import "photoswipe/style.css";
import XBanner from "@/components/MkMediaBanner.vue";
import XMedia from "@/components/MkMedia.vue";
import XModPlayer from "@/components/MkModPlayer.vue";
import * as os from "@/os";
import { FILE_TYPE_BROWSERSAFE } from "@/const";
import {
FILE_TYPE_BROWSERSAFE,
FILE_TYPE_TRACKER_MODULES,
FILE_EXT_TRACKER_MODULES,
} from "@/const";
const props = defineProps<{
mediaList: misskey.entities.DriveFile[];
@ -170,11 +187,24 @@ onMounted(() => {
const previewable = (file: misskey.entities.DriveFile): boolean => {
if (file.type === "image/svg+xml") return true; // svgwebpublic/thumbnailpngtrue
// FILE_TYPE_BROWSERSAFE
if (isModule(file)) return true;
return (
(file.type.startsWith("video") || file.type.startsWith("image")) &&
FILE_TYPE_BROWSERSAFE.includes(file.type)
);
};
const isModule = (file: misskey.entities.DriveFile): boolean => {
return (
FILE_TYPE_TRACKER_MODULES.some((type) => {
return file.type === type;
}) ||
FILE_EXT_TRACKER_MODULES.some((ext) => {
return file.name.toLowerCase().endsWith("." + ext);
})
);
};
const previewableCount = props.mediaList.filter((media) =>
previewable(media),
).length;

View file

@ -0,0 +1,516 @@
<template>
<div class="mod-player-disabled" v-if="!available">
<MkLoading v-if="fetching" />
<MkError v-else-if="error" @retry="load()" />
</div>
<div class="mod-player-disabled" v-else-if="hide" @click="toggleVisible()">
<div>
<b
><i class="ph-warning ph-bold ph-lg"></i>
{{ i18n.ts.sensitive }}</b
>
<span>{{ i18n.ts.clickToShow }}</span>
</div>
</div>
<div class="mod-player-enabled" v-else>
<div class="pattern-display">
<div class="mod-pattern" ref="modPattern" v-if="patternShow">
<span
v-for="(row, i) in patData[currentPattern]"
ref="initRow"
v-bind:class="{ modRowActive: isRowActive(i) }"
v-if="patData.length !== 0"
>
<span v-bind:class="{ modColQuarter: i % 4 === 0 }">{{
indexText(i)
}}</span>
<span class="mod-row-inner">{{ getRowText(row) }}</span>
</span>
<MkLoading v-else />
</div>
<div class="mod-pattern" v-else @click="showPattern()">
<span class="modRowActive" ref="initRow">
<span class="modColQuarter">00</span>
<span class="mod-row-inner">|F-12Ev10XEF</span>
</span>
<br />
<p>{{ i18n.ts.clickToShowPatterns }}</p>
</div>
</div>
<div class="controls">
<button class="play" @click="playPause()" v-if="!loading">
<i class="ph-pause ph-fill ph-lg" v-if="playing"></i>
<i class="ph-play ph-fill ph-lg" v-else></i>
</button>
<MkLoading v-else :em="true" />
<button class="stop" @click="stop()">
<i class="ph-stop ph-fill ph-lg"></i>
</button>
<button class="loop" @click="toggleLoop()">
<i class="ph-repeat ph-fill ph-lg" v-if="loop === -1"></i>
<i class="ph-repeat-once ph-fill ph-lg" v-else></i>
</button>
<FormRange
class="progress"
:min="0"
:max="length"
v-model="position"
:step="0.1"
ref="progress"
:background="false"
:tooltips="false"
:instant="true"
@update:modelValue="performSeek()"
></FormRange>
<button class="mute" @click="toggleMute()">
<i class="ph-speaker-simple-x ph-fill ph-lg" v-if="muted"></i>
<i class="ph-speaker-simple-high ph-fill ph-lg" v-else></i>
</button>
<FormRange
class="volume"
:min="0"
:max="1"
v-model="player.context.gain.value"
:step="0.1"
:background="false"
:tooltips="false"
:instant="true"
@update:modelValue="updateMute()"
></FormRange>
<a
class="download"
:title="i18n.ts.download"
:href="module.url"
target="_blank"
>
<i class="ph-download-simple ph-fill ph-lg"></i>
</a>
</div>
<div class="buttons">
<button
v-if="module.comment"
v-tooltip="i18n.ts.alt"
class="_button"
@click.stop="captionPopup"
>
<i class="ph-subtitles ph-bold ph-lg"></i>
</button>
<button
v-if="!hide"
v-tooltip="i18n.ts.hide"
class="_button"
@click.stop="toggleVisible()"
>
<i class="ph-eye-slash ph-bold ph-lg"></i>
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, shallowRef, nextTick, onDeactivated, onMounted } from "vue";
import * as firefish from "firefish-js";
import FormRange from "./form/range.vue";
import { i18n } from "@/i18n";
import * as os from "@/os";
import { defaultStore } from "@/store";
import { ChiptuneJsPlayer, ChiptuneJsConfig } from "@/scripts/chiptune2";
const props = defineProps<{
module: firefish.entities.DriveFile;
}>();
interface ModRow {
notes: string[];
insts: string[];
vols: string[];
fxs: string[];
ops: string[];
}
const available = ref(false);
const initRow = shallowRef<HTMLSpanElement>();
const player = shallowRef(new ChiptuneJsPlayer(new ChiptuneJsConfig()));
let hide = ref(
defaultStore.state.nsfw === "force"
? true
: props.module.isSensitive && defaultStore.state.nsfw !== "ignore",
);
let playing = ref(false);
let patternShow = ref(false);
let modPattern = ref<HTMLDivElement>();
let progress = ref<typeof FormRange>();
let position = ref(0);
let patData = shallowRef([] as ModRow[][]);
let currentPattern = ref(0);
let nbChannels = ref(0);
let length = ref(1);
let muted = ref(false);
let loop = ref(0);
let fetching = ref(true);
let error = ref(false);
let loading = ref(false);
function load() {
player.value
.load(props.module.url)
.then((result: null) => {
buffer = result;
available.value = true;
error.value = false;
fetching.value = false;
})
.catch((e: any) => {
console.error(e);
error.value = true;
fetching.value = false;
});
}
onMounted(load);
let currentRow = 0;
let rowHeight = 0;
let buffer = null;
let isSeeking = false;
function captionPopup() {
os.alert({
type: "info",
text: props.module.comment,
});
}
function showPattern() {
patternShow.value = !patternShow.value;
nextTick(() => {
if (playing.value) display();
else stop();
});
}
function getRowText(row: ModRow) {
let text = "";
for (let i = 0; i < row.notes.length; i++) {
text = text.concat(
"|",
row.notes[i],
row.insts[i],
row.vols[i],
row.fxs[i],
row.ops[i],
);
}
return text;
}
function playPause() {
player.value.addHandler("onRowChange", (i: { index: number }) => {
currentRow = i.index;
currentPattern.value = player.value.getPattern();
length.value = player.value.duration();
if (!isSeeking) {
position.value = player.value.position();
}
requestAnimationFrame(display);
});
player.value.addHandler("onEnded", () => {
stop();
});
if (player.value.currentPlayingNode === null) {
loading.value = true;
player.value.play(buffer).then(() => {
player.value.seek(position.value);
player.value.repeat(loop.value);
playing.value = true;
loading.value = false;
});
} else {
player.value.togglePause();
playing.value = !player.value.currentPlayingNode.paused;
}
}
async function stop(noDisplayUpdate = false) {
player.value.stop();
playing.value = false;
if (!noDisplayUpdate) {
try {
await player.value.play(buffer);
display(0, true);
} catch (e) {
console.warn(e);
}
}
player.value.stop();
position.value = 0;
currentRow = 0;
player.value.clearHandlers();
}
function toggleLoop() {
loop.value = loop.value === -1 ? 0 : -1;
player.value.repeat(loop.value);
}
let savedVolume = 0;
function toggleMute() {
if (muted.value) {
player.value.context.gain.value = savedVolume;
savedVolume = 0;
} else {
savedVolume = player.value.context.gain.value;
player.value.context.gain.value = 0;
}
muted.value = !muted.value;
}
function updateMute() {
muted.value = false;
savedVolume = 0;
}
function performSeek() {
player.value.seek(position.value);
display();
}
function toggleVisible() {
hide.value = !hide.value;
nextTick(() => {
stop(hide.value);
});
}
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;
}
return;
}
function indexText(i: number) {
let rowText = i.toString(16);
if (rowText.length === 1) {
rowText = "0" + rowText;
}
return rowText;
}
function getRow(pattern: number, rowOffset: number) {
let notes: string[] = [],
insts: string[] = [],
vols: string[] = [],
fxs: string[] = [],
ops: string[] = [];
for (let channel = 0; channel < nbChannels.value; channel++) {
const part = player.value.getPatternRowChannel(
pattern,
rowOffset,
channel,
);
notes.push(part.substring(0, 3));
insts.push(part.substring(4, 6));
vols.push(part.substring(6, 9));
fxs.push(part.substring(10, 11));
ops.push(part.substring(11, 13));
}
return {
notes,
insts,
vols,
fxs,
ops,
};
}
function display(_time = 0, reset = false) {
if (!patternShow.value) return;
if (reset) {
const pattern = player.value.getPattern();
currentPattern.value = pattern;
}
if (patData.value.length === 0) {
const nbPatterns = player.value.getNumPatterns();
const pattern = player.value.getPattern();
currentPattern.value = pattern;
if (player.value.currentPlayingNode) {
nbChannels.value = player.value.currentPlayingNode.nbChannels;
}
const patternsArray: ModRow[][] = [];
for (let patOffset = 0; patOffset < nbPatterns; patOffset++) {
const rowsArray: ModRow[] = [];
const nbRows = player.value.getPatternNumRows(patOffset);
for (let rowOffset = 0; rowOffset < nbRows; rowOffset++) {
rowsArray.push(getRow(patOffset, rowOffset));
}
patternsArray.push(rowsArray);
}
patData.value = Object.freeze(patternsArray);
}
}
onDeactivated(() => {
stop();
});
</script>
<style lang="scss" scoped>
.mod-player-enabled {
position: relative;
display: flex;
flex-direction: column;
> i {
display: block;
position: absolute;
border-radius: 6px;
background-color: var(--fg);
color: var(--accentLighten);
font-size: 14px;
opacity: 0.5;
padding: 3px 6px;
text-align: center;
cursor: pointer;
top: 12px;
right: 12px;
}
> .buttons {
display: flex;
gap: 4px;
position: absolute;
border-radius: 6px;
overflow: hidden;
top: 12px;
right: 12px;
> * {
background-color: var(--accentedBg);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
color: var(--accent);
font-size: 0.8em;
padding: 6px 8px;
text-align: center;
}
}
> .pattern-display {
width: 100%;
height: 100%;
overflow: hidden;
color: #ffffff;
background-color: black;
text-align: center;
font: 12px monospace;
white-space: pre;
user-select: none;
> .mod-pattern {
display: grid;
overflow-y: hidden;
height: 0;
padding-top: calc((56.25% - 48px) / 2);
padding-bottom: calc((56.25% - 48px) / 2);
content-visibility: auto;
> .modRowActive {
opacity: 1;
}
> span {
opacity: 0.5;
> .modColQuarter {
color: #ffff00;
}
> .mod-row-inner {
background: repeating-linear-gradient(
to right,
white 0 4ch,
#80e0ff 4ch 6ch,
#80ff80 6ch 9ch,
#ff80e0 9ch 10ch,
#ffe080 10ch 12ch
);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
}
}
> .controls {
display: flex;
width: 100%;
background-color: var(--panelHighlight);
> * {
padding: 4px 8px;
}
> button,
a {
border: none;
background-color: transparent;
color: var(--navFg);
cursor: pointer;
margin: auto;
&:hover {
background-color: var(--accentedBg);
border-radius: 3px;
}
}
> .progress {
flex-grow: 1;
min-width: 0;
}
> .volume {
flex-shrink: 1;
max-width: 128px;
}
}
}
.mod-player-disabled {
display: flex;
justify-content: center;
align-items: center;
background: #111;
color: #fff;
> div {
display: table-cell;
text-align: center;
font-size: 12px;
> b {
display: block;
}
}
}
</style>

View file

@ -1,7 +1,7 @@
<template>
<label class="timctyfi" :class="{ disabled, easing }">
<div class="label"><slot name="label"></slot></div>
<div v-adaptive-border class="body">
<div v-adaptive-border class="body" :class="{ background }">
<div class="container">
<input
ref="inputEl"
@ -19,7 +19,12 @@
@touchend="tooltipHide"
@mouseenter="tooltipShow"
@mouseleave="tooltipHide"
@input="(x) => (inputVal = x.target.value)"
@input="
(x) => {
inputVal = x.target.value;
if (instant) onChange(x);
}
"
/>
<datalist v-if="showTicks && steps" :id="id">
<option
@ -50,11 +55,17 @@ const props = withDefaults(
textConverter?: (value: number) => string;
showTicks?: boolean;
easing?: boolean;
background?: boolean;
tooltips?: boolean;
instant?: boolean;
}>(),
{
step: 1,
textConverter: (v) => v.toString(),
easing: false,
background: true,
tooltips: true,
instant: false,
},
);
@ -79,6 +90,7 @@ function onChange(x) {
const tooltipShowing = ref(false);
function tooltipShow() {
if (!props.tooltips) return;
tooltipShowing.value = true;
os.popup(
defineAsyncComponent(() => import("@/components/MkTooltip.vue")),
@ -94,6 +106,7 @@ function tooltipShow() {
);
}
function tooltipHide() {
if (!props.tooltips) return;
tooltipShowing.value = false;
}
</script>
@ -128,13 +141,21 @@ function tooltipHide() {
$thumbWidth: 20px;
> .body {
padding: 10px 0;
background: none;
border: none;
border-radius: 6px;
&.background {
padding: 10px 12px;
background: var(--panel);
border: solid 1px var(--panel);
border-radius: 6px;
}
> .container {
position: relative;
display: flex;
align-items: center;
height: $thumbHeight;
@mixin track {
@ -155,6 +176,7 @@ function tooltipHide() {
&:hover {
background: var(--accentLighten);
cursor: pointer;
}
}
> input {

View file

@ -38,6 +38,74 @@ export const FILE_TYPE_BROWSERSAFE = [
"audio/x-flac",
"audio/vnd.wave",
];
export const FILE_TYPE_TRACKER_MODULES = [
"audio/mod",
"audio/x-mod",
"audio/s3m",
"audio/x-s3m",
"audio/xm",
"audio/x-xm",
"audio/it",
"audio/x-it",
];
export const FILE_EXT_TRACKER_MODULES = [
"mptm",
"mod",
"s3m",
"xm",
"it",
"667",
"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",
"gtk",
"gt2",
"ult",
"wow",
"xmf",
"gdm",
"mo3",
"oxm",
"umx",
"xpk",
"ppm",
"mmcmp",
];
/*
https://github.com/sindresorhus/file-type/blob/main/supported.js
https://github.com/sindresorhus/file-type/blob/main/core.js

View file

@ -0,0 +1,372 @@
import wasm from "libopenmpt-wasm";
const ChiptuneAudioContext = window.AudioContext;
export function ChiptuneJsConfig(repeatCount?: number, context?: AudioContext) {
this.repeatCount = repeatCount;
this.context = context;
}
ChiptuneJsConfig.prototype.constructor = ChiptuneJsConfig;
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;
}
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);
}
}
}
};
ChiptuneJsPlayer.prototype.addHandler = function (
eventName: string,
handler: Function,
) {
this.handlers.push({ eventName, handler });
};
ChiptuneJsPlayer.prototype.clearHandlers = function () {
this.handlers = [];
};
ChiptuneJsPlayer.prototype.onEnded = function (handler: Function) {
this.addHandler("onEnded", handler);
};
ChiptuneJsPlayer.prototype.onError = function (handler: Function) {
this.addHandler("onError", handler);
};
ChiptuneJsPlayer.prototype.duration = function () {
return this.libopenmpt._openmpt_module_get_duration_seconds(
this.currentPlayingNode.modulePtr,
);
};
ChiptuneJsPlayer.prototype.position = function () {
return this.libopenmpt._openmpt_module_get_position_seconds(
this.currentPlayingNode.modulePtr,
);
};
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) {
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);
}
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;
};
ChiptuneJsPlayer.prototype.load = function (input) {
return new Promise((resolve, reject) => {
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;
}
this.libopenmpt._openmpt_module_set_repeat_count(
processNode.modulePtr,
this.config.repeatCount || 0,
);
this.currentPlayingNode = processNode;
processNode.connect(this.context);
this.context.connect(this.audioContext.destination);
});
};
ChiptuneJsPlayer.prototype.stop = function () {
if (this.currentPlayingNode != null) {
this.currentPlayingNode.disconnect();
this.currentPlayingNode.cleanup();
this.currentPlayingNode = null;
}
};
ChiptuneJsPlayer.prototype.togglePause = function () {
if (this.currentPlayingNode != null) {
this.currentPlayingNode.togglePause();
}
};
ChiptuneJsPlayer.prototype.getPattern = function () {
if (this.currentPlayingNode?.modulePtr) {
return this.libopenmpt._openmpt_module_get_current_pattern(
this.currentPlayingNode.modulePtr,
);
}
return 0;
};
ChiptuneJsPlayer.prototype.getRow = function () {
if (this.currentPlayingNode?.modulePtr) {
return this.libopenmpt._openmpt_module_get_current_row(
this.currentPlayingNode.modulePtr,
);
}
return 0;
};
ChiptuneJsPlayer.prototype.getNumPatterns = function () {
if (this.currentPlayingNode?.modulePtr) {
return this.libopenmpt._openmpt_module_get_num_patterns(
this.currentPlayingNode.modulePtr,
);
}
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(
this.currentPlayingNode.modulePtr,
pattern,
row,
channel,
0,
true,
),
);
}
return "";
};
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;
if (!this.libopenmpt) this.libopenmpt = await wasm();
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 () {
if (this.modulePtr !== 0) {
processNode.player.libopenmpt._openmpt_module_destroy(this.modulePtr);
this.modulePtr = 0;
}
if (this.leftBufferPtr !== 0) {
processNode.player.libopenmpt._free(this.leftBufferPtr);
this.leftBufferPtr = 0;
}
if (this.rightBufferPtr !== 0) {
processNode.player.libopenmpt._free(this.rightBufferPtr);
this.rightBufferPtr = 0;
}
};
processNode.stop = function () {
this.disconnect();
this.cleanup();
};
processNode.pause = function () {
this.paused = true;
};
processNode.unpause = function () {
this.paused = false;
};
processNode.togglePause = function () {
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;
};

File diff suppressed because it is too large Load diff