Skip to content

Commit

Permalink
Add: Support tssln (#172)
Browse files Browse the repository at this point in the history
* Add: Support importing tssln

* Add: Add basic export

* Code: gradlew ktlintFormat

* Change: Use yarn
  • Loading branch information
sevenc-nanashi authored Jun 24, 2024
1 parent cd06e72 commit 2f895bf
Show file tree
Hide file tree
Showing 15 changed files with 405 additions and 0 deletions.
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

0 comments on commit 2f895bf

Please sign in to comment.