mirror of
https://github.com/danbulant/osuVisualizer
synced 2026-05-19 04:18:35 +00:00
515 lines
No EOL
16 KiB
JavaScript
515 lines
No EOL
16 KiB
JavaScript
var fs = require('fs');
|
|
var slidercalc = require('osu-parser/lib/slidercalc.js');
|
|
|
|
function beatmapParser() {
|
|
var beatmap = {
|
|
nbCircles: 0,
|
|
nbSliders: 0,
|
|
nbSpinners: 0,
|
|
timingPoints: [],
|
|
breakTimes: [],
|
|
hitObjects: []
|
|
};
|
|
|
|
var osuSection;
|
|
var bpmMin;
|
|
var bpmMax;
|
|
var members;
|
|
|
|
var timingLines = [];
|
|
var objectLines = [];
|
|
var eventsLines = [];
|
|
var sectionReg = /^\[([a-zA-Z0-9]+)\]$/;
|
|
var keyValReg = /^([a-zA-Z0-9]+)[ ]*:[ ]*(.+)$/;
|
|
var curveTypes = {
|
|
C: "catmull",
|
|
B: "bezier",
|
|
L: "linear",
|
|
P: "pass-through"
|
|
};
|
|
|
|
/**
|
|
* Get the timing point affecting a specific offset
|
|
* @param {Integer} offset
|
|
* @return {Object} timingPoint
|
|
*/
|
|
var getTimingPoint = function (offset) {
|
|
for (var i = beatmap.timingPoints.length - 1; i >= 0; i--) {
|
|
if (beatmap.timingPoints[i].offset <= offset) { return beatmap.timingPoints[i]; }
|
|
}
|
|
return beatmap.timingPoints[0];
|
|
};
|
|
|
|
/**
|
|
* Parse additions member
|
|
* @param {String} str additions member (sample:add:customSampleIndex:Volume:hitsound)
|
|
* @return {Object} additions a list of additions
|
|
*/
|
|
var parseAdditions = function (str) {
|
|
if (!str) return {};
|
|
|
|
var additions = {};
|
|
var adds = str.split(':');
|
|
|
|
if (adds[0] && adds[0] !== '0') {
|
|
var sample;
|
|
switch (adds[0]) {
|
|
case '1':
|
|
sample = 'normal';
|
|
break;
|
|
case '2':
|
|
sample = 'soft';
|
|
break;
|
|
case '3':
|
|
sample = 'drum';
|
|
break;
|
|
}
|
|
additions.sample = sample;
|
|
}
|
|
|
|
if (adds[1] && adds[1] !== '0') {
|
|
var addSample;
|
|
switch (adds[1]) {
|
|
case '1':
|
|
addSample = 'normal';
|
|
break;
|
|
case '2':
|
|
addSample = 'soft';
|
|
break;
|
|
case '3':
|
|
addSample = 'drum';
|
|
break;
|
|
}
|
|
additions.additionalSample = addSample;
|
|
}
|
|
|
|
if (adds[2] && adds[2] !== '0') { additions.customSampleIndex = parseInt(adds[2]); }
|
|
if (adds[3] && adds[3] !== '0') { additions.hitsoundVolume = parseInt(adds[3]); }
|
|
if (adds[4]) { additions.hitsound = adds[4]; }
|
|
|
|
return additions;
|
|
};
|
|
|
|
/**
|
|
* Parse a timing line
|
|
* @param {String} line
|
|
*/
|
|
var parseTimingPoint = function (line) {
|
|
members = line.split(',');
|
|
|
|
var timingPoint = {
|
|
offset: parseInt(members[0]),
|
|
beatLength: parseFloat(members[1]),
|
|
velocity: 1,
|
|
timingSignature: parseInt(members[2]),
|
|
sampleSetId: parseInt(members[3]),
|
|
customSampleIndex: parseInt(members[4]),
|
|
sampleVolume: parseInt(members[5]),
|
|
timingChange: (members[6] == 1),
|
|
kiaiTimeActive: (members[7] == 1)
|
|
};
|
|
|
|
if (!isNaN(timingPoint.beatLength) && timingPoint.beatLength !== 0) {
|
|
if (timingPoint.beatLength > 0) {
|
|
// If positive, beatLength is the length of a beat in milliseconds
|
|
var bpm = Math.round(60000 / timingPoint.beatLength);
|
|
beatmap.bpmMin = beatmap.bpmMin ? Math.min(beatmap.bpmMin, bpm) : bpm;
|
|
beatmap.bpmMax = beatmap.bpmMax ? Math.max(beatmap.bpmMax, bpm) : bpm;
|
|
timingPoint.bpm = bpm;
|
|
} else {
|
|
// If negative, beatLength is a velocity factor
|
|
timingPoint.velocity = Math.abs(100 / timingPoint.beatLength);
|
|
}
|
|
}
|
|
|
|
beatmap.timingPoints.push(timingPoint);
|
|
};
|
|
|
|
/**
|
|
* Parse an object line
|
|
* @param {String} line
|
|
*/
|
|
var parseHitObject = function (line) {
|
|
members = line.split(',');
|
|
|
|
var soundType = members[4];
|
|
var objectType = members[3];
|
|
|
|
var hitObject = {
|
|
startTime: parseInt(members[2]),
|
|
newCombo: ((objectType & 4) == 4),
|
|
soundTypes: [],
|
|
position: [
|
|
parseInt(members[0]),
|
|
parseInt(members[1])
|
|
]
|
|
};
|
|
|
|
/**
|
|
* sound type is a bitwise flag enum
|
|
* 0 : normal
|
|
* 2 : whistle
|
|
* 4 : finish
|
|
* 8 : clap
|
|
*/
|
|
if ((soundType & 2) == 2) { hitObject.soundTypes.push('whistle'); }
|
|
if ((soundType & 4) == 4) { hitObject.soundTypes.push('finish'); }
|
|
if ((soundType & 8) == 8) { hitObject.soundTypes.push('clap'); }
|
|
if (hitObject.soundTypes.length === 0) { hitObject.soundTypes.push('normal'); }
|
|
|
|
/**
|
|
* object type is a bitwise flag enum
|
|
* 1: circle
|
|
* 2: slider
|
|
* 8: spinner
|
|
*/
|
|
if ((objectType & 1) == 1) {
|
|
// Circle
|
|
beatmap.nbCircles++;
|
|
hitObject.objectName = 'circle';
|
|
hitObject.additions = parseAdditions(members[5]);
|
|
} else if ((objectType & 8) == 8) {
|
|
// Spinner
|
|
beatmap.nbSpinners++;
|
|
hitObject.objectName = 'spinner';
|
|
hitObject.endTime = parseInt(members[5]);
|
|
hitObject.additions = parseAdditions(members[6]);
|
|
} else if ((objectType & 2) == 2) {
|
|
// Slider
|
|
beatmap.nbSliders++;
|
|
hitObject.objectName = 'slider';
|
|
hitObject.repeatCount = parseInt(members[6]);
|
|
hitObject.pixelLength = parseInt(members[7]);
|
|
hitObject.additions = parseAdditions(members[10]);
|
|
hitObject.edges = [];
|
|
hitObject.points = [
|
|
[hitObject.position[0], hitObject.position[1]]
|
|
];
|
|
|
|
/**
|
|
* Calculate slider duration
|
|
*/
|
|
var timing = getTimingPoint(hitObject.startTime);
|
|
|
|
if (timing) {
|
|
var pxPerBeat = beatmap.SliderMultiplier * 100 * timing.velocity;
|
|
var beatsNumber = (hitObject.pixelLength * hitObject.repeatCount) / pxPerBeat;
|
|
hitObject.duration = Math.ceil(beatsNumber * timing.beatLength);
|
|
hitObject.endTime = hitObject.startTime + hitObject.duration;
|
|
}
|
|
/**
|
|
* Parse slider points
|
|
*/
|
|
var points = (members[5] || '').split('|');
|
|
if (points.length) {
|
|
hitObject.curveType = curveTypes[points[0]] || 'unknown';
|
|
|
|
for (var i = 1, l = points.length; i < l; i++) {
|
|
var coordinates = points[i].split(':');
|
|
hitObject.points.push([
|
|
parseInt(coordinates[0]),
|
|
parseInt(coordinates[1])
|
|
]);
|
|
}
|
|
}
|
|
|
|
var edgeSounds = [];
|
|
var edgeAdditions = [];
|
|
if (members[8]) { edgeSounds = members[8].split('|'); }
|
|
if (members[9]) { edgeAdditions = members[9].split('|'); }
|
|
|
|
/**
|
|
* Get soundTypes and additions for each slider edge
|
|
*/
|
|
for (var j = 0, lgt = hitObject.repeatCount + 1; j < lgt; j++) {
|
|
var edge = {
|
|
soundTypes: [],
|
|
additions: parseAdditions(edgeAdditions[j])
|
|
};
|
|
|
|
if (edgeSounds[j]) {
|
|
var sound = edgeSounds[j];
|
|
if ((sound & 2) == 2) { edge.soundTypes.push('whistle'); }
|
|
if ((sound & 4) == 4) { edge.soundTypes.push('finish'); }
|
|
if ((sound & 8) == 8) { edge.soundTypes.push('clap'); }
|
|
if (edge.soundTypes.length === 0) { edge.soundTypes.push('normal'); }
|
|
} else {
|
|
edge.soundTypes.push('normal');
|
|
}
|
|
|
|
hitObject.edges.push(edge);
|
|
}
|
|
|
|
// get coordinates of the slider endpoint
|
|
var endPoint = slidercalc.getEndPoint(hitObject.curveType, hitObject.pixelLength, hitObject.points);
|
|
if (endPoint && endPoint[0] && endPoint[1]) {
|
|
hitObject.endPosition = [
|
|
Math.round(endPoint[0]),
|
|
Math.round(endPoint[1])
|
|
];
|
|
} else {
|
|
// If endPosition could not be calculated, approximate it by setting it to the last point
|
|
hitObject.endPosition = hitObject.points[hitObject.points.length - 1];
|
|
}
|
|
} else {
|
|
// Unknown
|
|
hitObject.objectName = 'unknown';
|
|
}
|
|
|
|
beatmap.hitObjects.push(hitObject);
|
|
};
|
|
|
|
/**
|
|
* Parse an event line
|
|
* @param {String} line
|
|
*/
|
|
var parseEvent = function (line) {
|
|
/**
|
|
* Background line : 0,0,"bg.jpg"
|
|
* TODO: confirm that the second member is always zero
|
|
*
|
|
* Breaktimes lines : 2,1000,2000
|
|
* second integer is start offset
|
|
* third integer is end offset
|
|
*/
|
|
members = line.split(',');
|
|
|
|
if (members[0] == '0' && members[1] == '0' && members[2]) {
|
|
var bgName = members[2].trim();
|
|
|
|
if (bgName.charAt(0) == '"' && bgName.charAt(bgName.length - 1) == '"') {
|
|
beatmap.bgFilename = bgName.substring(1, bgName.length - 1);
|
|
} else {
|
|
beatmap.bgFilename = bgName;
|
|
}
|
|
} else if (members[0] == 'Video' && members[2]) {
|
|
var bgName = members[2].trim();
|
|
|
|
if (bgName.charAt(0) == '"' && bgName.charAt(bgName.length - 1) == '"') {
|
|
beatmap.video = bgName.substring(1, bgName.length - 1);
|
|
} else {
|
|
beatmap.video = bgName;
|
|
}
|
|
} else if (members[0] == '2' && /^[0-9]+$/.test(members[1]) && /^[0-9]+$/.test(members[2])) {
|
|
beatmap.breakTimes.push({
|
|
startTime: parseInt(members[1]),
|
|
endTime: parseInt(members[2])
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Compute the total time and the draining time of the beatmap
|
|
*/
|
|
var computeDuration = function () {
|
|
var firstObject = beatmap.hitObjects[0];
|
|
var lastObject = beatmap.hitObjects[beatmap.hitObjects.length - 1];
|
|
|
|
var totalBreakTime = 0;
|
|
|
|
beatmap.breakTimes.forEach(function (breakTime) {
|
|
totalBreakTime += (breakTime.endTime - breakTime.startTime);
|
|
});
|
|
|
|
if (firstObject && lastObject) {
|
|
beatmap.totalTime = Math.floor(lastObject.startTime / 1000);
|
|
beatmap.drainingTime = Math.floor((lastObject.startTime - firstObject.startTime - totalBreakTime) / 1000);
|
|
} else {
|
|
beatmap.totalTime = 0;
|
|
beatmap.drainingTime = 0;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Browse objects and compute max combo
|
|
*/
|
|
var computeMaxCombo = function () {
|
|
if (beatmap.timingPoints.length === 0) { return; }
|
|
|
|
var maxCombo = 0;
|
|
var sliderMultiplier = parseFloat(beatmap.SliderMultiplier);
|
|
var sliderTickRate = parseInt(beatmap.SliderTickRate, 10);
|
|
|
|
var timingPoints = beatmap.timingPoints;
|
|
var currentTiming = timingPoints[0];
|
|
var nextOffset = timingPoints[1] ? timingPoints[1].offset : Infinity;
|
|
var i = 1;
|
|
|
|
beatmap.hitObjects.forEach(function (hitObject) {
|
|
if (hitObject.startTime >= nextOffset) {
|
|
currentTiming = timingPoints[i++];
|
|
nextOffset = timingPoints[i] ? timingPoints[i].offset : Infinity;
|
|
}
|
|
|
|
var osupxPerBeat = sliderMultiplier * 100 * currentTiming.velocity;
|
|
var tickLength = osupxPerBeat / sliderTickRate;
|
|
|
|
switch (hitObject.objectName) {
|
|
case 'spinner':
|
|
case 'circle':
|
|
maxCombo++;
|
|
break;
|
|
case 'slider':
|
|
var tickPerSide = Math.ceil((Math.floor(hitObject.pixelLength / tickLength * 100) / 100) - 1);
|
|
maxCombo += (hitObject.edges.length - 1) * (tickPerSide + 1) + 1; // 1 combo for each tick and endpoint
|
|
}
|
|
});
|
|
|
|
beatmap.maxCombo = maxCombo;
|
|
};
|
|
|
|
/**
|
|
* Read a single line, parse when key/value, store when further parsing needed
|
|
* @param {String|Buffer} line
|
|
*/
|
|
var readLine = function (line) {
|
|
line = line.toString().trim();
|
|
if (!line) { return; }
|
|
|
|
var match = sectionReg.exec(line);
|
|
if (match) {
|
|
osuSection = match[1].toLowerCase();
|
|
return;
|
|
}
|
|
if(line.startsWith("//")) return;
|
|
|
|
switch (osuSection) {
|
|
case 'timingpoints':
|
|
timingLines.push(line);
|
|
break;
|
|
case 'hitobjects':
|
|
objectLines.push(line);
|
|
break;
|
|
case 'events':
|
|
eventsLines.push(line);
|
|
break;
|
|
default:
|
|
if (!osuSection) {
|
|
match = /^osu file format (v[0-9]+)$/.exec(line);
|
|
if (match) {
|
|
beatmap.fileFormat = match[1];
|
|
return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apart from events, timingpoints and hitobjects sections, lines are "key: value"
|
|
*/
|
|
match = keyValReg.exec(line);
|
|
if (match) { beatmap[match[1]] = match[2]; }
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Compute everything that require the file to be completely parsed and return the beatmap
|
|
* @return {Object} beatmap
|
|
*/
|
|
var buildBeatmap = function () {
|
|
if (beatmap.Tags) {
|
|
beatmap.tagsArray = beatmap.Tags.split(' ');
|
|
}
|
|
|
|
eventsLines.forEach(parseEvent);
|
|
beatmap.breakTimes.sort(function (a, b) { return (a.startTime > b.startTime ? 1 : -1); });
|
|
|
|
timingLines.forEach(parseTimingPoint);
|
|
beatmap.timingPoints.sort(function (a, b) { return (a.offset > b.offset ? 1 : -1); });
|
|
|
|
var timingPoints = beatmap.timingPoints;
|
|
|
|
for (var i = 1, l = timingPoints.length; i < l; i++) {
|
|
if (!timingPoints[i].hasOwnProperty('bpm')) {
|
|
timingPoints[i].beatLength = timingPoints[i - 1].beatLength;
|
|
timingPoints[i].bpm = timingPoints[i - 1].bpm;
|
|
}
|
|
}
|
|
|
|
objectLines.forEach(parseHitObject);
|
|
beatmap.hitObjects.sort(function (a, b) { return (a.startTime > b.startTime ? 1 : -1); });
|
|
|
|
computeMaxCombo();
|
|
computeDuration();
|
|
|
|
return beatmap;
|
|
};
|
|
|
|
return {
|
|
readLine: readLine,
|
|
buildBeatmap: buildBeatmap
|
|
};
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Parse a .osu file
|
|
* @param {String} file path to the file
|
|
* @param {Function} callback(err, beatmap)
|
|
*/
|
|
exports.parseFile = function (file, callback) {
|
|
if (!fs.existsSync(file)) {
|
|
callback(new Error('File does not exist'));
|
|
return;
|
|
}
|
|
|
|
var parser = beatmapParser();
|
|
var stream = fs.createReadStream(file);
|
|
var buffer = '';
|
|
|
|
|
|
stream.on('data', function (chunk) {
|
|
buffer += chunk;
|
|
var lines = buffer.split(/\r?\n/);
|
|
buffer = lines.pop() || '';
|
|
lines.forEach(parser.readLine);
|
|
});
|
|
|
|
stream.on('error', function (err) {
|
|
callback(err);
|
|
});
|
|
|
|
stream.on('end', function () {
|
|
buffer.split(/\r?\n/).forEach(parser.readLine);
|
|
callback(null, parser.buildBeatmap());
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Parse a stream containing .osu content
|
|
* @param {Stream} stream
|
|
* @param {Function} callback(err, beatmap)
|
|
*/
|
|
exports.parseStream = function (stream, callback) {
|
|
var parser = beatmapParser();
|
|
var buffer = '';
|
|
|
|
stream.on('data', function (chunk) {
|
|
buffer += chunk.toString();
|
|
var lines = buffer.split(/\r?\n/);
|
|
buffer = lines.pop() || '';
|
|
lines.forEach(parser.readLine);
|
|
});
|
|
|
|
stream.on('error', function (err) {
|
|
callback(err);
|
|
});
|
|
|
|
stream.on('end', function () {
|
|
buffer.split(/\r?\n/).forEach(parser.readLine);
|
|
callback(null, parser.buildBeatmap());
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Parse the content of a .osu
|
|
* @param {String|Buffer} content
|
|
* @return {Object} beatmap
|
|
*/
|
|
exports.parseContent = function (content) {
|
|
var parser = beatmapParser();
|
|
content.toString().split(/[\n\r]+/).forEach(function (line) {
|
|
parser.readLine(line);
|
|
});
|
|
|
|
return parser.buildBeatmap();
|
|
}; |