From 3f206a16de129d7c11f8af912b958e820d232189 Mon Sep 17 00:00:00 2001 From: SoulMelody Date: Sun, 12 Jan 2025 04:31:56 +0800 Subject: [PATCH] vxf: implement vxf parser --- docs/project_formats.md | 2 + libresvip/plugins/vxf/model.py | 13 +- libresvip/plugins/vxf/options.py | 11 ++ libresvip/plugins/vxf/vx_beta_converter.py | 19 +++ libresvip/plugins/vxf/vx_beta_generator.py | 14 ++ libresvip/plugins/vxf/vx_beta_parser.py | 178 +++++++++++++++++++++ libresvip/plugins/vxf/vxf.yapsy-plugin | 11 ++ 7 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 libresvip/plugins/vxf/options.py create mode 100644 libresvip/plugins/vxf/vx_beta_converter.py create mode 100644 libresvip/plugins/vxf/vx_beta_generator.py create mode 100644 libresvip/plugins/vxf/vx_beta_parser.py create mode 100644 libresvip/plugins/vxf/vxf.yapsy-plugin diff --git a/docs/project_formats.md b/docs/project_formats.md index dcd278f4b..ebdbdc477 100644 --- a/docs/project_formats.md +++ b/docs/project_formats.md @@ -25,6 +25,7 @@ | `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 | 自定义二进制格式 | | | 停止开发 | @@ -32,4 +33,5 @@ | `vsq` | Vocaloid 2 | 自定义二进制格式 | 基于MIDI和INI | | 停止开发 | | `vsqx` | Vocaloid 3/4 | 标准序列化格式 | 基于xml | | 尚在维护 | | `vvproj` | VOICEVOX | 标准序列化格式 | 基于json | | 活跃开发 | +| `vxf` | VOCALOID β-STUDIO | 自定义二进制格式 | 基于MIDI2.0切片(SMF2CLIP)格式 | | 活跃开发 | | `y77` | 元七七编辑器 | 标准序列化格式 | 基于json | | 停止开发 | diff --git a/libresvip/plugins/vxf/model.py b/libresvip/plugins/vxf/model.py index 94cf2b2f7..4c6127386 100644 --- a/libresvip/plugins/vxf/model.py +++ b/libresvip/plugins/vxf/model.py @@ -4,6 +4,8 @@ Const, ExprAdapter, GreedyRange, + If, + IfThenElse, Int8ub, Int16ub, Int32ub, @@ -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"), ) @@ -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"), ) diff --git a/libresvip/plugins/vxf/options.py b/libresvip/plugins/vxf/options.py new file mode 100644 index 000000000..8ca809429 --- /dev/null +++ b/libresvip/plugins/vxf/options.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + +from libresvip.model.option_mixins import EnablePitchImportationMixin + + +class InputOptions(EnablePitchImportationMixin, BaseModel): + pass + + +class OutputOptions(BaseModel): + pass diff --git a/libresvip/plugins/vxf/vx_beta_converter.py b/libresvip/plugins/vxf/vx_beta_converter.py new file mode 100644 index 000000000..d4dd93300 --- /dev/null +++ b/libresvip/plugins/vxf/vx_beta_converter.py @@ -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)) diff --git a/libresvip/plugins/vxf/vx_beta_generator.py b/libresvip/plugins/vxf/vx_beta_generator.py new file mode 100644 index 000000000..27c5b4d9a --- /dev/null +++ b/libresvip/plugins/vxf/vx_beta_generator.py @@ -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() diff --git a/libresvip/plugins/vxf/vx_beta_parser.py b/libresvip/plugins/vxf/vx_beta_parser.py new file mode 100644 index 000000000..b6f92aa55 --- /dev/null +++ b/libresvip/plugins/vxf/vx_beta_parser.py @@ -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] diff --git a/libresvip/plugins/vxf/vxf.yapsy-plugin b/libresvip/plugins/vxf/vxf.yapsy-plugin new file mode 100644 index 000000000..782ece644 --- /dev/null +++ b/libresvip/plugins/vxf/vxf.yapsy-plugin @@ -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 \ No newline at end of file