UNPKG

sus-stringify

Version:

A SUS (Sliding Universal Score) stringifier.

193 lines (167 loc) 5.06 kB
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'); }