This commit is contained in:
2026-04-24 03:39:25 +09:00
parent 53e9908167
commit c4d8c8145a
37 changed files with 1887 additions and 3287 deletions

163
src/factorize.ts Normal file
View File

@@ -0,0 +1,163 @@
import { Song, Note, Bar, HitNote } from 'tja-parser';
import * as fs from 'fs';
import * as path from 'path';
import * as iconv from 'iconv-lite';
import { parse } from 'csv-parse/sync';
interface Features {
global_nps: number;
effective_nps: number;
peak_nps_1s: number;
peak_nps_2s: number;
peak_nps_5s: number;
nps_variance: number;
nps_spike_index: number;
// 구간별 NPS (곡을 4등분)
nps_q1: number; nps_q2: number; nps_q3: number; nps_q4: number;
max_nps_delta: number; // 인접 구간 간 최대 NPS 변화량
longest_stream: number;
avg_stream_length: number;
stream_count: number;
stream_ratio: number;
resting_ratio: number;
// 패턴 및 기술
don_ratio: number;
ka_ratio: number;
color_transition_ratio: number;
pattern_complexity: number;
hand_switch_ratio: number;
off_beat_ratio: number;
rhythm_std_dev: number;
rhythmic_entropy: number;
// 기믹 및 변속
gimmick_density: number;
sv_variance: number;
bpm_variance: number;
max_bpm: number;
min_bpm: number;
max_sv: number;
total_notes: number;
level: number;
}
function getFeatures(song: Song, diff: 'oni' | 'edit'): Features | null {
const course = song.course[diff];
if (!course) return null;
const bars = course.noteGroups.filter(ng => typeof (ng as any).getNotes === 'function') as Bar[];
const hitNotes = bars.flatMap(b => b.getNotes()).filter(n => [1, 2, 3, 4].includes(n.toJSON().type));
if (hitNotes.length === 0) return null;
const timings = hitNotes.map(n => n.getTimingMS() / 1000);
const startT = Math.min(...timings);
const endT = Math.max(...timings);
const duration = endT - startT;
if (duration <= 0) return null;
const intervals: number[] = [];
for (let i = 1; i < timings.length; i++) intervals.push(timings[i] - timings[i-1]);
const getPeakNPS = (window: number) => {
let max = 0;
for (let t = startT; t < endT; t += 0.2) {
const count = timings.filter(time => time >= t && time < t + window).length;
if (count / window > max) max = count / window;
}
return max;
};
// 구간별 NPS 계산
const getNPSInRange = (t1: number, t2: number) => {
const count = timings.filter(t => t >= t1 && t < t2).length;
return count / ((t2 - t1) || 1);
};
const q = [0, 0.25, 0.5, 0.75, 1.0].map(p => startT + duration * p);
const nps_qs = [
getNPSInRange(q[0], q[1]), getNPSInRange(q[1], q[2]),
getNPSInRange(q[2], q[3]), getNPSInRange(q[3], q[4])
];
let max_delta = 0;
for (let i = 1; i < nps_qs.length; i++) max_delta = Math.max(max_delta, Math.abs(nps_qs[i] - nps_qs[i-1]));
const streams: number[] = [];
let currentS = 0, restTime = 0;
for (const interval of intervals) {
if (interval <= 0.185) currentS++;
else {
if (currentS > 0) streams.push(currentS + 1);
currentS = 0;
if (interval >= 1.0) restTime += interval;
}
}
if (currentS > 0) streams.push(currentS + 1);
const donCount = hitNotes.filter(n => [1, 3].includes(n.toJSON().type)).length;
const kaCount = hitNotes.length - donCount;
const svs = bars.map(b => b.getScroll());
const bpms = bars.map(b => b.getBpm());
const gimmickCount = svs.filter((v, i) => i > 0 && v !== svs[i-1]).length + bpms.filter((v, i) => i > 0 && v !== bpms[i-1]).length;
const avg = (arr: number[]) => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
const std = (arr: number[]) => Math.sqrt(avg(arr.map(v => Math.pow(v - avg(arr), 2))));
return {
global_nps: hitNotes.length / duration,
effective_nps: hitNotes.length / (duration - (intervals.filter(v => v >= 1.0).reduce((a, b) => a + b, 0)) || 1),
peak_nps_1s: getPeakNPS(1),
peak_nps_2s: getPeakNPS(2),
peak_nps_5s: getPeakNPS(5),
nps_variance: std(intervals),
nps_spike_index: getPeakNPS(1) / (hitNotes.length / duration || 1),
nps_q1: nps_qs[0], nps_q2: nps_qs[1], nps_q3: nps_qs[2], nps_q4: nps_qs[3],
max_nps_delta: max_delta,
longest_stream: Math.max(0, ...streams),
avg_stream_length: avg(streams),
stream_count: streams.length,
stream_ratio: streams.reduce((a, b) => a + b, 0) / hitNotes.length,
resting_ratio: restTime / duration,
don_ratio: donCount / hitNotes.length,
ka_ratio: kaCount / hitNotes.length,
color_transition_ratio: hitNotes.filter((n, i) => i > 0 && [1, 3].includes(n.toJSON().type) !== [1, 3].includes(hitNotes[i-1].toJSON().type)).length / hitNotes.length,
pattern_complexity: hitNotes.filter((n, i) => i > 1 && ([1, 3].includes(hitNotes[i-2].toJSON().type) !== [1, 3].includes(hitNotes[i-1].toJSON().type) || [1, 3].includes(hitNotes[i-1].toJSON().type) !== [1, 3].includes(n.toJSON().type))).length / hitNotes.length,
hand_switch_ratio: streams.filter(s => s % 2 !== 0).length / (streams.length || 1),
off_beat_ratio: hitNotes.filter(n => Number((n.getTiming() as any).d) % 3 === 0).length / hitNotes.length,
rhythm_std_dev: std(intervals),
rhythmic_entropy: new Set(hitNotes.map(n => Number((n.getTiming() as any).d))).size / 10,
gimmick_density: gimmickCount / duration,
sv_variance: std(svs),
bpm_variance: std(bpms),
max_bpm: Math.max(...bpms),
min_bpm: Math.min(...bpms),
max_sv: Math.max(...svs),
total_notes: hitNotes.length,
level: course.getLevel()
};
}
const tjaDir = process.argv[2] || 'datas/tja';
const measureCsv = process.argv[3] || 'datas/measure.csv';
const outputFile = process.argv[4] || 'datas/dataset.csv';
const records = parse(fs.readFileSync(measureCsv, 'utf-8'), { columns: true });
const dataset: any[] = [];
console.log(`Extracting ${records.length} records...`);
for (const record of records) {
const { songno, diff, } = record;
const tjaPath = path.join(tjaDir, `${songno}.tja`);
if (!fs.existsSync(tjaPath)) continue;
try {
const song = Song.parse(iconv.decode(fs.readFileSync(tjaPath), 'shift-jis'));
const f = getFeatures(song, diff === 'ura' ? 'edit' : 'oni');
if (f) dataset.push({ ...f, target: parseFloat() });
} catch (e) {}
}
if (dataset.length > 0) {
const header = Object.keys(dataset[0]).join(',');
const rows = dataset.map(d => Object.values(d).join(',')).join('\n');
fs.writeFileSync(outputFile, `${header}\n${rows}`);
console.log(`Saved ${dataset.length} samples.`);
}

