Skip to content

Commit

Permalink
vxf: implement vxf parser
Browse files Browse the repository at this point in the history
  • Loading branch information
SoulMelody committed Jan 11, 2025
1 parent f05353b commit 3f206a1
Show file tree
Hide file tree
Showing 7 changed files with 245 additions and 3 deletions.
2 changes: 2 additions & 0 deletions docs/project_formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@
| `ufdata` | UtaFormatix 3 | 标准序列化格式 | 基于json | | 活跃开发 |
| `ust` | Ameya UTAU | 自定义文本格式 | | | 尚在维护 |
| `ustx` | OpenUTAU | 标准序列化格式 | 基于yaml | | 活跃开发 |
| `vfp` | VOX Factory | 标准序列化格式 | 基于json,并使用zip压缩 | | 活跃开发 |
| `vog` | Vogen | 标准序列化格式 | 基于json,并使用zip压缩 | | 疑似暂停 |
| `vpr` | Vocaloid 5+ | 标准序列化格式 | 基于json,并使用zip压缩 | | 活跃开发 |
| `vsp` | Vocalina Studio | 自定义二进制格式 | | | 停止开发 |
| `vspx` | VocalSharp | 标准序列化格式 | 基于xml | 使用了自定义的序列化和解析框架,在生成vspx文件时需特殊处理 | 停止开发 |
| `vsq` | Vocaloid 2 | 自定义二进制格式 | 基于MIDI和INI | | 停止开发 |
| `vsqx` | Vocaloid 3/4 | 标准序列化格式 | 基于xml | | 尚在维护 |
| `vvproj` | VOICEVOX | 标准序列化格式 | 基于json | | 活跃开发 |
| `vxf` | VOCALOID β-STUDIO | 自定义二进制格式 | 基于MIDI2.0切片(SMF2CLIP)格式 | | 活跃开发 |
| `y77` | 元七七编辑器 | 标准序列化格式 | 基于json | | 停止开发 |
13 changes: 10 additions & 3 deletions libresvip/plugins/vxf/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
Const,
ExprAdapter,
GreedyRange,
If,
IfThenElse,
Int8ub,
Int16ub,
Int32ub,
Expand Down Expand Up @@ -57,7 +59,7 @@
"header" / Const(b"\xd0"),
"seq_stat" / Int8ub,
"magic" / Const(b"\x01\x03"),
"data" / PaddedString(12, "utf-8"),
"name" / PaddedString(12, "utf-8"),
"type" / Computed("track_name"),
)

Expand Down Expand Up @@ -103,8 +105,13 @@
"header" / Const(b"\xd0"),
"seq_stat" / Int8ub,
"magic" / Const(b"\x01\x50"),
"seq_num" / Int16ub,
"lyric" / PaddedString(10, "utf-8"),
"seq_num" / If(lambda this: (this.seq_stat - 16) // 64 <= 1, Int16ub),
"text"
/ IfThenElse(
lambda this: (this.seq_stat - 16) // 64 <= 1,
PaddedString(10, "ascii"),
PaddedString(12, "ascii"),
),
"type" / Computed("lyrics"),
)

Expand Down
11 changes: 11 additions & 0 deletions libresvip/plugins/vxf/options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from pydantic import BaseModel

from libresvip.model.option_mixins import EnablePitchImportationMixin


class InputOptions(EnablePitchImportationMixin, BaseModel):
pass


class OutputOptions(BaseModel):
pass
19 changes: 19 additions & 0 deletions libresvip/plugins/vxf/vx_beta_converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import pathlib

from libresvip.extension import base as plugin_base
from libresvip.model.base import Project

from .model import VxFile
from .options import InputOptions, OutputOptions
from .vx_beta_generator import VxBetaGenerator
from .vx_beta_parser import VxBetaParser


class VxBetaConverter(plugin_base.SVSConverterBase):
def load(self, path: pathlib.Path, options: InputOptions) -> Project:
vx_file = VxFile.parse(path.read_bytes())
return VxBetaParser(options).parse_project(vx_file)

def dump(self, path: pathlib.Path, project: Project, options: OutputOptions) -> None:
vx_file = VxBetaGenerator(options).generate_project(project)
path.write_bytes(VxFile.build(vx_file))
14 changes: 14 additions & 0 deletions libresvip/plugins/vxf/vx_beta_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import dataclasses

from libresvip.model.base import Project

from .model import VxFile
from .options import OutputOptions


@dataclasses.dataclass
class VxBetaGenerator:
options: OutputOptions

def generate_project(self, project: Project) -> VxFile:
return VxFile()
178 changes: 178 additions & 0 deletions libresvip/plugins/vxf/vx_beta_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import collections
import dataclasses
import math
import operator

from libresvip.core.constants import TICKS_IN_BEAT
from libresvip.core.time_sync import TimeSynchronizer
from libresvip.core.warning_types import show_warning
from libresvip.model.base import Note, Project, SingingTrack, SongTempo, TimeSignature
from libresvip.model.pitch_simulator import PitchSimulator
from libresvip.model.point import Point
from libresvip.model.portamento import PortamentoPitch
from libresvip.model.relative_pitch_curve import RelativePitchCurve
from libresvip.utils.translation import gettext_lazy as _

from .model import VxFile, VxPitchData, VxTrack
from .options import InputOptions


@dataclasses.dataclass
class VxBetaParser:
options: InputOptions
ticks_per_beat: int = dataclasses.field(init=False)
first_bar_length: int = dataclasses.field(init=False)

