From dae858c13cb29ccf5af6bb8cd41ae10b5396b97d Mon Sep 17 00:00:00 2001 From: SoulMelody Date: Sun, 12 Jan 2025 09:18:15 +0800 Subject: [PATCH] vxf: implement vxf generator --- libresvip/plugins/mid/midi_generator.py | 4 +- libresvip/plugins/mid/midi_parser.py | 4 +- libresvip/plugins/mid/options.py | 4 +- libresvip/plugins/vsq/options.py | 3 +- libresvip/plugins/vsq/vsq_generator.py | 4 +- libresvip/plugins/vsq/vsq_parser.py | 4 +- libresvip/plugins/vxf/model.py | 6 +- libresvip/plugins/vxf/options.py | 18 ++- libresvip/plugins/vxf/vx_beta_generator.py | 176 ++++++++++++++++++++- libresvip/plugins/vxf/vx_beta_parser.py | 50 +++--- 10 files changed, 229 insertions(+), 44 deletions(-) diff --git a/libresvip/plugins/mid/midi_generator.py b/libresvip/plugins/mid/midi_generator.py index c9f5b8245..f8286add1 100644 --- a/libresvip/plugins/mid/midi_generator.py +++ b/libresvip/plugins/mid/midi_generator.py @@ -28,9 +28,7 @@ class MidiGenerator: @property def tick_rate(self) -> float: - if self.options is not None: - return TICKS_IN_BEAT / self.options.ticks_per_beat - return 1 + return TICKS_IN_BEAT / self.options.ticks_per_beat def generate_project(self, project: Project) -> mido.MidiFile: self.first_bar_length = round( diff --git a/libresvip/plugins/mid/midi_parser.py b/libresvip/plugins/mid/midi_parser.py index 072fb6d44..0b7d60df7 100644 --- a/libresvip/plugins/mid/midi_parser.py +++ b/libresvip/plugins/mid/midi_parser.py @@ -72,9 +72,7 @@ def __post_init__(self) -> None: @property def tick_rate(self) -> float: - if self.ticks_per_beat is not None: - return TICKS_IN_BEAT / self.ticks_per_beat - return 1 + return TICKS_IN_BEAT / self.ticks_per_beat def parse_project(self, mido_obj: mido.MidiFile) -> Project: self.ticks_per_beat = mido_obj.ticks_per_beat diff --git a/libresvip/plugins/mid/options.py b/libresvip/plugins/mid/options.py index 2dc7090b6..644a90587 100644 --- a/libresvip/plugins/mid/options.py +++ b/libresvip/plugins/mid/options.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, Field -from libresvip.core.constants import DEFAULT_BPM +from libresvip.core.constants import DEFAULT_BPM, TICKS_IN_BEAT from libresvip.model.option_mixins import ( EnablePitchImportationMixin, EnableVolumeImportationMixin, @@ -74,7 +74,7 @@ class OutputOptions(BaseModel): title=_("Transpose"), ) ticks_per_beat: int = Field( - default=480, + default=TICKS_IN_BEAT, title=_("Ticks per beat"), description=_( "Also known as parts per quarter, ticks per quarter note, the number of pulses per quarter note. This setting should not be changed unless you know what it is." diff --git a/libresvip/plugins/vsq/options.py b/libresvip/plugins/vsq/options.py index c92b36b2f..8b5f754ba 100644 --- a/libresvip/plugins/vsq/options.py +++ b/libresvip/plugins/vsq/options.py @@ -4,6 +4,7 @@ from pydantic import BaseModel, Field +from libresvip.core.constants import TICKS_IN_BEAT from libresvip.model.option_mixins import EnablePitchImportationMixin from libresvip.utils.translation import gettext_lazy as _ @@ -27,7 +28,7 @@ class InputOptions(EnablePitchImportationMixin, BaseModel): class OutputOptions(BaseModel): ticks_per_beat: int = Field( - default=480, + default=TICKS_IN_BEAT, title=_("Ticks per beat"), description=_( "Also known as parts per quarter, ticks per quarter note, the number of pulses per quarter note. This setting should not be changed unless you know what it is." diff --git a/libresvip/plugins/vsq/vsq_generator.py b/libresvip/plugins/vsq/vsq_generator.py index fe3c66132..fac6e967f 100644 --- a/libresvip/plugins/vsq/vsq_generator.py +++ b/libresvip/plugins/vsq/vsq_generator.py @@ -35,9 +35,7 @@ class VsqGenerator: @property def tick_rate(self) -> float: - if self.options is not None: - return TICKS_IN_BEAT / self.options.ticks_per_beat - return 1 + return TICKS_IN_BEAT / self.options.ticks_per_beat def generate_project(self, project: Project) -> mido.MidiFile: project = limit_bars(project, 4096) diff --git a/libresvip/plugins/vsq/vsq_parser.py b/libresvip/plugins/vsq/vsq_parser.py index 5ed9b2725..37ff99565 100644 --- a/libresvip/plugins/vsq/vsq_parser.py +++ b/libresvip/plugins/vsq/vsq_parser.py @@ -36,9 +36,7 @@ class VsqParser: @property def tick_rate(self) -> float: - if self.ticks_per_beat is not None: - return TICKS_IN_BEAT / self.ticks_per_beat - return 1 + return TICKS_IN_BEAT / self.ticks_per_beat def parse_project(self, vsq_project: mido.MidiFile) -> Project: self.ticks_per_beat = vsq_project.ticks_per_beat diff --git a/libresvip/plugins/vxf/model.py b/libresvip/plugins/vxf/model.py index 4c6127386..db21395d4 100644 --- a/libresvip/plugins/vxf/model.py +++ b/libresvip/plugins/vxf/model.py @@ -79,7 +79,7 @@ "tempo" / ExprAdapter( Int32ub, - encoder=lambda obj, context: int(1705032600 * obj), + encoder=lambda obj, context: int(1705032600 / obj), decoder=lambda obj, context: 1705032600 / obj, ), "padding" / Const(b"\x00" * 8), @@ -129,7 +129,7 @@ "note" / StartPitch, Check(lambda ctx: 0 <= ctx.note <= 127), "velocity" / Int16ub, - "data" / Int16ub, + "on_data" / Int16ub, "type" / Computed("note_on"), ) @@ -138,7 +138,7 @@ "note" / EndPitch, Check(lambda ctx: 0 <= ctx.note <= 127), "velocity" / Int16ub, - "data" / Int16ub, + "off_data" / Int16ub, "type" / Computed("note_off"), ) diff --git a/libresvip/plugins/vxf/options.py b/libresvip/plugins/vxf/options.py index 8ca809429..3cfe45cf8 100644 --- a/libresvip/plugins/vxf/options.py +++ b/libresvip/plugins/vxf/options.py @@ -1,11 +1,23 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field +from libresvip.core.constants import DEFAULT_BPM, TICKS_IN_BEAT from libresvip.model.option_mixins import EnablePitchImportationMixin +from libresvip.utils.translation import gettext_lazy as _ class InputOptions(EnablePitchImportationMixin, BaseModel): - pass + default_bpm: float = Field( + default=DEFAULT_BPM, + title=_("Default BPM"), + description=_("Used when no BPM information is found in the MIDI file."), + ) class OutputOptions(BaseModel): - pass + ticks_per_beat: int = Field( + default=TICKS_IN_BEAT, + title=_("Ticks per beat"), + description=_( + "Also known as parts per quarter, ticks per quarter note, the number of pulses per quarter note. This setting should not be changed unless you know what it is." + ), + ) diff --git a/libresvip/plugins/vxf/vx_beta_generator.py b/libresvip/plugins/vxf/vx_beta_generator.py index 27c5b4d9a..6ddf366f6 100644 --- a/libresvip/plugins/vxf/vx_beta_generator.py +++ b/libresvip/plugins/vxf/vx_beta_generator.py @@ -1,14 +1,182 @@ import dataclasses +import operator +from typing import Any, Optional -from libresvip.model.base import Project +import more_itertools + +from libresvip.core.constants import TICKS_IN_BEAT +from libresvip.core.time_sync import TimeSynchronizer +from libresvip.model.base import Project, SingingTrack, SongTempo, TimeSignature, Track -from .model import VxFile from .options import OutputOptions +Container = dict[str, Any] + @dataclasses.dataclass class VxBetaGenerator: options: OutputOptions + synchronizer: TimeSynchronizer = dataclasses.field(init=False) + first_bar_length: int = dataclasses.field(init=False) + + @property + def tick_rate(self) -> float: + return TICKS_IN_BEAT / self.options.ticks_per_beat + + def generate_project(self, project: Project) -> Container: + self.first_bar_length = round( + project.time_signature_list[0].bar_length(self.options.ticks_per_beat) + ) + self.synchronizer = TimeSynchronizer(project.song_tempo_list) + master_track_events = [] + master_track_events.extend(self.generate_tempos(project.song_tempo_list)) + master_track_events.extend(self.generate_time_signatures(project.time_signature_list)) + if tracks := self.generate_tracks(project.track_list): + tracks[0]["events"].extend(master_track_events) + self._convert_cumulative_to_delta(tracks) + return { + "ticks_per_beat": self.options.ticks_per_beat, + "tracks": tracks, + } + + @staticmethod + def _convert_cumulative_to_delta(tracks: list[Container]) -> None: + for track in tracks: + tick = 0 + track["events"] = sorted(track["events"], key=operator.itemgetter("time")) + for event in track["events"]: + tick, event["time"] = event["time"], event["time"] - tick + + def generate_tempos(self, song_tempo_list: list[SongTempo]) -> list[Container]: + return [ + { + "tempo": tempo.bpm, + "time": round(tempo.position / self.tick_rate), + } + for tempo in song_tempo_list + if tempo.position >= 0 + ] + + def generate_time_signatures( + self, + time_signature_list: list[TimeSignature], + ) -> list[Container]: + events = [] + prev_ticks = 0 + prev_time_signature = None + for time_signature in time_signature_list: + if time_signature.bar_index >= 0: + if prev_time_signature is not None: + prev_ticks += round( + (time_signature.bar_index - prev_time_signature.bar_index) + * prev_time_signature.bar_length(self.options.ticks_per_beat) + ) + events.append( + { + "numerator": time_signature.numerator, + "denominator": time_signature.denominator, + "time": prev_ticks, + } + ) + prev_time_signature = time_signature + return events + + def generate_tracks(self, tracks: list[Track]) -> list[Container]: + return [ + mido_track + for track in tracks + if isinstance(track, SingingTrack) + and (mido_track := self.generate_track(track)) is not None + ] - def generate_project(self, project: Project) -> VxFile: - return VxFile() + def generate_track(self, track: SingingTrack) -> Optional[Container]: + events: list[Container] = [] + encoded_title = track.title.encode().decode("latin-1") + title_parts = [] + for is_first, is_last, i in more_itertools.mark_ends(range(0, len(encoded_title), 12)): + if len(encoded_title) <= 12: + title_parts.append( + {"name": encoded_title[i : i + 12].encode("latin-1").decode(), "seq_stat": 0x10} + ) + elif is_first: + title_parts.append( + {"name": encoded_title[i : i + 12].encode("latin-1").decode(), "seq_stat": 0x50} + ) + elif is_last: + title_parts.append( + {"name": encoded_title[i : i + 12].encode("latin-1").decode(), "seq_stat": 0xD0} + ) + else: + title_parts.append( + {"name": encoded_title[i : i + 12].encode("latin-1").decode(), "seq_stat": 0x90} + ) + for i, note in enumerate(track.note_list): + lyric = "l-aa" + if note.lyric.isascii(): + lyric = note.lyric + elif note.pronunciation and note.pronunciation.isascii(): + lyric = note.pronunciation + if lyric: + for is_first, is_last, offset in more_itertools.mark_ends( + range(-2, len(lyric), 12) + ): + if len(lyric) <= 10: + events.append( + { + "type": "lyrics", + "seq_num": i, + "seq_stat": 0x10, + "text": lyric[:10], + "time": 0, + } + ) + elif is_first: + events.append( + { + "type": "lyrics", + "seq_num": i, + "seq_stat": 0x50, + "text": lyric[:10], + "time": 0, + } + ) + elif is_last: + events.append( + { + "type": "lyrics", + "seq_num": None, + "seq_stat": 0xD0, + "text": lyric[offset:], + "time": 0, + } + ) + else: + events.append( + { + "type": "lyrics", + "seq_num": None, + "seq_stat": 0x90, + "text": lyric[offset : offset + 12], + "time": 0, + } + ) + events.extend( + [ + { + "type": "note_on", + "note": note.key_number, + "time": round(note.start_pos / self.tick_rate), + "velocity": 0x7F, + "on_data": i, + }, + { + "type": "note_off", + "note": note.key_number, + "time": round(note.end_pos / self.tick_rate), + "velocity": 0x00, + "off_data": 0x00, + }, + ] + ) + if events: + return {"title_parts": title_parts, "events": events} diff --git a/libresvip/plugins/vxf/vx_beta_parser.py b/libresvip/plugins/vxf/vx_beta_parser.py index b6f92aa55..9370aec23 100644 --- a/libresvip/plugins/vxf/vx_beta_parser.py +++ b/libresvip/plugins/vxf/vx_beta_parser.py @@ -7,10 +7,7 @@ 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 @@ -142,36 +139,51 @@ def parse_track(self, track: VxTrack) -> SingingTrack: buffer := "".join(event.text for event in track.events if event.type == "metadata") ): pitch_data = VxPitchData.model_validate_json(buffer) - rel_pitch_points = [] + pitch_points = [Point.start_point()] 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( + if prev_pos and point.position - prev_pos > 1 and len(pitch_points): + pitch_points.append( Point( - x=rel_pitch_points[-1].x, - y=0, + x=pitch_points[-1].x, + y=-100, ) ) - rel_pitch_points.append( + if pitch_points[-1].y == -100: + 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 + ) + ) + + self.first_bar_length, + y=-100, + ) + ) + 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), + ) + + self.first_bar_length, + y=int(point.pitch) + 6900, ) ) 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) + if len(pitch_points) > 1: + pitch_points.append( + Point( + x=pitch_points[-1].x, + y=-100, + ) + ) + pitch_points.append(Point.end_point()) + singing_track.edited_params.pitch.points.root = pitch_points return singing_track def parse_tracks(self, vx_tracks: list[VxTrack]) -> list[SingingTrack]: