sus-stringify
Version:
A SUS (Sliding Universal Score) stringifier.
193 lines (167 loc) • 5.06 kB
text/typescript
import { InvalidAlignmentError } from './errors';
const version = '[VI]{version}[/VI]';
function gcd(a: number, b: number): number {
if (a < b) return gcd(b, a);
if (b === 0) return a;
return gcd(b, a % b);
}
import type { Score } from 'sus-types';
export type Raw = {
tick: number;
value: string;
};
const TICK_PER_BEAT = 480;
class DefaultMap<K, V> extends Map<K, V> {
generateDefault: () => V;
constructor(generateDefault: () => V) {
super();
this.generateDefault = generateDefault;
}
override get(key: K) {
const value = super.get(key);
if (value === undefined) {
const defaultValue: V = this.generateDefault();
super.set(key, defaultValue);
return defaultValue;
}
return value;
}
}
class ChannelProvider {
#channelMap: [identifier: number, data: [start: number, end: number]][];
constructor() {
this.#channelMap = [...Array(36).keys()].map((key) => [key, [0, 0]]);
}
generateChannel(startTick: number, endTick: number): number {
const channel = this.#channelMap.find(
([, [start, end]]) =>
(start === 0 && end === 0) || endTick < start || end < startTick,
);
if (!channel) {
throw new Error('No more channel available.');
}
channel[1] = [startTick, endTick];
return channel[0];
}
}
function addQuotes(str: string): string {
return `"${str}"`;
}
export function stringify(
score: Score,
comment = `This file was generated by sus-js v${version}`,
) {
const { metadata, bpms, taps, directionals, slides, barLengths } = score;
let tickPerBeat = TICK_PER_BEAT;
const lines = [];
lines.push(comment);
for (const [header, value] of Object.entries(metadata)) {
if (header !== 'requests') {
lines.push(
`#${header.toUpperCase()} ${
typeof value === 'number' ? value : addQuotes(value as string)
}`,
);
} else {
lines.push('');
for (const request of value as string[]) {
lines.push(`#REQUEST "${request}"`);
if (request.startsWith('ticks_per_beat')) {
tickPerBeat = +request.split(' ')[1];
}
}
}
}
lines.push('');
const noteMaps: DefaultMap<string, { raws: Raw[]; ticksPerMeasure: number }> =
new DefaultMap(() => ({ raws: [], ticksPerMeasure: 0 }));
for (const [measure, value] of barLengths) {
lines.push(`#${measure.toString().padStart(3, '0')}02:${value}`);
}
lines.push('');
let accumulatedTicks = 0;
const barLengthsInTicks = barLengths
.map(([measure, value], ind, arr) => {
const [nextMeasure] = arr[ind + 1] ?? [Number.POSITIVE_INFINITY];
const tick = accumulatedTicks;
accumulatedTicks += (nextMeasure - measure) * value * tickPerBeat;
return { tick, measure, value };
})
.reverse();
const pushRaw = (tick: number, info: string, data: string) => {
const {
tick: startTick,
measure,
value: beatsPerMeasure,
} = barLengthsInTicks.find(({ tick: startTick }) => tick >= startTick) ?? {
tick: 0,
measure: 0,
value: 4,
};
const currentMeasure = Math.floor(
measure + (tick - startTick) / tickPerBeat / beatsPerMeasure,
);
const noteMap = noteMaps.get(
`${currentMeasure.toString().padStart(3, '0')}${info}`,
);
noteMap.raws.push({ tick: tick - startTick, value: data });
noteMap.ticksPerMeasure = beatsPerMeasure * tickPerBeat;
};
if (bpms.length >= 36 ** 2 - 1) {
throw new Error(
`Too much BPMS (${bpms.length} >= 36^2 -1 = ${36 ** 2 - 1})`,
);
}
const bpmIdentifiers = new Map();
for (const [tick, value] of bpms) {
const identifier = (bpmIdentifiers.size + 1).toString(36).padStart(2, '0');
if (!bpmIdentifiers.has(value)) {
bpmIdentifiers.set(value, identifier);
lines.push(`#BPM${bpmIdentifiers.get(value)}:${value}`);
}
pushRaw(tick, '08', bpmIdentifiers.get(value) ?? identifier);
}
for (const { tick, lane, width, type } of taps) {
pushRaw(tick, `1${lane.toString(36)}`, `${type}${width.toString(36)}`);
}
for (const { tick, lane, width, type } of directionals) {
pushRaw(tick, `5${lane.toString(36)}`, `${type}${width.toString(36)}`);
}
const provider = new ChannelProvider();
for (const steps of slides) {
const startTick = steps[0].tick;
const endTick = steps[steps.length - 1].tick;
const channel = provider.generateChannel(startTick, endTick).toString(36);
for (const { tick, lane, width, type } of steps) {
pushRaw(
tick,
`3${lane.toString(36)}${channel}`,
`${type}${width.toString(36)}`,
);
}
}
noteMaps.forEach(({ raws, ticksPerMeasure }, tag) => {
const gcdValue = raws.reduce(
(acc, ele) => gcd(ele.tick, acc),
ticksPerMeasure,
);
if (!Number.isInteger(gcdValue)) {
throw new InvalidAlignmentError(
gcdValue,
raws.map(({ tick }) => tick),
);
}
const data = new Map(
raws
.sort(({ tick: a }, { tick: b }) => a - b)
.map(({ tick, value }) => [tick % ticksPerMeasure, value]),
);
const values: string[] = [];
for (let i = 0; i * gcdValue < ticksPerMeasure; i++) {
const tick = i * gcdValue;
values.push(data.get(tick) ?? '00');
}
lines.push(`#${tag}:${values.join('')}`);
});
return lines.join('\n');
}