@property
def tick_rate(self) -> float:
if self.ticks_per_beat is not None:
return TICKS_IN_BEAT / self.ticks_per_beat
return 1

def parse_project(self, vx_project: VxFile) -> Project:
self.ticks_per_beat = vx_project.ticks_per_beat
self._convert_delta_to_cumulative(vx_project.tracks)
time_signature_list = []
if len(vx_project.tracks) > 0:
master_track = vx_project.tracks[0]
time_signature_list.extend(self.parse_time_signatures(master_track))
song_tempo_list = self.parse_tempos(vx_project.tracks)
self.synchronizer = TimeSynchronizer(song_tempo_list)
return Project(
song_tempo_list=song_tempo_list,
time_signature_list=time_signature_list,
track_list=self.parse_tracks(vx_project.tracks),
)

@staticmethod
def _convert_delta_to_cumulative(tracks: list[VxTrack]) -> None:
for track in tracks:
tick = 0
for event in track.events:
event.time += tick
tick = event.time

def parse_tempos(self, tracks: list[VxTrack]) -> list[SongTempo]:
tempos: list[SongTempo] = []

# traversing
for track in tracks:
for event in track.events:
if event.type == "set_tempo":
# convert tempo to BPM
tempo = round(event.tempo, 3)
tick = round(event.time * self.tick_rate)
last_tempo = tempos[-1].bpm if tempos else None
if tempo != last_tempo:
tempos.append(SongTempo(position=tick, bpm=tempo))
if not tempos:
# default bpm
show_warning(_("No tempo labels found in the imported project."))
tempos.append(SongTempo(position=0, bpm=self.options.default_bpm))
else:
tempos.sort(key=operator.attrgetter("position"))
return tempos

def parse_time_signatures(self, master_track: VxTrack) -> list[TimeSignature]:
# no default
time_signature_changes: list[TimeSignature] = []

# traversing
prev_ticks = 0
measure = 0
for event in master_track.events:
if event.type == "time_signature":
tick = event.time
if not time_signature_changes:
tick_in_full_note = 4 * self.ticks_per_beat
else:
tick_in_full_note = round(
time_signature_changes[-1].bar_length(self.ticks_per_beat)
)
measure += (tick - prev_ticks) / tick_in_full_note
ts_obj = TimeSignature(
bar_index=math.floor(measure),
numerator=event.numerator,
denominator=event.denominator,
)
time_signature_changes.append(ts_obj)
prev_ticks = tick
if not time_signature_changes or time_signature_changes[0].bar_index > 0:
time_signature_changes.insert(0, TimeSignature(bar_index=0, numerator=4, denominator=4))
self.first_bar_length = round(time_signature_changes[0].bar_length())
return time_signature_changes

def parse_track(self, track: VxTrack) -> SingingTrack:
lyrics: dict[int, str] = collections.defaultdict(lambda: "l-aa")
prev_index = None
for event in track.events:
if event.type == "lyrics":
seq_stat = (event.seq_stat - 16) // 64
if seq_stat == 0:
lyrics[event.seq_num] = event.text
prev_index = None
elif seq_stat == 1:
lyrics[event.seq_num] = event.text
prev_index = event.seq_num
elif seq_stat == 2 and prev_index is not None:
lyrics[prev_index] += event.text
elif seq_stat == 3 and prev_index is not None:
lyrics[prev_index] += event.text
prev_index = None
last_note_on = None
notes: list[Note] = []
for event in track.events:
if event.type == "note_on":
last_note_on = event
elif event.type == "note_off" and last_note_on:
notes.append(
Note(
start_pos=round(last_note_on.time * self.tick_rate),
length=round((event.time - last_note_on.time) * self.tick_rate),
lyric=lyrics[len(notes)],
key_number=event.note,
)
)
last_note_on = None
singing_track = SingingTrack(
title="".join(event.name for event in track.title_parts),
note_list=notes,
)
if self.options.import_pitch and (
buffer := "".join(event.text for event in track.events if event.type == "metadata")
):
pitch_data = VxPitchData.model_validate_json(buffer)
rel_pitch_points = []
prev_pos = None
for point in pitch_data.time_based_pitch_sequence.pitch_sequence:
if prev_pos and point.position - prev_pos > 1 and len(rel_pitch_points):
rel_pitch_points.append(
Point(
x=rel_pitch_points[-1].x,
y=0,
)
)
rel_pitch_points.append(
Point(
x=int(
self.synchronizer.get_actual_ticks_from_secs(
point.position
* pitch_data.time_based_pitch_sequence.time_frame_period_seconds
)
),
y=int(point.pitch),
)
)
prev_pos = point.position
pitch_simulator = PitchSimulator(
synchronizer=self.synchronizer,
portamento=PortamentoPitch.no_portamento(),
note_list=[note.model_copy(update={"key_number": 69}) for note in notes],
)
singing_track.edited_params.pitch = RelativePitchCurve(
self.first_bar_length
).to_absolute(rel_pitch_points, pitch_simulator)
return singing_track

def parse_tracks(self, vx_tracks: list[VxTrack]) -> list[SingingTrack]:
return [self.parse_track(track) for track in vx_tracks]
11 changes: 11 additions & 0 deletions libresvip/plugins/vxf/vxf.yapsy-plugin
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[Core]
name = VX-β
module = vx_beta_converter

[Documentation]
Author = SoulMelody
Version = 3.0.2
Website = https://space.bilibili.com/175862486
Format = VX-β project file
Description = Convertion plugin for VX-β project file
Suffix = vxf

0 comments on commit 3f206a1

Please sign in to comment.