Tracker playback support thanks to Jeder

This commit is contained in:
Crimekillz 2024-03-26 12:40:21 +01:00
parent 78725e4267
commit 3678afcc42
15 changed files with 1060 additions and 14 deletions

10
.pnp.cjs generated
View File

@ -8365,6 +8365,7 @@ const RAW_RUNTIME_STATE =
["insert-text-at-cursor", "npm:0.3.0"],\
["json5", "npm:2.2.3"],\
["katex", "npm:0.16.8"],\
["libopenmpt-wasm", "https://github.com/TheEssem/libopenmpt-packaging.git#commit=d05d151a72b638c6312227af0417aca69521172c"],\
["matter-js", "npm:0.18.0"],\
["mfm-js", "npm:0.23.3"],\
["paralint", "npm:1.2.1"],\
@ -16813,6 +16814,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["libopenmpt-wasm", [\
["https://github.com/TheEssem/libopenmpt-packaging.git#commit=d05d151a72b638c6312227af0417aca69521172c", {\
"packageLocation": "./.yarn/cache/libopenmpt-wasm-https-c2e0cb89a7-ace14fcb0e.zip/node_modules/libopenmpt-wasm/",\
"packageDependencies": [\
["libopenmpt-wasm", "https://github.com/TheEssem/libopenmpt-packaging.git#commit=d05d151a72b638c6312227af0417aca69521172c"]\
],\
"linkType": "HARD"\
}]\
]],\
["liftoff", [\
["npm:3.1.0", {\
"packageLocation": "./.yarn/cache/liftoff-npm-3.1.0-6dd0a868bd-af0ea7c51c.zip/node_modules/liftoff/",\

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a0ff96aad5c05d839f740e4d7941a3e1fa15942be56cad2b354b3ebb02ff29c0
size 487564

View File

@ -25,6 +25,10 @@ RsaSignature2017 implementation by Transmute Industries Inc
License: MIT
https://github.com/transmute-industries/RsaSignature2017/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:
`yarn -R info --manifest`

View File

@ -14,6 +14,7 @@ It is made in honor of the grind that went into upstream projects and is not mea
- Highlighted changes:
- Achievements using patches from Sharkey/Misskey
- Chomp! Activity Support (Patch adopted [from mia](https://iceshrimp.dev/mia/withdrawal))
- Fast🚜 Scream🚜 Mod🔌 Tracker/SID playback support (Thanks to [Jeder](https://iceshrimp.dev/iceshrimp/iceshrimp/pulls/490))
- Suppress the "You haven't configured mail" message (Patch adopted [from mia](https://iceshrimp.dev/mia/withdrawal))
- Fixes that allow proper federation of likes with instances using the UA "misskey" (Sharkey)
- First-class Mastodon client API support

View File

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

View File

@ -113,6 +113,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

@ -469,7 +469,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 *; media-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.18.0",
"mfm-js": "0.23.3",
"paralint": "^1.2.1",

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),
)"
:key="media.id"
:class="{ image: media.type.startsWith('image') }"
:data-id="media.id"
:media="media"
:raw="raw"
/>
>
<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";
import { defaultStore } from "@/store";
const props = defineProps<{
@ -171,11 +188,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: var(--fg);
background-color: var(--panelHighlight);
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: var(--badge);
}
> .mod-row-inner {
background: repeating-linear-gradient(
to right,
var(--fg) 0 4ch,
var(--codeBoolean) 4ch 6ch,
var(--codeNumber) 6ch 9ch,
var(--codeString) 9ch 10ch,
var(--error) 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: var(--infoWarnBg);
color: var(--infoWarnFg);
> 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 12px;
background: var(--panel);
border: solid 1px var(--panel);
padding: 10px 0;
background: none;
border: none;
border-radius: 6px;
&.background {
padding: 10px 12px;
background: var(--panel);
border: solid 1px var(--panel);
}
> .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;
};

View File

@ -6522,6 +6522,7 @@ __metadata:
insert-text-at-cursor: "npm:0.3.0"
json5: "npm:2.2.3"
katex: "npm:0.16.8"
libopenmpt-wasm: "github:TheEssem/libopenmpt-packaging#build"
matter-js: "npm:0.18.0"
mfm-js: "npm:0.23.3"
paralint: "npm:^1.2.1"
@ -13794,6 +13795,13 @@ __metadata:
languageName: node
linkType: hard
"libopenmpt-wasm@github:TheEssem/libopenmpt-packaging#build":
version: 0.7.2
resolution: "libopenmpt-wasm@https://github.com/TheEssem/libopenmpt-packaging.git#commit=d05d151a72b638c6312227af0417aca69521172c"
checksum: 10/ace14fcb0e0def3069f6dab96f60c7e20332e6c1a7ae85b144a4b248b98b0afb286a7eedef1605e35181bfeb8fcfca1c3356a52a5816a5c3effc56842bd7c502
languageName: node
linkType: hard
"liftoff@npm:^3.1.0":
version: 3.1.0
resolution: "liftoff@npm:3.1.0"