Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add: Support tssln #172

Merged
merged 4 commits into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io
1 change: 1 addition & 0 deletions core/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io
3 changes: 3 additions & 0 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ kotlin {
implementation(npm("uuid", "8.3.2"))
implementation(npm("midi-file", "1.2.4"))
implementation(npm("js-yaml", "4.1.0"))
implementation(
npm("@sevenc-nanashi/valuetree-ts", "npm:@jsr/[email protected]"),
)
}
}
}
2 changes: 2 additions & 0 deletions core/src/main/kotlin/core/exception/IllegalFileException.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ sealed class IllegalFileException(message: String) : Throwable(message) {
)

class IllegalMidiFile : IllegalFileException("Cannot parse this file as a MIDI file.")

class IllegalTsslnFile : IllegalFileException("Cannot parse this file as a tssln file.")
}
2 changes: 2 additions & 0 deletions core/src/main/kotlin/core/external/Common.kt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
package core.external

external fun require(module: String): dynamic

external fun <T> structuredClone(obj: T): T
3 changes: 3 additions & 0 deletions core/src/main/kotlin/core/external/Resources.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ object Resources {
val musicXmlTemplate: String
get() = require("./format_templates/template.musicxml").default as String

val tsslnTemplate: Array<Byte>
get() =
require("./format_templates/template.tssln.json") as Array<Byte>
val svpTemplate: String
get() = require("./format_templates/template.svp").default as String

Expand Down
65 changes: 65 additions & 0 deletions core/src/main/kotlin/core/external/ValueTree.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package core.external

import org.khronos.webgl.Uint8Array

fun createValueTree(): ValueTree {
return js("{type: '', attributes: {}, children: []}").unsafeCast<ValueTree>()
}

fun baseVariantType(): dynamic {
return js("({type: '', value: undefined})")
}

fun String.toVariantType(): dynamic {
val value = baseVariantType()
value.type = "string"
value.value = this

return value
}
fun Int.toVariantType(): dynamic {
val value = baseVariantType()
value.type = "int"
value.value = this

return value
}
fun Double.toVariantType(): dynamic {
val value = baseVariantType()
value.type = "double"
value.value = this

return value
}
fun Boolean.toVariantType(): dynamic {
val value = baseVariantType()
if (this) {
value.type = "boolTrue"
value.value = true
} else {
value.type = "boolFalse"
value.value = false
}

return value
}
fun Uint8Array.toVariantType(): dynamic {
val value = baseVariantType()
value.type = "binary"
value.value = this

return value
}

external interface ValueTree {
var type: String
var attributes: dynamic
var children: Array<ValueTree>
}

@JsModule("@sevenc-nanashi/valuetree-ts")
@JsNonModule
external object ValueTreeTs {
fun parseValueTree(text: Uint8Array): ValueTree
fun dumpValueTree(tree: ValueTree): Uint8Array
}
289 changes: 289 additions & 0 deletions core/src/main/kotlin/core/io/Tssln.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
package core.io

import core.exception.IllegalFileException
import core.external.Resources
import core.external.ValueTree
import core.external.ValueTreeTs
import core.external.createValueTree
import core.external.structuredClone
import core.external.toVariantType
import core.model.ExportResult
import core.model.Format
import core.model.ImportParams
import core.model.ImportWarning
import core.model.Note
import core.model.Project
import core.model.TICKS_IN_BEAT
import core.model.Tempo
import core.model.TimeSignature
import core.model.Track
import core.util.nameWithoutExtension
import core.util.readAsArrayBuffer
import org.khronos.webgl.Uint8Array
import org.w3c.files.Blob
import org.w3c.files.File
import kotlin.math.floor

object Tssln {
suspend fun parse(file: File, params: ImportParams): Project {
val blob = file.readAsArrayBuffer()
val valueTree = ValueTreeTs.parseValueTree(
Uint8Array(blob),
)

if (valueTree.type != "TSSolution") {
throw IllegalFileException.IllegalTsslnFile()
}

val trackTrees =
valueTree.children.first { it.type == "Tracks" }.children.filter { it.attributes.Type.value == 0 }

val masterTrackResult = parseMasterTrack(trackTrees.first())
val tempos = masterTrackResult.first
val timeSignatures = masterTrackResult.second

val tracks = parseTracks(trackTrees, params)

val warnings = mutableListOf<ImportWarning>()

return Project(
format = format,
inputFiles = listOf(file),
name = file.nameWithoutExtension,
tracks = tracks,
tempos = tempos,
timeSignatures = timeSignatures,
measurePrefix = 1,
importWarnings = warnings,
)
}

private fun parseTracks(trackTrees: List<ValueTree>, params: ImportParams): List<Track> {
return trackTrees.mapIndexed { trackIndex, trackTree ->
val trackName = trackTree.attributes.Name.value as String
val pluginData = trackTree.attributes.PluginData.value as Uint8Array
val pluginDataTree = ValueTreeTs.parseValueTree(pluginData)

if (pluginDataTree.type != "StateInformation") {
throw IllegalFileException.IllegalTsslnFile()
}

val songTree = pluginDataTree.children.first { it.type == "Song" }
val scoreTree = songTree.children.first { it.type == "Score" }

val notes = mutableListOf<Note>()

for ((noteIndex, noteTree) in scoreTree.children.withIndex()) {
if (noteTree.type != "Note") {
continue
}

val pitchStep = noteTree.attributes.PitchStep.value as Int
val pitchOctave = noteTree.attributes.PitchOctave.value as Int
val rawLyric = (noteTree.attributes.Lyric.value as String)
val lyric = phonemePartPattern.replace(rawLyric, "").takeUnless { it.isBlank() } ?: params.defaultLyric

val phoneme = (noteTree.attributes.Phoneme.value as String).replace(",", "")

val tickOn = (noteTree.attributes.Clock.value as Int)
val tickOff = tickOn + (noteTree.attributes.Duration.value as Int)

notes.add(
Note(
id = noteIndex,
key = pitchOctave * 12 + pitchStep + 12,
lyric = lyric,
phoneme = phoneme,
tickOn = (tickOn / TICK_RATE).toLong(),
tickOff = (tickOff / TICK_RATE).toLong(),
),
)
}

Track(
id = trackIndex,
name = trackName,
notes = notes,
)
}
}

private fun parseMasterTrack(trackTree: ValueTree): Pair<List<Tempo>, List<TimeSignature>> {
val pluginData = trackTree.attributes.PluginData.value as Uint8Array
val pluginDataTree = ValueTreeTs.parseValueTree(pluginData)
if (pluginDataTree.type != "StateInformation") {
throw IllegalFileException.IllegalTsslnFile()
}

val songTree = pluginDataTree.children.first { it.type == "Song" }

val tempoTree = songTree.children.first { it.type == "Tempo" }

val tempos = tempoTree.children.map {
Tempo(
((it.attributes.Clock.value as Int) / TICK_RATE).toLong(),
it.attributes.Tempo.value as Double,
)
}

val timeSignaturesTree = songTree.children.first { it.type == "Beat" }

val timeSignatures = mutableListOf<TimeSignature>()

var currentBeatIndex = 0
var currentMeasureIndex = 0
var beatLength = 4.0

for (
timeSignatureTree in timeSignaturesTree.children.sortedBy {
it.attributes.Clock.value as Int
}
) {
val numerator = timeSignatureTree.attributes.Beats.value as Int
val denominator = timeSignatureTree.attributes.BeatType.value as Int
val clock = timeSignatureTree.attributes.Clock.value as Int
val beatIndex = floor(clock / TICK_RATE / TICKS_IN_BEAT).toInt()

val beatNum = beatIndex - currentBeatIndex

if (beatNum < 0) {
throw IllegalFileException.IllegalTsslnFile()
}

val measureIndex = (currentMeasureIndex + beatNum / beatLength).toInt()
beatLength = numerator.toDouble() / denominator * 4

currentBeatIndex = beatIndex
currentMeasureIndex = measureIndex

timeSignatures.add(
TimeSignature(
measureIndex,
numerator,
denominator,
),
)
}

return Pair(tempos, timeSignatures)
}

fun generate(project: Project): ExportResult {
val baseJson = Resources.tsslnTemplate

val baseTree = ValueTreeTs.parseValueTree(Uint8Array(baseJson))

val tracksTree = baseTree.children.first { it.type == "Tracks" }
val baseTrack = tracksTree.children.first { it.attributes.Type.value == 0 }

val tracks = generateTracks(baseTrack, project)
tracksTree.children = tracks.toTypedArray()

val result = ValueTreeTs.dumpValueTree(baseTree)

return ExportResult(
Blob(arrayOf(result)),
project.name + ".tssln",
listOf(),
)
}

private fun generateTracks(baseTrack: ValueTree, project: Project): List<ValueTree> {
return project.tracks.map { track ->
val trackTree = structuredClone(baseTrack)
trackTree.attributes.Name = (track.name).toVariantType()

val pluginData = trackTree.attributes.PluginData.value as Uint8Array
val pluginDataTree = ValueTreeTs.parseValueTree(pluginData)

val songTree = pluginDataTree.children.first { it.type == "Song" }

val tempoTree = songTree.children.first { it.type == "Tempo" }
val timeSignaturesTree = songTree.children.first { it.type == "Beat" }

tempoTree.children = (generateTempos(project.tempos)).toTypedArray()
timeSignaturesTree.children = (generateTimeSignatures(project.timeSignatures)).toTypedArray()

val scoreTree = songTree.children.first { it.type == "Score" }
val notes = generateNotes(track)
val baseChildren = structuredClone(scoreTree.children)

val newChildren = baseChildren.toMutableList()
newChildren.addAll(notes)

scoreTree.children = newChildren.toTypedArray()

trackTree.attributes.PluginData = (ValueTreeTs.dumpValueTree(pluginDataTree)).toVariantType()

trackTree
}
}

private fun generateTempos(tempos: List<Tempo>): List<ValueTree> {
return tempos.map {
val tempoTree = createValueTree()
tempoTree.type = "Sound"
val attributes: dynamic = js("{}")
attributes.Clock = ((it.tickPosition * TICK_RATE).toInt()).toVariantType()
attributes.Tempo = (it.bpm).toVariantType()
tempoTree.attributes = attributes

tempoTree
}
}

private fun generateTimeSignatures(timeSignatures: List<TimeSignature>): List<ValueTree> {
val timeSignatureTrees = mutableListOf<ValueTree>()

var currentBeat = 0
var currentMeasure = 0
var beatLength = 4.0

for (timeSignature in timeSignatures) {
val timeSignatureTree = createValueTree()
timeSignatureTree.type = "Time"
val attributes: dynamic = js("{}")

val numMeasure = timeSignature.measurePosition - currentMeasure
val numBeat = numMeasure * beatLength
currentBeat += numBeat.toInt()

beatLength = timeSignature.numerator.toDouble() / timeSignature.denominator * 4
currentMeasure = timeSignature.measurePosition

attributes.Clock = (currentBeat * TICKS_IN_BEAT * TICK_RATE).toInt().toVariantType()
attributes.Beats = timeSignature.numerator.toVariantType()
attributes.BeatType = timeSignature.denominator.toVariantType()

timeSignatureTree.attributes = attributes

timeSignatureTrees.add(timeSignatureTree)
}

return timeSignatureTrees
}

private fun generateNotes(track: Track): List<ValueTree> {
return track.notes.map {
val noteTree = createValueTree()
noteTree.type = "Note"
val attributes: dynamic = js("{}")
attributes.PitchStep = (it.key % 12).toVariantType()
attributes.PitchOctave = (it.key / 12 - 1).toVariantType()
attributes.Lyric = it.lyric.toVariantType()
attributes.Phoneme = (it.phoneme ?: "").toVariantType()
attributes.Clock = (it.tickOn * TICK_RATE).toInt().toVariantType()
attributes.Syllabic = 0.toVariantType()
attributes.Duration = ((it.tickOff - it.tickOn) * TICK_RATE).toInt().toVariantType()
noteTree.attributes = attributes

noteTree
}
}

private const val TICK_RATE = 2.0

private val phonemePartPattern = Regex("""\[.+?]""")

private val format = Format.Tssln
}
Loading
Loading