135
src/predict.ts Normal file
View File

@@ -0,0 +1,135 @@
import { Song, Note, Bar, HitNote } from 'tja-parser';
import * as fs from 'fs';
import * as path from 'path';
import * as iconv from 'iconv-lite';
import { spawn } from 'child_process';
function getFeatures(song: Song, diff: 'oni' | 'edit') {
const course = song.course[diff];
if (!course) return null;
const bars = course.noteGroups.filter(ng => typeof (ng as any).getNotes === 'function') as Bar[];
const hitNotes = bars.flatMap(b => b.getNotes()).filter(n => [1, 2, 3, 4].includes(n.toJSON().type));
if (hitNotes.length === 0) return null;
const timings = hitNotes.map(n => n.getTimingMS() / 1000);
const startT = Math.min(...timings);
const endT = Math.max(...timings);
const duration = endT - startT;
if (duration <= 0) return null;
const intervals: number[] = [];
for (let i = 1; i < timings.length; i++) intervals.push(timings[i] - timings[i-1]);
const getPeakNPS = (window: number) => {
let max = 0;
for (let t = startT; t < endT; t += 0.2) {
const count = timings.filter(time => time >= t && time < t + window).length;
if (count / window > max) max = count / window;
}
return max;
};
const getNPSInRange = (t1: number, t2: number) => {
const count = timings.filter(t => t >= t1 && t < t2).length;
return count / ((t2 - t1) || 1);
};
const q = [0, 0.25, 0.5, 0.75, 1.0].map(p => startT + duration * p);
const nps_qs = [
getNPSInRange(q[0], q[1]), getNPSInRange(q[1], q[2]),
getNPSInRange(q[2], q[3]), getNPSInRange(q[3], q[4])
];
let max_delta = 0;
for (let i = 1; i < nps_qs.length; i++) max_delta = Math.max(max_delta, Math.abs(nps_qs[i] - nps_qs[i-1]));
const streams: number[] = [];
let currentS = 0, restTime = 0;
for (const interval of intervals) {
if (interval <= 0.185) currentS++;
else {
if (currentS > 0) streams.push(currentS + 1);
currentS = 0;
if (interval >= 1.0) restTime += interval;
}
}
if (currentS > 0) streams.push(currentS + 1);
const donCount = hitNotes.filter(n => [1, 3].includes(n.toJSON().type)).length;
const kaCount = hitNotes.length - donCount;
const svs = bars.map(b => b.getScroll());
const bpms = bars.map(b => b.getBpm());
const gimmickCount = svs.filter((v, i) => i > 0 && v !== svs[i-1]).length + bpms.filter((v, i) => i > 0 && v !== bpms[i-1]).length;
const avg = (arr: number[]) => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
const std = (arr: number[]) => Math.sqrt(avg(arr.map(v => Math.pow(v - avg(arr), 2))));
return {
global_nps: hitNotes.length / duration,
effective_nps: hitNotes.length / (duration - (intervals.filter(v => v >= 1.0).reduce((a, b) => a + b, 0)) || 1),
peak_nps_1s: getPeakNPS(1),
peak_nps_2s: getPeakNPS(2),
peak_nps_5s: getPeakNPS(5),
nps_variance: std(intervals),
nps_spike_index: getPeakNPS(1) / (hitNotes.length / duration || 1),
nps_q1: nps_qs[0], nps_q2: nps_qs[1], nps_q3: nps_qs[2], nps_q4: nps_qs[3],
max_nps_delta: max_delta,
longest_stream: Math.max(0, ...streams),
avg_stream_length: avg(streams),
stream_count: streams.length,
stream_ratio: streams.reduce((a, b) => a + b, 0) / hitNotes.length,
resting_ratio: restTime / duration,
don_ratio: donCount / hitNotes.length,
ka_ratio: kaCount / hitNotes.length,
color_transition_ratio: hitNotes.filter((n, i) => i > 0 && [1, 3].includes(n.toJSON().type) !== [1, 3].includes(hitNotes[i-1].toJSON().type)).length / hitNotes.length,
pattern_complexity: hitNotes.filter((n, i) => i > 1 && ([1, 3].includes(hitNotes[i-2].toJSON().type) !== [1, 3].includes(hitNotes[i-1].toJSON().type) || [1, 3].includes(hitNotes[i-1].toJSON().type) !== [1, 3].includes(n.toJSON().type))).length / hitNotes.length,
hand_switch_ratio: streams.filter(s => s % 2 !== 0).length / (streams.length || 1),
off_beat_ratio: hitNotes.filter(n => Number((n.getTiming() as any).d) % 3 === 0).length / hitNotes.length,
rhythm_std_dev: std(intervals),
rhythmic_entropy: new Set(hitNotes.map(n => Number((n.getTiming() as any).d))).size / 10,
gimmick_density: gimmickCount / duration,
sv_variance: std(svs),
bpm_variance: std(bpms),
max_bpm: Math.max(...bpms),
min_bpm: Math.min(...bpms),
max_sv: Math.max(...svs),
total_notes: hitNotes.length,
level: course.getLevel()
};
}
async function run() {
const modelPath = process.argv[2] || 'model/constant_predictor.joblib';
const tjaPath = process.argv[3];
const diff: any = process.argv[4] || 'oni';
if (!tjaPath) {
console.log("Usage: bun run src/predict.ts <model_path> <tja_path> [oni|edit]");
return;
}
const buffer = fs.readFileSync(tjaPath);
const content = iconv.decode(buffer, 'shift-jis');
const song = Song.parse(content);
const features = getFeatures(song, diff);
if (!features) {
console.error("Course not found or no notes.");
return;
}
const python = spawn('python3', ['model/predict.py', modelPath]);
python.stdin.write(JSON.stringify(features));
python.stdin.end();
python.stdout.on('data', (data) => {
console.log(`Predicted Constant: ${data.toString().trim()}`);
});
python.stderr.on('data', (data) => {
console.error(`Python Error: ${data.toString()}`);
});
}
run();

26
src/validate_all.py Normal file
View File

@@ -0,0 +1,26 @@
import pandas as pd
from sklearn.metrics import mean_absolute_error
import joblib
import sys
def validate(dataset_path, model_path):
df = pd.read_csv(dataset_path)
X = df.drop('target', axis=1)
y = df['target']
model = joblib.load(model_path)
preds = model.predict(X)
mae = mean_absolute_error(y, preds)
print(f"Overall MAE on full dataset: {mae:.4f}")
# Check error distribution
diffs = (y - preds).abs()
within_01 = (diffs <= 0.1).mean() * 100
within_03 = (diffs <= 0.3).mean() * 100
print(f"Samples within 0.1 error: {within_01:.2f}%")
print(f"Samples within 0.3 error: {within_03:.2f}%")
if __name__ == "__main__":
validate(sys.argv[1], sys.argv[2])