kshoot-tools
Version:
A collection of tools related to KSH and KSON chart files of K-Shoot Mania
318 lines (317 loc) • 13 kB
JavaScript
import decodeAudio from 'audio-decode';
import { FFT } from 'dsp.js';
import * as kshoot from 'kshoot';
const COMPUTE_SAMPLE_RATE = 1000;
const BUFFER_SIZE = (1 << 15);
const BUFFER_SEC = BUFFER_SIZE / COMPUTE_SAMPLE_RATE;
function GCD(x, y) {
while (y)
[y, x] = [x % y, y];
return x;
}
function getBeatWeight(timing_info) {
const pulse = timing_info.pulse - timing_info.measure.pulse;
const common_beat = GCD(pulse, timing_info.measure.length);
if (common_beat % timing_info.measure.beat_length === 0n)
return 1;
return Number(common_beat) / Number(timing_info.measure.beat_length);
}
function getChartEnergyMap(chart, timing = chart.getTiming()) {
const note_stats = new Map();
const getStat = (timing_info) => {
let entry = note_stats.get(timing_info.pulse);
if (entry)
return entry[1];
entry = [getBeatWeight(timing_info), { notes: 0, lasers: 0 }];
note_stats.set(timing_info.pulse, entry);
return entry[1];
};
for (const [timing_info, notes] of timing.withTimingInfo(chart.buttonNotes())) {
getStat(timing_info).notes += notes.length;
}
for (const [timing_info, conducts] of timing.withTimingInfo(chart.laserConducts())) {
getStat(timing_info).lasers += conducts.filter((conduct) => conduct.action !== kshoot.LaserConductAction.End).length;
}
const energy_map = new Map();
for (const [pulse, value] of note_stats.entries()) {
const stat = value[1];
energy_map.set(pulse, Math.cbrt(stat.notes + 0.5 * stat.lasers));
}
return energy_map;
}
function getMusicEnergy(audio_buffer, offset) {
if (audio_buffer.numberOfChannels === 0)
return null;
const channel_data_x = audio_buffer.getChannelData(0);
const channel_data_y = audio_buffer.getChannelData(audio_buffer.numberOfChannels < 2 ? 0 : 1);
const data_len = Math.min(channel_data_x.length, channel_data_y.length);
const ind_begin = Math.ceil(offset * audio_buffer.sampleRate / COMPUTE_SAMPLE_RATE);
const ind_end = Math.min(data_len, Math.floor((offset + BUFFER_SIZE) * audio_buffer.sampleRate / COMPUTE_SAMPLE_RATE));
if (ind_begin >= ind_end) {
return null;
}
const energy_buffer = new Float32Array(BUFFER_SIZE * 2);
let [pd_l, pd_r] = [0, 0];
let [pv_l, pv_r] = [0, 0];
let [pe_l, pe_r] = [0, 0];
for (let i = ind_begin; i < ind_end; ++i) {
const [d_l, d_r] = [channel_data_x[i] ?? 0, channel_data_y[i] ?? 0];
let [v_l, v_r] = [0, 0];
if (i > 0)
[v_l, v_r] = [d_l - pd_l, d_r - pd_r];
let [a_l, a_r] = [0, 0];
if (i > 1)
[a_l, a_r] = [v_l - pv_l, v_r - pv_r];
// Assume that a mass at d_x is attached with a spring.
// K.E = 1/2 k x^2
// P.E = 1/2 m v^2
// Additionally, at the previous step, it is is assumed that the mass got no external force.
// It's a weird and technically incorrect assumption to make, but it works.
// ma = -kx, k = -(ma/x)
// K.E + P.E = 1/2 m (v^2 - ax)
let [e_l, e_r] = [v_l * pv_l - a_l * d_l, v_r * pv_r - a_r * d_r];
const [de_l, de_r] = [e_l - pe_l, e_r - pe_r];
const e = de_l + de_r;
const buffer_ind_r = i * COMPUTE_SAMPLE_RATE / audio_buffer.sampleRate;
const buffer_ind_f = buffer_ind_r % 1.0;
const buffer_ind = Math.floor(buffer_ind_r) - offset;
if (0 <= buffer_ind && buffer_ind < BUFFER_SIZE) {
energy_buffer[buffer_ind] += e * (1.0 - buffer_ind_f);
if (buffer_ind + 1 < BUFFER_SIZE) {
energy_buffer[buffer_ind + 1] += e * buffer_ind_f;
}
}
[pd_l, pd_r, pv_l, pv_r, pe_l, pe_r] = [d_l, d_r, v_l, v_r, e_l, e_r];
}
return energy_buffer;
}
const FFT_BUFFER = {
X: new FFT(BUFFER_SIZE * 2, COMPUTE_SAMPLE_RATE),
Y: new FFT(BUFFER_SIZE * 2, COMPUTE_SAMPLE_RATE),
};
function getCrossCorrelation(x, y) {
FFT_BUFFER.X.forward(x);
FFT_BUFFER.Y.forward(y);
for (let i = 0; i < BUFFER_SIZE; ++i) {
const x_r = FFT_BUFFER.X.real[i];
const x_i = -FFT_BUFFER.X.imag[i];
const y_r = FFT_BUFFER.Y.real[i];
const y_i = FFT_BUFFER.Y.imag[i];
FFT_BUFFER.X.real[i] = x_r * y_r - x_i * y_i;
FFT_BUFFER.X.imag[i] = x_r * y_i + x_i * y_r;
}
return FFT_BUFFER.X.inverse();
}
export class CrossCorrelation {
data;
half_window_size;
constructor(x, y, half_window_size = 1024) {
const data = getCrossCorrelation(x, y);
const source_half_window_size = Math.min(half_window_size, data.length >> 1);
this.data = new Float32Array(half_window_size * 2);
this.data.set(data.subarray(0, source_half_window_size), 0);
this.data.set(data.subarray(data.length - source_half_window_size), half_window_size * 2 - source_half_window_size);
this.half_window_size = half_window_size;
}
*peaks(prefer_center = this.half_window_size / 2) {
const prefer_center_sq = prefer_center ** 2;
const data = this.data;
const half_window_size = this.half_window_size;
for (let i = -half_window_size; i < half_window_size; ++i) {
const prev_v = data[i <= 0 ? data.length + i - 1 : i - 1];
const curr_v = data[i < 0 ? data.length + i : i];
const next_v = data[i < -1 ? data.length + i + 1 : i + 1];
if (prev_v > curr_v || next_v > curr_v)
continue;
if (prev_v === curr_v && i > 0)
continue;
if (next_v === curr_v && i < 0)
continue;
const center_mul = prefer_center === 0 ? 1.0 : (prefer_center_sq / (prefer_center_sq + i ** 2));
yield [i, curr_v * center_mul];
}
}
bestOffset(prefer_center = this.half_window_size / 2) {
let max_offset = 0;
let max_value = 0;
for (const [offset, value] of this.peaks(prefer_center)) {
if (value > max_value) {
max_offset = offset;
max_value = value;
}
}
return max_offset;
}
}
export class OffsetComputer {
chart_ctx;
chart;
timing;
constructor(chart_ctx) {
this.chart_ctx = chart_ctx;
this.chart = chart_ctx.chart;
this.timing = chart_ctx.timing ?? this.chart.getTiming();
}
getTimeByPulse(pulse) {
return this.timing.getTimeByPulse(pulse);
}
_chart_energy_map = null;
get chart_energy_map() {
if (this._chart_energy_map)
return this.chart_energy_map;
else
return (this._chart_energy_map = getChartEnergyMap(this.chart, this.timing));
}
getChartEnergy() {
const energy_map = this.chart_energy_map;
if (energy_map.size === 0) {
return [0, new Float32Array()];
}
const times = [...energy_map.keys()]
.sort((x, y) => x === y ? 0 : x < y ? -1 : +1)
.map((pulse) => [pulse, (this.chart.audio.bgm.offset + this.timing.getTimeByPulse(pulse)) / 1000]);
let max_begin_ind = 0;
let max_energy = 0;
let range_begin_ind = 0;
let curr_energy = 0;
while (range_begin_ind < times.length && times[range_begin_ind][1] < 0)
++range_begin_ind;
for (let range_end_ind = range_begin_ind; range_end_ind < times.length; ++range_end_ind) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const end_energy = energy_map.get(times[range_end_ind][0]);
const range_end_sec = times[range_end_ind][1];
curr_energy += end_energy;
while (range_begin_ind < range_end_ind) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const begin_energy = energy_map.get(times[range_begin_ind][0]);
const range_begin_sec = times[range_begin_ind][1];
if (range_end_sec < range_begin_sec + BUFFER_SEC) {
break;
}
curr_energy -= begin_energy;
++range_begin_ind;
}
if (curr_energy > max_energy) {
max_energy = curr_energy;
max_begin_ind = range_begin_ind;
}
}
if (max_energy <= 0) {
return [0, new Float32Array()];
}
const energy_buffer = new Float32Array(BUFFER_SIZE * 2);
const sample_offset = Math.floor(times[max_begin_ind][1] * COMPUTE_SAMPLE_RATE);
for (let i = max_begin_ind; i < times.length; ++i) {
const buffer_ind_r = times[i][1] * COMPUTE_SAMPLE_RATE;
const buffer_ind_f = buffer_ind_r % 1.0;
const buffer_ind = Math.floor(buffer_ind_r) - sample_offset;
if (buffer_ind >= BUFFER_SIZE)
break;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const energy = energy_map.get(times[i][0]);
energy_buffer[buffer_ind] += energy * (1.0 - buffer_ind_f);
if (buffer_ind + 1 < BUFFER_SIZE) {
energy_buffer[buffer_ind + 1] += energy * buffer_ind_f;
}
}
return [sample_offset, energy_buffer];
}
async getMusicAudioBuffer(in_audio_file_buffer) {
const bgm_filename = this.chart.audio.bgm.filename;
let audio_file_buffer = null;
if (in_audio_file_buffer) {
audio_file_buffer = in_audio_file_buffer;
}
else if (bgm_filename) {
audio_file_buffer = await this.chart_ctx.resolve(bgm_filename);
}
if (audio_file_buffer == null)
return null;
try {
return await decodeAudio(audio_file_buffer);
}
catch (e) {
return null;
}
}
async computeCrossCorrelation(in_audio_file_buffer) {
const [offset, chart_energy] = this.getChartEnergy();
if (chart_energy.length === 0)
return null;
const audio_buffer = (in_audio_file_buffer == null || Buffer.isBuffer(in_audio_file_buffer)) ?
await this.getMusicAudioBuffer(in_audio_file_buffer) : in_audio_file_buffer;
if (audio_buffer == null)
return null;
const music_energy = getMusicEnergy(audio_buffer, offset);
if (music_energy == null)
return null;
return new CrossCorrelation(chart_energy, music_energy);
}
async computeOffset(in_audio_file_buffer) {
const correlation = await this.computeCrossCorrelation(in_audio_file_buffer);
if (correlation == null) {
return null;
}
return this.chart.audio.bgm.offset + correlation.bestOffset();
}
}
export async function computeOffset(chart_ctx) {
return await (new OffsetComputer(chart_ctx)).computeOffset();
}
export async function computeCrossCorrelation(chart_ctx) {
return await (new OffsetComputer(chart_ctx)).computeCrossCorrelation();
}
export async function drawDebugImage(chart_ctx) {
const offset_computer = new OffsetComputer(chart_ctx);
const corr = await offset_computer.computeCrossCorrelation();
if (corr == null)
return null;
const { createCanvas } = await import('canvas');
const canvas = createCanvas(1600, 400);
const ctx = canvas.getContext('2d');
// Background
{
ctx.beginPath();
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fill();
ctx.beginPath();
ctx.strokeStyle = '#F00';
ctx.lineWidth = 1;
ctx.moveTo(canvas.width / 2, 0);
ctx.lineTo(canvas.width / 2, canvas.height);
ctx.stroke();
ctx.beginPath();
ctx.strokeStyle = '#933';
ctx.lineWidth = 1;
for (let i = -20; i <= 20; ++i) {
if (i === 0)
continue;
const x = canvas.width / 2 + i * 40;
ctx.moveTo(x, (i % 10 === 0 ? 0 : i % 5 === 0 ? 0.1 : 0.2) * canvas.height);
ctx.lineTo(x, canvas.height);
}
ctx.stroke();
}
// correlation
{
ctx.beginPath();
ctx.lineWidth = 1;
ctx.strokeStyle = '#FFF';
ctx.moveTo(0, canvas.height);
const min_corr = Math.max(0, Math.min(...corr.data));
const max_corr = Math.max(0, ...corr.data);
let corr_range = max_corr - min_corr;
if (corr_range === 0)
corr_range = 1;
let min_ind = Math.floor(-canvas.width / 4);
let max_ind = Math.ceil(canvas.width / 4);
for (let i = min_ind; i <= max_ind; ++i) {
const x = canvas.width / 2 + i * 2;
const y = ((corr.data[i < 0 ? i + corr.data.length : i] ?? 0) - min_corr) / corr_range;
ctx.lineTo(x, canvas.height * (1.0 - y));
}
ctx.stroke();
}
return canvas.toBuffer();
}