diff --git a/src/internal/syntacticAnalysis/deserializer.ts b/src/internal/syntacticAnalysis/deserializer.ts index 0b8692f..5a4ee8d 100644 --- a/src/internal/syntacticAnalysis/deserializer.ts +++ b/src/internal/syntacticAnalysis/deserializer.ts @@ -21,23 +21,30 @@ export class Deserializer { */ public readonly enumerator: Enumerator; + /** + * INTERNAL USAGE ONLY! DO NOT USE THIS PROPERTY DIRECTLY! + */ + public readonly timingChanges: TimingChange[] = []; private _maxFinishTime: number = 0; - private _currentTime: number = 0; /** * INTERNAL USAGE ONLY! DO NOT USE THIS PROPERTY DIRECTLY! */ - public currentNoteCollection: NoteCollection | undefined; + public currentTime: number; /** * INTERNAL USAGE ONLY! DO NOT USE THIS PROPERTY DIRECTLY! */ - public currentTiming: TimingChange = new TimingChange(0, 0); + public currentNoteCollection: NoteCollection | undefined; /** * INTERNAL USAGE ONLY! DO NOT USE THIS PROPERTY DIRECTLY! */ - public endOfFile: boolean = false; + public endOfFile: boolean; constructor(sequence: Iterable) { this.enumerator = new Enumerator(sequence); + this.timingChanges.push(new TimingChange()); + this.currentNoteCollection = undefined; + this.currentTime = 0; + this.endOfFile = false; } public getChart(): MaiChart { @@ -61,7 +68,7 @@ export class Deserializer { break; } case TokenType.Location: { - this.currentNoteCollection ??= new NoteCollection(this._currentTime); + this.currentNoteCollection ??= new NoteCollection(this.currentTime); if (token.lexeme[0] === "0") { if (this.currentNoteCollection.eachStyle !== EachStyle.ForceBroken) @@ -86,7 +93,7 @@ export class Deserializer { this.currentNoteCollection = undefined; } - this._currentTime += this.currentTiming.secondsPerBeat; + this.currentTime += this.timingChanges[this.timingChanges.length - 1].secondsPerBeat; } break; case TokenType.EachDivider: @@ -111,7 +118,7 @@ export class Deserializer { case TokenType.SlideJoiner: throw new ScopeMismatchException(token.line, token.character, ScopeType.Slide); case TokenType.EndOfFile: - this._chart.finishTiming = this._currentTime; + this._chart.finishTiming = this.currentTime; break; case TokenType.None: break; @@ -128,6 +135,7 @@ export class Deserializer { } this._chart.noteCollections = noteCollections; + this._chart.timingChanges = this.timingChanges; return this._chart; } diff --git a/src/internal/syntacticAnalysis/serializer.ts b/src/internal/syntacticAnalysis/serializer.ts new file mode 100644 index 0000000..2acfd84 --- /dev/null +++ b/src/internal/syntacticAnalysis/serializer.ts @@ -0,0 +1,79 @@ +import { EachStyle } from "../../structures/eachStyle"; +import { MaiChart } from "../../structures/maiChart"; +import { NoteCollection } from "../../structures/noteCollection"; + +/** + * INTERNAL USAGE ONLY! DO NOT USE THIS PROPERTY DIRECTLY! + */ +export class Serializer { + private _currentTimingChange: number = 0; + private _currentNoteCollection: number = 0; + private _currentTime: number = 0; + + // there's no such thing as StringWriter in TypeScript + public serialize(chart: MaiChart): string { + let writer = ""; + + writer += `(${chart.timingChanges[this._currentTimingChange].tempo})`; + writer += `{${chart.timingChanges[this._currentTimingChange].subdivisions}}`; + + while (this._currentTime <= (chart.finishTiming ? chart.finishTiming : 0)) { + if ( + this._currentTimingChange < chart.timingChanges.length - 1 && + Math.abs(chart.timingChanges[this._currentTimingChange + 1].time - this._currentTime) < 1.401298E-45 + ) { + this._currentTimingChange++; + + if ( + Math.abs( + chart.timingChanges[this._currentTimingChange].tempo - + chart.timingChanges[this._currentTimingChange - 1].tempo + ) > 1.401298E-45 + ) { + writer += `(${chart.timingChanges[this._currentTimingChange].tempo})`; + } + + if ( + Math.abs( + chart.timingChanges[this._currentTimingChange].subdivisions - + chart.timingChanges[this._currentTimingChange - 1].subdivisions + ) > 1.401298E-45 + ) { + writer += `{${chart.timingChanges[this._currentTimingChange].subdivisions}}`; + } + } + + if ( + this._currentNoteCollection < chart.noteCollections.length && + Math.abs(chart.noteCollections[this._currentNoteCollection].time - this._currentTime) <= 1.401298E-45 + ) { + writer += Serializer.serializeNoteCollection(chart.noteCollections[this._currentNoteCollection]); + + this._currentNoteCollection++; + } + + const timingChange = chart.timingChanges[this._currentTimingChange]; + this._currentTime += timingChange.secondsPerBeat; + writer += ","; + } + writer += "E"; + + return writer; + } + + private static serializeNoteCollection(notes: NoteCollection): string { + let writer = ""; + + const seperator = notes.eachStyle === EachStyle.ForceBroken ? "`" : "/"; + + if (notes.eachStyle === EachStyle.ForceEach) writer += "0/"; + + for (let i = 0; i < notes.length; i++) { + writer += notes[i].write(); + + if (i != notes.length - 1) writer += seperator; + } + + return writer; + } +} diff --git a/src/internal/syntacticAnalysis/states/noteReader.ts b/src/internal/syntacticAnalysis/states/noteReader.ts index 66f34b2..fac8dae 100644 --- a/src/internal/syntacticAnalysis/states/noteReader.ts +++ b/src/internal/syntacticAnalysis/states/noteReader.ts @@ -26,7 +26,8 @@ export class NoteReader { const currentNote = new Note(parent.currentNoteCollection!); currentNote.location = noteLocation; - const overrideTiming = new TimingChange(parent.currentNoteCollection!.time); + const overrideTiming = new TimingChange(); + overrideTiming.tempo = parent.timingChanges[parent.timingChanges.length - 1].tempo; if (noteLocation.group !== NoteGroup.Tap) currentNote.type = NoteType.Touch; @@ -59,7 +60,7 @@ export class NoteReader { } case TokenType.Duration: { - NoteReader.readDuration(token, parent.currentTiming, currentNote); + NoteReader.readDuration(parent.timingChanges[parent.timingChanges.length - 1], token, currentNote); break; } @@ -101,9 +102,8 @@ export class NoteReader { note.styles |= NoteStyles.Mine; return; case "h": - if (note.type === NoteType.Break || note.type === NoteType.ForceInvalidate) break; + if (note.type !== NoteType.Break && note.type !== NoteType.ForceInvalidate) note.type = NoteType.Hold; - note.type = NoteType.Hold; note.length ??= 0; return; case "?": @@ -126,7 +126,7 @@ export class NoteReader { } } - private static readDuration(token: Token, timing: TimingChange, note: Note) { + private static readDuration(timing: TimingChange, token: Token, note: Note) { if (note.type !== NoteType.Break) note.type = NoteType.Hold; if (token.lexeme[0] === "#") { diff --git a/src/internal/syntacticAnalysis/states/slideReader.ts b/src/internal/syntacticAnalysis/states/slideReader.ts index be2948d..6e75c9f 100644 --- a/src/internal/syntacticAnalysis/states/slideReader.ts +++ b/src/internal/syntacticAnalysis/states/slideReader.ts @@ -53,7 +53,7 @@ export class SlideReader { } case TokenType.Duration: { - SlideReader.readDuration(token, parent.currentTiming, path); + SlideReader.readDuration(parent.timingChanges[parent.timingChanges.length - 1], token, path); break; } @@ -151,7 +151,7 @@ export class SlideReader { } while (parent.enumerator.current.type === TokenType.Location); } - private static readDuration(token: Token, timing: TimingChange, path: SlidePath) { + private static readDuration(timing: TimingChange, token: Token, path: SlidePath) { const startOfDurationDeclaration = 0; const overrideTiming = timing; diff --git a/src/internal/syntacticAnalysis/states/subdivisionReader.ts b/src/internal/syntacticAnalysis/states/subdivisionReader.ts index 60b5e55..7ae843d 100644 --- a/src/internal/syntacticAnalysis/states/subdivisionReader.ts +++ b/src/internal/syntacticAnalysis/states/subdivisionReader.ts @@ -1,6 +1,7 @@ import { UnexpectedCharacterException } from "../../errors/unexpectedCharacterException"; import { Token } from "../../lexicalAnalysis/token"; import { Deserializer } from "../deserializer"; +import { TimingChange } from "../timingChange"; /** * INTERNAL USAGE ONLY! DO NOT USE THIS PROPERTY DIRECTLY! @@ -13,7 +14,24 @@ export class SubdivisionReader { if (isNaN(explicitTempo)) throw new UnexpectedCharacterException(token.line, token.character + 1, '0~9, or "."'); - parent.currentTiming.explicitOverride(explicitTempo); + let newTimingChange; + { + const oldTimingChange = parent.timingChanges[parent.timingChanges.length - 1]; + newTimingChange = new TimingChange(); + newTimingChange.tempo = oldTimingChange.tempo; + newTimingChange.subdivisions = oldTimingChange.subdivisions; + } + + newTimingChange.setSeconds(explicitTempo); + newTimingChange.time = parent.currentTime; + + if ( + Math.abs(parent.timingChanges[parent.timingChanges.length - 1].time - parent.currentTime) <= + 1.401298e-45 + ) + parent.timingChanges.pop(); + + parent.timingChanges.push(newTimingChange); return; } @@ -21,6 +39,20 @@ export class SubdivisionReader { if (isNaN(subdivision)) throw new UnexpectedCharacterException(token.line, token.character, '0~9, or "."'); - parent.currentTiming.subdivisions = subdivision; + let newTimingChange; + { + const oldTimingChange = parent.timingChanges[parent.timingChanges.length - 1]; + newTimingChange = new TimingChange(); + newTimingChange.tempo = oldTimingChange.tempo; + newTimingChange.subdivisions = oldTimingChange.subdivisions; + } + + newTimingChange.subdivisions = subdivision; + newTimingChange.time = parent.currentTime; + + if (Math.abs(parent.timingChanges[parent.timingChanges.length - 1].time - parent.currentTime) <= 1.401298e-45) + parent.timingChanges.pop(); + + parent.timingChanges.push(newTimingChange); } } diff --git a/src/internal/syntacticAnalysis/states/tempoReader.ts b/src/internal/syntacticAnalysis/states/tempoReader.ts index 010533b..699a98f 100644 --- a/src/internal/syntacticAnalysis/states/tempoReader.ts +++ b/src/internal/syntacticAnalysis/states/tempoReader.ts @@ -1,6 +1,7 @@ import { UnexpectedCharacterException } from "../../errors/unexpectedCharacterException"; import { Token } from "../../lexicalAnalysis/token"; import { Deserializer } from "../deserializer"; +import { TimingChange } from "../timingChange"; /** * INTERNAL USAGE ONLY! DO NOT USE THIS PROPERTY DIRECTLY! @@ -11,6 +12,20 @@ export class TempoReader { if (isNaN(tempo)) throw new UnexpectedCharacterException(token.line, token.character, '0~9, or "."'); - parent.currentTiming.tempo = tempo; + let newTimingChange; + { + const oldTimingChange = parent.timingChanges[parent.timingChanges.length - 1]; + newTimingChange = new TimingChange(); + newTimingChange.tempo = oldTimingChange.tempo; + newTimingChange.subdivisions = oldTimingChange.subdivisions; + } + + newTimingChange.tempo = tempo; + newTimingChange.time = parent.currentTime; + + if (Math.abs(parent.timingChanges[parent.timingChanges.length - 1].time - parent.currentTime) <= 1.401298e-45) + parent.timingChanges.pop(); + + parent.timingChanges.push(newTimingChange); } } diff --git a/src/internal/syntacticAnalysis/timingChange.ts b/src/internal/syntacticAnalysis/timingChange.ts index 9dd2002..f722ce3 100644 --- a/src/internal/syntacticAnalysis/timingChange.ts +++ b/src/internal/syntacticAnalysis/timingChange.ts @@ -1,6 +1,7 @@ export class TimingChange { - public tempo: number; - public subdivisions: number; + public time: number = 0; + public tempo: number = 0; + public subdivisions: number = 0; get secondsPerBar(): number { // could use || here since number is falsy if 0 @@ -12,12 +13,7 @@ export class TimingChange { return this.secondsPerBar / ((this.subdivisions === 0 ? 4 : this.subdivisions) / 4); } - constructor(tempo: number, subdivisions: number = 4) { - this.tempo = tempo; - this.subdivisions = subdivisions; - } - - explicitOverride(value: number) { + public setSeconds(value: number) { this.tempo = 60 / value; this.subdivisions = 4; } diff --git a/src/internal/utility.ts b/src/internal/utility.ts index 154248c..4083c0e 100644 --- a/src/internal/utility.ts +++ b/src/internal/utility.ts @@ -1,6 +1,13 @@ export type FileEncoding = "utf7" | "utf8" | "utf16le" | "utf16be" | "utf32le" | "utf32be" | "unicode" | "unicodebe"; export class Utility { + /** + * Format a number to `0.0000000` format. + */ + static formatNumber(number: number): string { + return number.toFixed(7); + } + static removeLineEndings(str: string): string { return str.replace(/(\r\n|\n|\r)/gm, ""); } diff --git a/src/simaiConvert.ts b/src/simaiConvert.ts index a75e71b..53218ca 100644 --- a/src/simaiConvert.ts +++ b/src/simaiConvert.ts @@ -1,6 +1,7 @@ import { Deserializer } from "./internal/syntacticAnalysis/deserializer"; import { Tokenizer } from "./internal/lexicalAnalysis/tokenizer"; import { MaiChart } from "./structures/maiChart"; +import { Serializer } from "./internal/syntacticAnalysis/serializer"; export class SimaiConvert { public static deserialize(data: string): MaiChart { @@ -9,4 +10,9 @@ export class SimaiConvert { return chart; } + + public static serialize(chart: MaiChart): string { + const serializer = new Serializer(); + return serializer.serialize(chart); + } } diff --git a/src/structures/location.ts b/src/structures/location.ts index 7c0b712..4597aed 100644 --- a/src/structures/location.ts +++ b/src/structures/location.ts @@ -18,6 +18,32 @@ export class Location { } public toString(): string { - return `index: ${this.index}, group: ${this.group.toString()}`; + switch (this.group) { + case NoteGroup.Tap: + return (this.index + 1).toString(); + case NoteGroup.CSensor: + return "C"; + default: + let groupChar = ""; + + switch (this.group) { + case NoteGroup.ASensor: + groupChar = "A"; + break; + case NoteGroup.BSensor: + groupChar = "B"; + break; + case NoteGroup.DSensor: + groupChar = "D"; + break; + case NoteGroup.ESensor: + groupChar = "E"; + break; + // default: + // throw new ArgumentOutOfRangeException(); + } + + return groupChar + (this.index + 1).toString(); + } } } diff --git a/src/structures/maiChart.ts b/src/structures/maiChart.ts index f3805f4..8dcec4b 100644 --- a/src/structures/maiChart.ts +++ b/src/structures/maiChart.ts @@ -1,6 +1,8 @@ +import { TimingChange } from "../internal/syntacticAnalysis/timingChange"; import { NoteCollection } from "./noteCollection"; export class MaiChart { public finishTiming: number | undefined; public noteCollections: NoteCollection[] = []; + public timingChanges: TimingChange[] = []; } diff --git a/src/structures/note.ts b/src/structures/note.ts index f2459ce..4cc3035 100644 --- a/src/structures/note.ts +++ b/src/structures/note.ts @@ -41,4 +41,38 @@ export class Note { return baseValue; } + + public write(): string { + let writer = ""; + + writer += this.location.toString(); + + if ((this.styles & NoteStyles.Ex) != 0) writer += "x"; + + if ((this.styles & NoteStyles.Mine) != 0) writer += "m"; + + if (this.type === NoteType.ForceInvalidate) writer += this.slideMorph === SlideMorph.FadeIn ? "?" : "!"; + + switch (this.appearance) { + case NoteAppearance.ForceNormal: + writer += "@"; + break; + case NoteAppearance.ForceStarSpinning: + writer += "$$"; + break; + case NoteAppearance.ForceStar: + writer += "$"; + break; + } + + if (this.length) `h[#${this.length.toFixed(7)}]`; + + for (let i = 0; i < this.slidePaths.length; i++) { + if (i > 0) writer += "*"; + + writer += this.slidePaths[i].write(); + } + + return writer; + } } diff --git a/src/structures/slidePath.ts b/src/structures/slidePath.ts index 508a68c..e40276c 100644 --- a/src/structures/slidePath.ts +++ b/src/structures/slidePath.ts @@ -18,4 +18,18 @@ export class SlidePath { constructor(segments: SlideSegment[]) { this.segments = segments; } + + public write(): string { + let writer = ""; + + for (const segment of this.segments) { + writer += segment.write(this.startLocation); + } + + if (this.type === NoteType.Break) writer += "b"; + + writer += `[${this.delay.toFixed(7)}##${this.duration.toFixed(7)}]`; + + return writer; + } } diff --git a/src/structures/slideSegment.ts b/src/structures/slideSegment.ts index 766cba1..2c41d55 100644 --- a/src/structures/slideSegment.ts +++ b/src/structures/slideSegment.ts @@ -13,4 +13,48 @@ export class SlideSegment { this.vertices = vertices ?? []; this.slideType = SlideType.StraightLine; } + + public write(startLocation: Location): string { + let writer = ""; + + switch (this.slideType) { + case SlideType.StraightLine: + writer += `-${this.vertices[0]}`; + break; + case SlideType.RingCw: + writer += (startLocation.index + 2) % 8 >= 4 ? `<${this.vertices[0]}` : `>${this.vertices[0]}`; + break; + case SlideType.RingCcw: + writer += (startLocation.index + 2) % 8 >= 4 ? `<${this.vertices[0]}` : `>${this.vertices[0]}`; + break; + case SlideType.Fold: + writer += `v${this.vertices[0]}`; + break; + case SlideType.CurveCw: + writer += `q${this.vertices[0]}`; + break; + case SlideType.CurveCcw: + writer += `pp${this.vertices[0]}`; + break; + case SlideType.ZigZagS: + writer += `s${this.vertices[0]}`; + break; + case SlideType.ZigZagZ: + writer += `z${this.vertices[0]}`; + break; + case SlideType.EdgeFold: + writer += `V${this.vertices[0]}${this.vertices[1]}`; + break; + case SlideType.EdgeCurveCw: + writer += `qq${this.vertices[0]}`; + case SlideType.EdgeCurveCcw: + writer += `pp${this.vertices[0]}`; + case SlideType.Fan: + writer += `w${this.vertices[0]}`; + // default: + // throw new ArguementOutOfRangeException(); + } + + return writer; + } } diff --git a/tests/chartTests.ts b/tests/chartTests.ts index 1033f09..27d6cc7 100644 --- a/tests/chartTests.ts +++ b/tests/chartTests.ts @@ -93,4 +93,15 @@ describe("SimaiConvert", () => { assert.equal(chart.noteCollections[1].time, 1); assert.equal(chart.noteCollections[2].time, 1.5); }); + + it("should be able to serialize", async () => { + const chartText = await fs.readFile(path.join(testChartPath, "../fileTests/0.txt"), "utf-8"); + + const simaiFile = new SimaiFile(chartText); + const chart = SimaiConvert.deserialize(simaiFile.getValue("inote_3")); + + const serialized = SimaiConvert.serialize(chart); + console.log(serialized); + assert.notEqual(serialized, ""); + }); });