From 17b4d9961d4377c0b918fd12f87489265a09b38f Mon Sep 17 00:00:00 2001 From: SoulMelody Date: Tue, 21 Jan 2025 19:46:43 +0800 Subject: [PATCH] tsmsln: init tsmsln support --- libresvip/plugins/ccs/cevio_pitch.py | 2 +- libresvip/plugins/tsmsln/__init__.py | 0 libresvip/plugins/tsmsln/constants.py | 8 + libresvip/plugins/tsmsln/model.py | 362 ++++++++++++++ libresvip/plugins/tsmsln/options.py | 17 + libresvip/plugins/tsmsln/tsmsln.yapsy-plugin | 12 + libresvip/plugins/tsmsln/value_tree.py | 136 +++++ .../tsmsln/voisona_mobile_converter.py | 23 + .../tsmsln/voisona_mobile_generator.py | 154 ++++++ .../plugins/tsmsln/voisona_mobile_parser.py | 178 +++++++ .../plugins/tsmsln/voisona_mobile_pitch.py | 472 ++++++++++++++++++ libresvip/plugins/tssln/voisona_converter.py | 6 +- libresvip/plugins/tssln/voisona_generator.py | 2 +- libresvip/plugins/tssln/voisona_pitch.py | 2 +- 14 files changed, 1368 insertions(+), 6 deletions(-) create mode 100644 libresvip/plugins/tsmsln/__init__.py create mode 100644 libresvip/plugins/tsmsln/constants.py create mode 100644 libresvip/plugins/tsmsln/model.py create mode 100644 libresvip/plugins/tsmsln/options.py create mode 100644 libresvip/plugins/tsmsln/tsmsln.yapsy-plugin create mode 100644 libresvip/plugins/tsmsln/value_tree.py create mode 100644 libresvip/plugins/tsmsln/voisona_mobile_converter.py create mode 100644 libresvip/plugins/tsmsln/voisona_mobile_generator.py create mode 100644 libresvip/plugins/tsmsln/voisona_mobile_parser.py create mode 100644 libresvip/plugins/tsmsln/voisona_mobile_pitch.py diff --git a/libresvip/plugins/ccs/cevio_pitch.py b/libresvip/plugins/ccs/cevio_pitch.py index a1132b54c..60e5ae867 100644 --- a/libresvip/plugins/ccs/cevio_pitch.py +++ b/libresvip/plugins/ccs/cevio_pitch.py @@ -321,7 +321,7 @@ def generate_for_cevio( repeat = end_tick - index if end_tick else 1 repeat = max(repeat, 1) value = math.log(midi2hz(this_point.y / 100)) if this_point.y != -100 else None - if value is not None: + if value is not None and (next_point is None or next_point.y != -100): events_with_full_params.append( CeVIOParamEventFloat(float(index), float(repeat), float(value)) ) diff --git a/libresvip/plugins/tsmsln/__init__.py b/libresvip/plugins/tsmsln/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libresvip/plugins/tsmsln/constants.py b/libresvip/plugins/tsmsln/constants.py new file mode 100644 index 000000000..89ad83466 --- /dev/null +++ b/libresvip/plugins/tsmsln/constants.py @@ -0,0 +1,8 @@ +from typing import Final + +DEFAULT_PHONEME: Final[str] = "r,a" +TIME_UNIT_AS_TICKS_PER_BPM: Final[float] = 4.8 / 120 +OCTAVE_OFFSET: Final[int] = -1 +TICK_RATE: Final[float] = 2.0 +MIN_DATA_LENGTH: Final[int] = 500 +TEMP_VALUE_AS_NULL: Final[float] = -1.0 diff --git a/libresvip/plugins/tsmsln/model.py b/libresvip/plugins/tsmsln/model.py new file mode 100644 index 000000000..4db880505 --- /dev/null +++ b/libresvip/plugins/tsmsln/model.py @@ -0,0 +1,362 @@ +# mypy: disable-error-code="attr-defined" +from __future__ import annotations + +import enum +from types import GenericAlias +from typing import Any, Literal, Optional, get_args + +from pydantic import AliasChoices, Field + +from libresvip.model.base import BaseModel + +from .value_tree import JUCENode, JUCEVarTypes + + +class VoiSonaMobilePlayControlItem(BaseModel): + loop: bool = Field(alias="Loop") + loop_start: float = Field(0, alias="LoopStart") + loop_end: float = Field(alias="LoopEnd") + play_position: float = Field(alias="PlayPosition") + + +class VoiSonaMobileSongEditorItem(BaseModel): + editor_width: int = Field(1920, alias="EditorWidth") + editor_height: int = Field(1080, alias="EditorHeight") + + +class VoiSonaMobileControlPanelStatus(BaseModel): + quantization: int = Field(alias="Quantization") + edit_tool: Optional[int] = Field(None, alias="EditTool") + record_note: bool = Field(True, alias="RecordNote") + record_tempo: bool = Field(True, alias="RecordTempo") + + +class VoiSonaMobileAdjustToolBarStatus(BaseModel): + main_panel: int = Field(alias="MainPanel") + sub_panel: int = Field(alias="SubPanel") + + +class VoiSonaMobilePanelControllerStatus(BaseModel): + tempo_panel: Optional[bool] = Field(None, alias="TempoPanel") + beat_panel: Optional[bool] = Field(None, alias="BeatPanel") + key_panel: Optional[bool] = Field(None, alias="KeyPanel") + dynamics_panel: Optional[bool] = Field(None, alias="DynamicsPanel") + global_param_panel: Optional[bool] = Field(None, alias="GlobalParamPanel") + lyric_panel: Optional[bool] = Field(None, alias="LyricPanel") + property_panel: Optional[bool] = Field(None, alias="PropertyPanel") + + +class VoiSonaMobileMainPanelStatus(VoiSonaMobilePanelControllerStatus): + scale_x: float = Field(alias="ScaleX_V2", validation_alias=AliasChoices("ScaleX_V2", "ScaleX")) + scale_y: float = Field(alias="ScaleY_V2", validation_alias=AliasChoices("ScaleY_V2", "ScaleY")) + scroll_x: Optional[float] = Field(None, alias="ScrollX") + scroll_y: Optional[float] = Field(None, alias="ScrollY") + + +class VoiSonaMobileNeuralVocoderInformation(BaseModel): + nv_file_name: str = Field(alias="NVFileName") + nv_lib_file_name: Optional[str] = Field(None, alias="NVLibFileName") + nv_version: str = Field(alias="NVVersion") + nv_hash: Optional[str] = Field(None, alias="NVHash") + nv_lib_hash: Optional[str] = Field(None, alias="NVLibHash") + nv_label: str = Field(alias="NVLabel") + + +class VoiSonaMobileNeuralVocoderListItem(BaseModel): + neural_vocoder_information: Optional[list[VoiSonaMobileNeuralVocoderInformation]] = Field( + None, alias="NeuralVocoderInformation" + ) + + +class VoiSonaMobileEmotionItem(BaseModel): + label: str = Field(alias="Label") + ratio: float = Field(alias="Ratio") + + +class VoiSonaMobileEmotionListItem(BaseModel): + emotion: list[VoiSonaMobileEmotionItem] = Field(default_factory=list, alias="Emotion") + + +class VoiSonaMobileSpecialSymbol(BaseModel): + special_symbol_raspy: list[str] = Field(default_factory=list, alias="SpecialSymbolRaspy") + special_symbol_falsetto: list[str] = Field(default_factory=list, alias="SpecialSymbolFalsetto") + + +class VoiSonaMobileVoiceInformation(BaseModel): + neural_vocoder_list: list[VoiSonaMobileNeuralVocoderListItem] = Field( + default_factory=list, alias="NeuralVocoderList" + ) + emotion_list: list[VoiSonaMobileEmotionListItem] = Field( + default_factory=list, alias="EmotionList" + ) + character_name: str = Field(alias="CharacterName") + language: str = Field("ja_JP", alias="Language") + active_after_this_version: Optional[str] = Field(None, alias="ActiveAfterThisVersion") + voice_file_name: str = Field(alias="VoiceFileName") + voice_lib_file_name: Optional[str] = Field(None, alias="VoiceLibFileName") + voice_version: str = Field(alias="VoiceVersion") + voice_hash: Optional[str] = Field(None, alias="VoiceHash") + voice_lib_hash: Optional[str] = Field(None, alias="VoiceLibHash") + special_symbol: list[VoiSonaMobileSpecialSymbol] = Field( + default_factory=list, alias="SpecialSymbol" + ) + + +class VoiSonaMobileGlobalParameter(BaseModel): + global_vib_amp: float = Field(1, alias="GlobalVibAmp") + global_vib_frq: float = Field(0, alias="GlobalVibFrq") + global_alpha: float = Field(0, alias="GlobalAlpha") + global_husky: float = Field(0, alias="GlobalHusky") + global_tune: Optional[float] = Field(None, alias="GlobalTune") + + +class VoiSonaMobileSoundItem(BaseModel): + clock: int = Field(alias="Clock") + tempo: float = Field(alias="Tempo") + + +class VoiSonaMobileTempoItem(BaseModel): + sound: list[VoiSonaMobileSoundItem] = Field(default_factory=list, alias="Sound") + + +class VoiSonaMobileTimeItem(BaseModel): + clock: int = Field(alias="Clock") + beats: int = Field(alias="Beats") + beat_type: int = Field(alias="BeatType") + + +class VoiSonaMobileBeatItem(BaseModel): + time: list[VoiSonaMobileTimeItem] = Field(default_factory=list, alias="Time") + + +class VoiSonaMobileKeyItem(BaseModel): + clock: int = Field(alias="Clock") + fifths: int = Field(alias="Fifths") + mode: int = Field(alias="Mode") + + +class VoiSonaMobileDynamic(BaseModel): + clock: int = Field(alias="Clock") + value: int = Field(alias="Value") + + +class VoiSonaMobileNoteItem(BaseModel): + clock: int = Field(alias="Clock") + duration: int = Field(alias="Duration") + pitch_step: int = Field(alias="PitchStep") + pitch_octave: int = Field(alias="PitchOctave") + lyric: str = Field(alias="Lyric") + syllabic: int = Field(alias="Syllabic") + phoneme: str = Field(alias="Phoneme") + do_re_mi: Optional[bool] = Field(None, alias="DoReMi") + accent: Optional[bool] = Field(None, alias="Accent") + breath: Optional[bool] = Field(None, alias="Breath") + staccato: Optional[bool] = Field(None, alias="Staccato") + slur_start: Optional[bool] = Field(None, alias="SlurStart") + slur_stop: Optional[bool] = Field(None, alias="SlurStop") + default_phoneme: Optional[str] = Field(None, alias="DefaultPhoneme") + past_analyzed_phoneme: Optional[str] = Field(None, alias="PastAnalyzedPhoneme") + special_symbol_raspy: Optional[bool] = Field(None, alias="SpecialSymbolRaspy") + special_symbol_falsetto: Optional[bool] = Field(None, alias="SpecialSymbolFalsetto") + + +class VoiSonaMobileScoreItem(BaseModel): + key: list[VoiSonaMobileKeyItem] = Field(default_factory=list, alias="Key") + dynamics: list[VoiSonaMobileDynamic] = Field(default_factory=list, alias="Dynamics") + note: list[VoiSonaMobileNoteItem] = Field(default_factory=list, alias="Note") + + +class VoiSonaMobileSongItem(BaseModel): + tempo: list[VoiSonaMobileTempoItem] = Field(default_factory=list, alias="Tempo") + beat: list[VoiSonaMobileBeatItem] = Field(default_factory=list, alias="Beat") + score: list[VoiSonaMobileScoreItem] = Field(default_factory=list, alias="Score") + + +class VoiSonaMobilePointData(BaseModel): + index: Optional[int] = Field(None, alias="Index") + repeat: Optional[int] = Field(None, alias="Repeat") + value: float = Field(alias="Value") + + +class VoiSonaMobileParameterItem(BaseModel): + length: int = Field(alias="Length") + data: list[VoiSonaMobilePointData] = Field(default_factory=list, alias="Data") + + +class VoiSonaMobileParametersItem(BaseModel): + timing: Optional[list[VoiSonaMobileParameterItem]] = Field(None, alias="Timing") + c0: Optional[list[VoiSonaMobileParameterItem]] = Field(None, alias="C0") + c0_c_tick: Optional[list[VoiSonaMobileParameterItem]] = Field(None, alias="C0CTick") + log_f0: Optional[list[VoiSonaMobileParameterItem]] = Field(None, alias="LogF0") + log_f0_c_tick: Optional[list[VoiSonaMobileParameterItem]] = Field(None, alias="LogF0CTick") + vocoder_log_f0: Optional[float] = Field(None, alias="VocoderLogF0") + vib_amp: Optional[list[VoiSonaMobileParameterItem]] = Field(None, alias="VibAmp") + vib_amp_c_tick: Optional[list[VoiSonaMobileParameterItem]] = Field(None, alias="VibAmpCTick") + vib_frq: Optional[list[VoiSonaMobileParameterItem]] = Field(None, alias="VibFrq") + vib_frq_c_tick: Optional[list[VoiSonaMobileParameterItem]] = Field(None, alias="VibFrqCTick") + alpha: Optional[list[VoiSonaMobileParameterItem]] = Field(None, alias="Alpha") + alpha_c_tick: Optional[list[VoiSonaMobileParameterItem]] = Field(None, alias="AlphaCTick") + husky: Optional[list[VoiSonaMobileParameterItem]] = Field(None, alias="Husky") + husky_c_tick: Optional[list[VoiSonaMobileParameterItem]] = Field(None, alias="HuskyCTick") + + +class VoiSonaMobileSignerConfig(BaseModel): + snap_shot: Optional[str] = Field(None, alias="SnapShot") + + +class VoiSonaMobileStateInformation(BaseModel): + song_editor: list[VoiSonaMobileSongEditorItem] = Field(default_factory=list, alias="SongEditor") + control_panel_status: list[VoiSonaMobileControlPanelStatus] = Field( + default_factory=list, alias="ControlPanelStatus" + ) + adjust_tool_bar_status: list[VoiSonaMobileAdjustToolBarStatus] = Field( + default_factory=list, alias="AdjustToolBarStatus" + ) + main_panel_status: list[VoiSonaMobileMainPanelStatus] = Field( + default_factory=list, alias="MainPanelStatus" + ) + panel_controller_status: Optional[list[VoiSonaMobilePanelControllerStatus]] = Field( + None, alias="PanelControllerStatus" + ) + voice_information: Optional[list[VoiSonaMobileVoiceInformation]] = Field( + None, alias="VoiceInformation" + ) + global_parameters: Optional[list[VoiSonaMobileGlobalParameter]] = Field( + None, alias="GlobalParameters" + ) + song: Optional[list[VoiSonaMobileSongItem]] = Field(None, alias="Song") + parameter: Optional[list[VoiSonaMobileParametersItem]] = Field(None, alias="Parameter") + tempo_sync: Optional[bool] = Field(False, alias="TempoSync") + signer_config: Optional[list[VoiSonaMobileSignerConfig]] = Field(None, alias="SignerConfig") + version_of_app_file_saved: Optional[str] = Field("1.12.1.0", alias="VersionOfAppFileSaved") + + +class VoiSonaPluginData(BaseModel): + state_information: VoiSonaMobileStateInformation = Field( + default_factory=VoiSonaMobileStateInformation, alias="StateInformation" + ) + + +class VoiSonaMobileTrackType(enum.IntEnum): + SINGING = 0 + + +class VoiSonaMobileBaseTrackItem(BaseModel): + name: str = Field(alias="Name") + state: int = Field(0, alias="State") + volume: float = Field(0, alias="Volume") + pan: float = Field(0, alias="Pan") + + +class VoiSonaMobileSingingTrackItem(VoiSonaMobileBaseTrackItem): + track_type: Literal[VoiSonaMobileTrackType.SINGING] = Field( + VoiSonaMobileTrackType.SINGING, alias="Type" + ) + plugin_data: VoiSonaPluginData = Field(default_factory=VoiSonaPluginData, alias="PluginData") + + +class VoiSonaMobileTrack(BaseModel): + track: list[VoiSonaMobileSingingTrackItem] = Field(default_factory=list, alias="Track") + + +class VoiSonaMobileSingerData(BaseModel): + state_information: VoiSonaMobileStateInformation = Field( + default_factory=VoiSonaMobileStateInformation, alias="StateInformation" + ) + + +class VoiSonaMobileSinger(BaseModel): + mobile_singer_data: VoiSonaMobileSingerData = Field( + default_factory=VoiSonaMobileSingerData, alias="ModileSingerData" + ) + + +class VoiSonaMobileAudio(BaseModel): + mobile_offset: int = Field(0, alias="ModileOffset") + mobile_volume: float = Field(0, alias="ModileVolume") + mobile_audio_data: str = Field(alias="ModileAudioData") + + +class VoiSonaMobileProject(BaseModel): + tracks: list[VoiSonaMobileTrack] = Field(default_factory=list, alias="Tracks") + version_of_app_file_saved: Optional[str] = Field("1.12.1.0", alias="VersionOfAppFileSaved") + mobile_singer: list[VoiSonaMobileSinger] = Field(default_factory=list, alias="MobileSinger") + mobile_audio: list[VoiSonaMobileAudio] = Field(default_factory=list, alias="MobileAudio") + + +def value_to_dict(field_value: Any, field_type: type) -> dict[str, Any]: + if issubclass(field_type, bool): + variant_type = JUCEVarTypes.BOOL_TRUE if field_value is True else JUCEVarTypes.BOOL_FALSE + elif issubclass(field_type, (enum.IntEnum, int)): + variant_type = JUCEVarTypes.INT + elif issubclass(field_type, float): + variant_type = JUCEVarTypes.DOUBLE + elif issubclass(field_type, str): + variant_type = JUCEVarTypes.STRING + elif issubclass(field_type, bytes): + variant_type = JUCEVarTypes.BINARY + elif issubclass(field_type, list): + variant_type = JUCEVarTypes.ARRAY + field_value = [value_to_dict(item, type(item)) for item in field_value] + else: + variant_type = None + msg = f"Unknown field type {field_type}" + raise TypeError(msg) + return { + "type": variant_type, + "value": field_value, + } + + +def model_to_value_tree(model: BaseModel, name: str = "MobileSongEditor") -> dict[str, Any]: + model_class = type(model) + value_tree = { + "name": name, + "attrs": [], + "children": [], + } + for field_name, field_info in model_class.model_fields.items(): + field_value = getattr(model, field_name) + alias_field_name = field_info.alias or field_name + if field_value is not None: + if isinstance(field_info.annotation, type): + field_type = field_info.annotation + elif field_info.default is None or field_info.default_factory is list: + field_type = field_info.annotation + while not isinstance(field_type, (type, GenericAlias)): + field_type = get_args(field_type)[0] + else: + field_type = type(field_info.default) + if isinstance(field_type, GenericAlias): + inner_type = get_args(field_type)[0] + if not isinstance(inner_type, type) or issubclass(inner_type, BaseModel): + value_tree["children"].extend( + model_to_value_tree(item, alias_field_name) for item in field_value + ) + else: + value_tree["attrs"].extend( + {"name": alias_field_name, "data": value_to_dict(item, inner_type)} + for item in field_value + ) + elif alias_field_name == "ModileSingerData": + value_tree["attrs"].append( + { + "name": alias_field_name, + "data": { + "type": JUCEVarTypes.BINARY, + "value": JUCENode.build( + model_to_value_tree( + field_value.state_information, + "StateInformation", + ) + ), + }, + } + ) + elif issubclass(field_type, BaseModel): + value_tree["children"].append(model_to_value_tree(field_value, alias_field_name)) + else: + value_tree["attrs"].append( + {"name": alias_field_name, "data": value_to_dict(field_value, field_type)} + ) + return value_tree diff --git a/libresvip/plugins/tsmsln/options.py b/libresvip/plugins/tsmsln/options.py new file mode 100644 index 000000000..ea52d5ec5 --- /dev/null +++ b/libresvip/plugins/tsmsln/options.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + +from libresvip.model.option_mixins import ( + EnablePitchImportationMixin, + SelectSingleTrackMixin, +) + + +class InputOptions( + EnablePitchImportationMixin, + BaseModel, +): + pass + + +class OutputOptions(SelectSingleTrackMixin, BaseModel): + pass diff --git a/libresvip/plugins/tsmsln/tsmsln.yapsy-plugin b/libresvip/plugins/tsmsln/tsmsln.yapsy-plugin new file mode 100644 index 000000000..d596f2cfc --- /dev/null +++ b/libresvip/plugins/tsmsln/tsmsln.yapsy-plugin @@ -0,0 +1,12 @@ +[Core] +name = TSMSln +module = voisona_mobile_converter + +[Documentation] +Author = SoulMelody +Version = 1.12.1 +Website = https://space.bilibili.com/175862486 +Format = Techno-Speech Mobile Solution file +Description = Conversion plugin for VoiSona Mobile project file +Suffix = tsmsln +IconBase64 = iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAABHNCSVQICAgIfAhkiAAAIABJREFUeJztnXucnVV573/P2nNJMvdLMgSSUNt4w0uPQrXeqvTU09aeU1vB9BQrFwWTSCAQyD0wBJIIKLTW2lM+oqcFsaexiscC1SMavIAgAVEJauSaEHLPzJ7JdWb2+p0/9ntZ723Pnpk99+f7+SR77/Wud71rzd7Ps57LetcLKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKEnITkN2mvHuh6IoYwzZaQ7tft3aQ7tfd7UqAUWZRpCd5tDLr11/5OXXdx/e/foDh19+3QpVAspQyI13B5ThQX441737wWsNZA1EmkRQB8rZJ48+ffzs3z/3ia9+9RmOdx+ViY+MdweUobN//1n1NX0Dl5JYI4IOAIAYEAKCeyx5U5+pv/uMM544Ps5dVSY4ai5OMnhoYWP1qcIlsLhG4Ak/ANBCWIAAZxhg7Qwcu2Dv3jfXjWNXlUmAKoBJBPecPevw0dzFIFdCMK9YGK0jtDCCM8Vi/QzbdwG5sHbse6pMFlQBTBLITtM9kF9swFUA5tMVfCKqCEgI7G+xYK/Lv2yuJs+qGdveKpMFjQFMAshO0/XiPasArhAxs+l/bf5L/Ft0PlugG7D/cPgENr3mNc+eGpMOK5MGtQAmOGSn6Xr+y2sFWCOQ2d7sDgG9WV9AAgmLwMMAzQKzrG2muU4tASWOWgATGLLT5F+4ZyUFawE0RQ5K+IYw3uei5EcsAvHbQo8Fbt/XU/WpN77xmb7R7bkyWVAFMEHh3jfXHT5+/BMCrDGCOQCS35bzmTDFAklRAl5dknspuKF1Hv9FRN0BRV2ACQkPLWzMnzj2MUN7jQjmBDG+RLAv/Fx0C2xQxvgyIAICmWuIy/N75D3kh3URmKIKYKLBvW+uy/fwItKuFMEZQgvxpHlwRRCNDyRiAwBIeRMtLj2++6nTxmA4ygRHFcAEguw03b29n7AFrgbN/Ihg03oSPQRF4Ne3EigCEQiAD5yy8ofc9t6qsRudMhFRBTBBIDtN/jd3ryTMGoGcURRmQdF5h2fC04n+l6sImGIRSANhL8VZB2eM2QCVCYkqgAkA2Wm6dn55LWHWCjAnKcwSvvdmd98aCKpE3iBSH55bIE7aUER+f9+x/jeM5riUiY8qgHGG7DT5X9+9ypArBWwqhvYlscQXFO8fHGsgqQiSF0BEEYDWUyimJic8f7TGpUwO1AccR7jn7Fk9v75rMYEVItJUXORDQIriXZyq4ydJscw7Jp6EkyY4DP8099wgBkAABZCCKphzR3mIygRH1wGME9y5sLGr315sDFZB5Aw/fx+pI541gBRFABRz/pFyT3FEVwpnnAvQ4mTL8Zom0YVB0xZ1AcaB/U+fVZ8v8CJjcC3cgB+jkiq+ReAEAiNQAOu6C65bQLc45VxADGYcq7dtFR2cMqlQBTDGcOuHczPMyUtBuwqU+eEB/19MEbCYAgwMBJYbHyCEDEIEWUHCvoKprvgglUmDKoAxhOw0vW94/BpLuwbAvCCpH6nkv8YtgqJA+4uC0lN/brbAO8c7r+T6AWXaokHAMYLsNPmn71oLwcpiwA/eun2G+X5f3n3hFIRBv1AzQHxfv9hwWBdwlIZfzrApwgswhk0ePdFztMJDVSYRGgQcA8hOk//FXathuBqQ8K4+P8YXL8i66SeiCBBmC+CUJ+4EjJbTvQbZ0/LbzzV7OkGZhqgFMMrs2T53Vvcv7loiwqtBaUpLzQFwhJWAl9KLyLb49cM7/hJpQ68sqhOctCH8NCDA4lqAR1X4pzcaAxhFDu1c2FhfVXspwGtAmQ0gY7VevNwiER9wj2cECoukBQmRuprQFgoPDHdsytRAFcAowafPqq86PnARBdcKcHpiuV6mcCMQUvgpPWTVjacNvTsB4/Xg1PeyBSSOVBuoApjmqAIYBbjtvVXd/ccuBbAKQHhXn/VNfL8i0oU/Uk6A0SW/0bpp1gDDNUIZaUOh/EdjTd/LFRu0MilRBVBhyE7T3fjSCoGsBjAv3Rwnogl6IDFjp1oEGWnD1IVEDGMEkLgieGWgwLsx/2XdFWiao0HACsKdC2vzT/7ztSJyLYDmSPAusaYfYTDP99vjdSPpQK9AUtKGbt2gPD1QSMsCRf45V83tIrBQpjWqACrEzp0La/O9fZsJLBawPsjVx2fsuNAGN/aUqBtXBFnrB9y6brYAcKwBPGgh97T9zvP54Y1UmUqoAqgAB3/52oaqnpOrILxMROoBgCwKqYijCIKZHEnhDvL1GdZApK7fhj/LpygBfxGRV4+AFfIp0NzS/vpnnxnRgJUpgyqAEZJ/7PS2wrETnyTkkwJpjAstSU+mJcWkR1QROPl6Z+VOhvuA0taA611YsSLYkcuZm+sXPvv9EQ9amTLozrAjoPuRea2oqloKYrkRaSsKoCOtEcH1w/Ipx7NW/sU/ZK3bDModE8HphwBPSs5e1/Dq57+pfr/iohbAMNmzfe4sO4ArDO0yEdOeWK0HIDTr/Y/ebO3f55+2Bs+1DgAv6IdkjMC9hGslBG5BsZIAPzGC1Q2veeGh4Y1UmcqoBTAMdu5cWNt8Ap05yOUi0ibwJ9z4Ov4SFkHcGnA/lrIIMi2MZPskHxGRFY1nPfejkgNSpi1qAQwR7lxYmz/StwXAJyioD61vP8puUoyA6Hr88L1jDQR1EdYBouVZGYPUbAEfNsKVjf/2wmPDHqwy5cnyKpUUdu5cWNt+pG+LEItFUAcg+AuGE7Nnj/t32SRm6Zhb4BdH7uxDWCftG/JNjniAMHz/mBh7dePrX3pMfX6lFKoAymT/ttn1M2pr11DkCog0Rg7GLH03pefu0Rc9J6kIim2YSCofae8jnyV4IWGN4ZNicG3jWS9qtF8ZFHUByuDl75zeVltjPgmRpQJpZEp+nuHEHx50luIy/rTO2O6+XnUANhkkzFoT4K/484QfxJPsx/qmt6jwK+WhFsAgdD8yrxVWlkJwlQja3Zk33cT3XlL+spQS1kBqecwHKBEkJLFdDNY3v+nF/1dqPIriojcDlWDvt99cB4tlAK4SoB1A5OYciXz28Nfl+FaBcyDY0y/l7rzo7r5+OQHa8AKM/fOrWT5eZblKhV8ZKuoCZLDzgYW1NTO71wvNYhi0pt6JB4YzPWMWASMvEL8igWJcLiU+4N+xF48PRPb9iyoKAo9Yy5UNb931yLAHq0xb1AVIgQ8srM03nNgiNJdRpCFq1se0QMRKL74ZkmswxGxBJOJIPCzkysa3vqRbeynDQhcCxdj5wMLaqroTW0BZLKaY55fITC2p/r13yK+RKIu/d9OGxdCAxOpk+P/eOQQfM8Q1jW996TEVfmW4qAXgsPfbHXUzanPrjZhlEDQAiAhk3GQX93bb1ABduYHCWNowfo5reQgsiJ8a2BWNZ7/8g/JHpyhJNAbg0f3IvFb0YZmAiwPhBxwnvpjSo3PXHSmhW5B6L78fKJTEZj2RpgOT3t+8wyCSagxXExZo+VMxXNf4VhV+ZeSoC4Binr8GXErIcoGEz8pLsY+imbxw2i/bLcioF64fKCb9JeUcEk9WC9c3nr37O4MOSlHKYNorgP3bZtfPMnIlKctF0C6pUp/8HHrovkbwRDZr9Z449dPahOsS+Ct/3HgftkOwuumc3Q+WOTRFGZRp7QLsfGBh7QwcWwOLpSJoDdNtEm7B554QS/EFaUAGxn7wKv4qvxji7OITTxu6Zr+bNgT5iBFZ2fR7mupTKsu0DQLygYW1+ZpjW0RwaWRtvzsLu8/Ry5rZY+eUFSgM3jvh+8w2+bDArGx8m6b6lMozLV2AnQ8srK2qObYFkMUiEov2ezhBPIpE9AJKvE/67inxgVg6MDM+QDwqYq5tfJum+pTRYdpZAHu+OXfWrJm8TmCWiaA+OJCaxnPLi7N7QhEMZg14ZYMvIvLP8Xb4EzxpwKsbf3/3D8sdm6IMlWkVA+j+1rxWi4ErDLEYrvADsY02Y+XeG39zzzRZTsQHxLkT0Pf3IVFF4G7nFZRLwRA/Jbiu8R0q/MroMm1cgEMPtDaKwSeMyNUi0hb19VNOyErXef+VZQ0EzXu2gIQVs9KGAj5JckPzO1/WG3uUUWdaWADcilwPqt8LyuUwEt7VByCy6WZwAmIzs1MezOXwFvdI5PkbwUH3HD+e4GcLvEVEgJsVAAA+YWHXtLzrle+OYLiKUjbTQgFg9pkN9sSpKwxkQWSTDSBi4gcHEsfSznE28PKzBYO5BWBR4BnGB4JWaH8sxl7b8q5XNNWnjBnTYj+A7pOn/lAEfwAg85569z7/SP7erZe4Xx/BPfvinUP3nPg1nAoC71HexTYeFsld06TCr4wx00IBiJWPCFCbEOZSiiC+o0cZQg0WhdrVJUBG/fCcR0XMysZ3vaS79ypjzpRXAMUnc/GPI1OzK9txhYDY5/gOPmXUF9ogocAMxUGCsHjCiKxsevdLP9bde5XxYMorgJ4HT381gLqoEHoLfBgTUACps/VgbkGqgii6BZIoB0gUBHyCxqxpfM9ufWiHMm5MeQVQOMmORJzPUQKRJfulTPwsayBNcURNfG+6D8uFeAXE9c3v2aU39ijjypRXAD4Rv9wVaD8OH3cLMhVBzBpItJl+UfE29yQAS/aR7OfW6bMOQ5mYTHkFYIWH4G+syxRFYOEoAic+MFjQzzLbIigZHyBEcCZgbsx3zPsjVQLKeDLlFUCbqX8eZHeQo2f4EnENYvGBshVBPD6QaC9eTghZBdhzYNnZfdrp79uqSkAZJ6a8ApAPPHuK5MNFwUNCOCMWAdwPKYoASAp2VnwgcixZLkQ1yLeZAdz8X1tOf1tn59T/LpSJx7T40Vmxdxfl2nmYh//qKILkbB8qgtRsQSL6n+EWpNUHIEAOgnNyOd55zfvmvmZEg1SUYTAtFIAp9H8b4GPFidqL/qcIc8QlcMr9+EAiSOjWjdWPKIKs+t5nETmLFl/t2Tb3dWoJKGPJtPixNffne1nAzbTYBxSVADIUQSJI6LyPBAndOlluQTw+UKq+yButxdeufPect2tMQBkrpoUCkEUoSPWM70NwB4kjwWzvKIK4cCYChP77eGwA8eMOWfGBDEUgImdVmdzfv799zvs0O6CMBdNCAQBA83/f1VUYqLpTgH8B0eVa6fTTc0AkUBgcB2JC6ysOJoXZeZZngHPOYBYBIW8R5G7Kt8/VFKEy6kwbBQAA7eftebna9N8G8m4AvQCiM36aa+AECVNdA6fC0FYUpisCAXIgzgHRmZ99+rnqDiijybTbExAA8l8/vc2ysAk5fFyA6viOPvH7/IPCtL07I5uG+Pf4Rzb/STnJ/5yyG1G4G/gAwacMZHnjD155VDbqzUJK5ZmWCgAAuA1V+SMdd8LgooiQujv2Bm9ie/iXoQh8xSGSPC/CYIpAuGPAclH7H+17poxhKcqQmFYugIuci4Gmwv6Pg7gTxFEAKf546Bok1g8gxcp3/ITIPp9Z0X+E9bNWEwrlDVWUrT3b5r7OvblQUSrBtFUAQDE70FRllgtwOy0PpaX+/Lf+Ov70wB2SqwUTDaS3HfmcsZpQjLyBBfx7z7aOt5HT+ztTKovOKAC6t85rZXX/lQIsBnAagOSGoJFnfaQ89jsRP0CiIDU2kPlekuXkE0bsqvrD+78vi1Aob3SKko0qAI/er3XMKYgsBbgUgo7gQEYwz93mu2R8ICHcKQGEkoHCyHUKIB8HcUNT994HVQkoI0XNSY+G8/YfMDPwj0L8k4BHggMZ5ru/fqDUQqLstCFTyjGoayBAToCzYdCZb5z7h5oiVEaKWgAxjmxtacpV1VxjwZVGZEbEJS9lDQCZrkEiWxC8xrIFadeIn1c8Z0DInxYEK1p+tPcRTREqw0UtgBiti7ry3x7Yv9FAbmc48xZJW+gTme0Hub8g0UZxNeGQtiUDIWAVRc4xwP868s6OsyoycGVaohZACbr+vWOzGLsckLrEs7ziPnyKNQA48YHU9QXua8oiopRzo+UCWu4w1Ti/4X17f61PEFaGiloAJdhj2zbSmttpeXjQDUFTrIHU+ABi1kAsWDDU/QlF5A12AP+ef/C0czRFqAwVtQAGofueBS2ccWI5iCVipJgdSHuyZzmrCYeTNoy3mbmakE9ay5XNeU0RKuWjCqAMer/WMadALiW4VMRXAhhcETivgy0rDt5mpA0D1yBj/UCQIhTRFKFSNmoylkHDefsP5ET+lxH8E4liirDEyr009yASKCyxojArbegHCrP2JxQgJyJng+zMN8/9o23bpsmDX5URoRbAEOi6t6kZ/TNWIGevFcjM4IA3U4vE9EGJ1N5IVhSWWkREYkCAXwJc3Viz7ztyLgbKHqAy7VAFMES4DVU9Bzs2Msd1kQO+4LrR/9ixVEUwzNWEqdkC75UERfCSEH/T8N/2PqLZASULVQDDJL+1Y7M19moRxxIAyrcGvPeJ2EBK3UEDhRltA9zNAZzX9Kf7ntCHjyppaAxgmDTO3t8Jaz5DW9xjMMDz25mVNowvEvKrOduSxZcIp6YNg0VBsdhA5FyZL1X4Wve3O95LjQlMKsgP51544cwZo53aVQtgBHTd29RsBmqussCSSHbAZzC/3X3vzvZDcQviBWmLiMhnAa5ozO//tixCX1mDm0Ds3za7Pldb22SFs7LqVBvWQaoH/T0bIzUFFGoAoKq6qmSAhAXbkH7EuwXDU6lCW22qpCarWrRNFNssIdYF5GBymCHG1gnl3uZXP/dsiW6OCFUAI6T3ax1zCgUupXCpQDpKr+UvP1CY6RpkrShMCxj4gUGBBfGMAW9o6Gn9D1n0zIRXAtw+d1ZPv3kTmfs9Cl8DwWwh6gGk/2oNGgGR1NSsCf/iBGag+M+rKqW+jyYkjnnKnJG6teK1ma7oPWkXAcRpM3Ithh+C34iABX6w9fXPfTNlxBVBFUAF6P1ax5wBaz8JYpmItJVWAt6brGOZQj6ybckIFET4tAhu3NW97743TlBLgITp+vG8NxjBxwD8ASi/TUiTCCTrb5aQ+bTsCgD4D4hM/Ool2chg36GvyQetl1KQWTcWqxWBLWBUFYDGACpAw3n7D0jfzM8C5h8BHkv194GI7x4sE3aP+a/O1uKR9QNwfiPx9QVu5fCmoSBGIECOlDdaKzfOb+h4/0SNCXQ9PP9/GMqXhfIJobxVBM1GKMEkmYi3ODGQoNwWx56oL96/WDkIMLafe8pai8hnMlm/ZMzH+ZC2dsTtX1BOYJTXc6kCqBDNH9nVtQdtm0jz2aAwTQk45UxTAvEfXmSRkKc4EsG+2O8sVuAITw7EWYT5XO/Jue/gBHsM2ZHvz/9gTvhlAG8CUfT3He0miAVJ/feOIigi3t+qqAiSwumZSolyRh2ClL9z5Jh/0fhj4FLrwfmCLBKLyCLXipmBo4i6AKNA99bZVwC4GSJh0GqosQH/WIpP6RdRYgVDChTyRVSbP2n6r6/8etABjTI7H1hYO6fu1CU0+HsRqZa4lKf+7WL3VsTqhha9b/abDLfAreMUS6BuonWzrikIZ++y3QLvQ4kl5ZbqAkw6mnYc/LyFWSPg/qCwlEmJlLShWzdjtk9NG7rNZJihxUlLfot99kv575zeNpKxjhQ+urCxvb5vMYSfEkg1/Ak1Ygo7r65FUNK68gv82d4W/16p9SV87xe7FkSs7dRrWu+NIKO/sfPdL6nUdz/KqAIYBWQjbD/s/y6QnwK5OziQZVK6AloqNpDhFgSxAadu/DfmtiHwTV15py0Ulo/XduPd35rX2tN/6hKDwmoRNAsckx1FJUBXOEu7N1EiSgAIJNO9FyNSPz0+kFAEWcIdlKUcSFX60XGkdHhMUAUwSnQsOnjU9PffRcvbQe5KfVy4BdJ+2EzzKzP+0akvbnzAOscz2hAQYnFxz31nLBzlP0eCrm1nNrNWLqTlCoGcHhXq6A1TqYog+MdAoSWDfgB9d5tAmBcFhBZiSymCqE4MvhNXu6ZZBE6/IvGBeN2UcThfaLJfo4QqgFGk+SP5rl7Td5eAt5PYW9YMH3kf/hjiLm28fnhK7KEk0Wai5xYLZiPX/2cVGG7Z7P12Rx1pLzLCFQIsSApQsXOBpeJPvnGnv1yLICZbRYptSaZFAMDGAzNhIxGLoJQicDvK6MdEXVcR+IHCUb6pWxXAKLNgUc+R/lP2fxO8GRb5cIZ3KsWFP1HOaMagxEziK4FU1wApv0tKjdCcW4mxlgMJqa2p/pgI1wOYn6rQ/M9wFEGaNRA/L1ACKdkC53PC0o7foh055rgGTkN09nN0285WBBnWQ0r/ol+QpgEnPe0fPdLzihz6JytYDyAyu6emATNnsBRrIP6jiVkDkW3JUtYXCGAsOb/7vgUtlRpvKfIPnb4kh8ItBnY2wIhsRMYRKS923k3pMZ4qS/zdst2CbGsgGoOInJOSNiQJMm3RBjLa8N5ISv20ccSPjQITcjHIVKS48u7g54/822wI8TkJrPSiGEbSgJ5kBq+R8mIUX9wlwhk/lCAr5SsBkejsF16zVni8HUDXyEaZzdNbz6o5vb37MiH+wUlkeuOW9Bk7rOZ9DoKXRUvAqxRJGzqazW9UvIEypc2gaS8dGyplSakvReFNfC/hOCKNZo0j+BCzLBLfNUYdtQDGEG5DlRTsL4XIh4XF/zIXBZVrDcCpF3t1LYLUtKFA+go1o/ZbOPRAa+PpLfklOWJz5AC9Pnl+b0KhZc2OIMT3keHP5inSEjO/B3MLwkAhMdTVhH62IFqIRL20fsW/j3jfRhO1AMYIbj2rpveVQx8Qw89D0BzR8IGgeoVpM0GaNeCfUYY14E82kjLDCaWvtsYeyThzROz61rzW6ip+lORqAE1Z4/C64lktkszXp/xNfHOd3t/MVwIijlC5eXnxlUBsEZFT17euiuWe4hAn7hB8Z9735JtUQTmDdT2pFkHqdR1rIO27HkVUAYwB/OzC2vzAgQ+J4Eb4Ka/ID9OviNDEdBXBIMLA4q82/cfl1/eVTMwtsAQFONhQt7/i5n/3fQtaaPovJOVqEcwdbBwQOrKQ4hZkCI+AYUzAUwSBWxBXBL5b4P2N0xRB6BaEiiD0EOLfS1IRMK4USpn6QTuMriYsHjiVoxnVLd1UAYwyOz+7sDbf3nU+KOsp+G14spq65sNVCs4vJpjhs84BIg1Gthnzzcp4Bi2Y0NgvFo9Xeu/A/Vtn19ua/gsNuEL8aH84NWaMIeidIzsS6MXIOGLnSJAmCJc5BcE91yKIXJeevo3P7tHuJBSBV9n1GJIdQvRLDjRIiXEEA4U/jkMW9lhKzyuGxgBGkae3omZOc/dfwMoGEq8FYYAydwtyysuK/sfqM65h4vWDNuRUX8HcO6KBxmAnTHVj1SU5Yq1A5odjY1QoSo7bH0zRFw+qpI07Mh5n7YAvS6VWFKIoypnR/0iXHdcgM2PgWSKRMRGpNxrF+xMbB8gXQTMqrpmPWgCjxLZOVM0/0f7faPApAr8liH7/xalHQmvANVEzXIMgSDWYaxCxIpwMQ0p9sfzP9sL+J0cw1ARdbz9tiRHeDMisyGwX61d0fEgft/dGQAQ39LjK0K0ftBXNFvh1iq5BbHp32pDAgki2GVoDzkHPIqA7lqBtxzUIqhelO7jRyB1v3Eor6phfVgv2YxRRBTAKsBPm6MK2dxeIOwCcHviyCSEnCEl3CeI/iuDH4lkD8cDfsBQHD/cXqq+p1ENEtt+B6oXzT7sMIp+XeF9cfFOXSLoFmeOAN+tK9JxSiiNFERT/3rGZWOLnOOnSTLcgPCi0AFLuNgwKohorUxH4FOMIRw1ke/3vPn8Qo4gqgArDbag6+lLbewpW7obg9FQh9vFmCHofEtaAWy+4gP/CUBDctstUHAQP2IIsbz9vz8vDHqzDwW+0NVTX1FwMcHMw8fl98fuTFsQb1jg8oRYnW1BKccDJfND5FLcGgmtG6yctDGd8/om06dkCwHMLmCgPg4VRhUYLCuQp5Oz20d7SXWMAFYRbUdP7QtufFcAvAzyjpJ/nlvvWAGM/yESdeDmT5nDJ+vBvanlJgOtaz9z/tWEMM0H3fQtaaqprPgZynQgaAhHzZXWwfqXelFN6HAAhtCCGuZoQqPxqQqLEasK02IDXWGw1oYC9lvhO04yapzHKqAVQIfjZhbVHj3X/JcVugp/qc6eWzBkNsVmy+CYeRM42dUMLIn2GRWzWwXNW8JnWZn5FzkH/8EYb0n3fghay/yKIvVogp8G640BgTruTZWIcXseCwjLGEU6o9OTKN8djHcxyC+hbQkXBHHw1IZCID8QNCN8tcLIFkfiAf3KJ1YSWKIjIT3I5/Ju85tlTGGVUAVQAfnZhbb6x63wQ6ynyKkkIuvOr9PPIWW6BZ4KGxeK7v0l/OvUaKUE/rz+WfNFAPiPs/1c5t+vocMYa6eq22fVdR/svNGKvBmSBa6VHzO9AMOKmc6lxIBkfiAilU0Z65nwxPkC/D5nXQGjm05/nw0rZgcJyFUHYb7/lSKoxbfFB0e+HAK8I8beNb35pJ8aAlJ3LlaHArajJ03yI5PWAvFbiblXqj10GOZ72WUKfOl4v9X1ih+y9lrK5pabwlVnnHenJGE7ZcCty+ULDJwRYC8gZaX2JDDX4/RffMH48/t4tTC3Pqh9dly+D1Q+ad313CYU4fq5TTSLnpDQdlHuuhD/geKeCijxhKcuba+vvk46DY/J0Z7UARgC3oibf0/4hwH5KRBZAvFCwa+4nTL3gPySlBdFZJyLwDFa4lXQNIvW9QuExUq5vrW+8Wz5QGbOya8bsxQbYImA9IMngpSBjJh6Ca+A3EIzDLU+p7x/zxd+3BvzyLAsCoQURZgu8XoZmQmwc/nUyAoX+5VwtV/JGI5wgeEEL5twvb3xixK5ZuWTpVmUQuBW5w92tf14l+BIEzcnpzPkM6zsnAAAaiElEQVQYf40cl0GOx8qzLIiMNgj0w3BJy4cPfanEcMqGW5Hrqp692Bjz+Ug30p5m5L2PCHnsvZ8GjZeXsoRSrYaS9SWqh7POCcok6rvDcQtKjKNUP5KblIp3HRSs4MVCgYtnv33Xd1N6NKqoAhgG3Iqaw4db/6zK4EswaA4OBLNLymxVxo+ubEUQ/KhSZh+nDsmDMFjbsujQF8sfXTbcNrs+320ugWALgPo0s3hIjzULT3Jmy+Q4IgTlgyjblPrB/QXl1EcgoM7fm+l1nXMSCtCpG8l20vZB5ICFPGhzcmvbW1/61Xg8xVkVwBDZc8fcWTPZ9yEj/BwkJvyJ1/Jn6oQSiNfJFI7Y+gGvnOQuATY3nXboS5VY599934IW6Tt5IUXWADgtyzoJZSH9aceRYaf8rSKKYChKM7XNtHJTnjUQF2rvWsXLDlcRsI/AYYjsFuHPCwX79b6egR/M/eP9o7revxQaAxgCvKujrvvYyQvEYCMgzdGDznuB4wBLeFxirwgPDzltKOE58dtOST5nwc8MVJmvVEr4eerkRRBcDeC0xHidfmVuQhI/nmiD3rDDVXipysKp7q4mJGCFfAHAdvg3D8TrA4AUIDCwxovWMn489hmEGAPrxQH8r8m4/XGv5TUqKC5vgAFAFADpNcABSzzfb+Vnx/vlV686d/dJjDNqAZQJ7+qo6zrad6EAqyFYUHxWXYbf55elmLoJwR7MIsiqk20uvwDwVltl/7V1UVceI+TgN9oaagZyH6NwBSALhjTzxiwCwHENUoyjYVtPACj4jaFZk4N59DiODz4wZD5oeERV0yjkqgodMxuO4nd/fnw8zPxSqAVQBnvumDsrf/TkRwywisACCWy8mAOeNSs4s3WiYsl8tRcmDzafiLcXew+8AvCWAct/bb+ga8Spvu13oLq6UHWxBdcIJJz5nch8qRuN4hYBgDBu4dSLROqD8mg6IR7qCK5R7NTunOWSX794+g/PWVxuBP1wedWmOKoABuHpTtTM6D/5QQg6CZkrxv/JAqlKAIgKcZqwiiMZkXIkdUT5rkEviRua++vvlkteqohpubBl9mUscLMYNKRf00s1pq3ldxH3lFgqzD8tyzWIpQ2DOoGC4W4M4Lz/PLr/yUWL949J7nwqoS5ACQhI9+fa3wLYr4vBmQAcMzQtEOTYvJlBJUTrxO9six9PbUMiZjSJAohPtFxUoVRfJ0zXG2YvMSKfT1w/w92JpAHj/U0ZTxAfKHk83lZ4DS+ovwu28NeNf3Lg0YlmWk8W9GagEhz4h9l1ZGGDCM5MBIsSj3IGgrtDiPBf5Hi8DYQ3wgii58XPj7QRXoPEYQCLKyX8+7fOru96/ezLhfy074Ekrp/Sr+ImJIPUd94XhxHbjz92nPFzvQoCFEj8KkcueXLGgcdV+IePugAlmGXt2waA/xFIaNwXdf1z95cbN/HhnJTqK2eY+PE4gk+oBHYJZHPTrEP/PLwRRum+p6nFFnihMVgLyCwgXE0Y8fWzXBVJqQ8kx+H/qdz4gJS3mpBAAcIdhnJD3ex93z33nMpuZTbdUAugBP20nxRBlTsdRXzUyMzl2uRh/bTZD1ltxOsnruGez+cLxKdOmNw9ldjQY9cXGlsp1RcZkWsA6Yj2uYwnE6XUj1gQaeOIzPbpDzmN/EkIC4tnjODGhqMt91fibsbpjsYAMth127yZjVXHXkHmYp8SaSqgdIwgw48ueQ3/owAWfMFAbrV99l9bF4881Xfo7tbGamMuoWAFxE/1xfoTeR+b4dP6HymXjPL09iPxAaec5M6ccFXD8QP/KYvQN+jAlEFRCyCDmSa/gERz5FkPkVks7rAiNrvFJN2d4WPPj0htg24hQtPYYo8hbxmo4lcqIfzcippcznzMiqyGyIKsWTrSR2RsbBrrclhOJDYvGcQi8OMDgcVl+bIIL2/Yc+ABFf7KoTGADGpQ1Wp915+RyS/mBzOI5Ee2wQrqu06vXx9A2tZRfp1EG0FMIU/ajb0NdV9esOjlExUYJrr72q4XwTKATcHMXlYMwu8XopkMv27q+zJiCv4lIvEB7K6ukvNmHd3/pCwe7eflTi9UAWRhnH0h3N95fOb2g3gSe5BFpK4kIlnRikgXMKcdgv22wCVti7v+TyUe4UdCuu9q2yyQtaEQOvsTIqYI/Pep/fQ3uIyVu8oyUh57vmHq38w7RbBbBB+e9YF92+NehzJyVAFkUJ3jnlP9sAIxkSdFwVEEkR+sbyaEs6H72w8FPU0RpMQT/EPFdvYZw0tbFnfdP/KRFX3+nrvlWjFYkWVxBI/ZQnkWQeZDTrPGhfCcpIIoBvxE8GsU7NWNJw+q8I8SGgPIYNay7pfE2pfhP4SSoewGm0HG/ddYbCCRMQBKxwZibRIgid+AWNb48a4HKjGuni+eNrvaygpClgFSG+l7yjiY6H+sXmwcQTQfsWPx+s45iVmfKAjxNMn1TfMPfq9S25YrSVQBlOZroTAUI3eRCTwhNIgJUKnjpdOGtKBYPCPg9d01DfdXYrHLvi/M6bC5gU9aYimIllSBTCiDqEaK1HPLnPLMJxNlKQFPo3rWhhVgB0Vuau5r11TfKKMKoASk3G0LOBIRaj86zdA8TlgEsfoRAUpTBIwqAloS4C9Bbj4xs+Y/XlWBtf3dX2hsra0qLAXsEgHmBH3zMxJxQXbLYwoqUF1p4wGS4y/VtlNOSwi404CdzX2t35RFz2i0f5RRBVCC5ur8MxB8IWruO0LtLo5BhiIAogIUayLDNXjOWtx04mjNN+deOPLNInbdNm+m5KqXg7gc/l19br/SnkcQHE+Ow1/2myn88fOdv1UkDYjEuS+jwOUN8w/cr8I/Nkz4hUD87MLaQwf2vkNy8h5j5I0CnAFBG4mCCA4TfN6ST+UMf9D8i+M/l69W1F+U7tsaf8da+ZwI/gQIM17hQhbvZ+2mz1Lrua1mBP0EIHkgB1naMNB6vyyv0Aaed7ZtEsMrAWkovRBJ0vvrfk4cL3ORT9o1ovV3w8r5Tdj/hPr8Y8eEVQAH1s2aW1VlLgZwGYAFEBjx81POj4ihb1wA8Yuc4PbGmb33ykpUbJulrs80vYWW/yBG3glE5TeiCJz1ABFFkFo//ECvHoEBQ36waVllAn4AkP9i2xYLrAn+Ym5/UhWBpPc3673ECkrWidYP1wLw+Wqxf1X34cPbBxmOUmEmnALY21k3p8bKXxjhtYC8OnIw9kNK24mWQAHgTwoFuRmzarbNXn24txL9Onxr/btEzC0CeTukmD4dtjUQOaf4H4kDVnhJW4WE/+CdbQ1VtKsgsloE1YHS9PuVJdhOn4amCMINkuL7E2Zdg0DBQH46AFz59zsOPLZxI+wQhqhUgAmjAAjIkc66Nxgrawj8lUiJNQoxczr44UUd8ryF/P2M3MAddRtO7KlEH7s+3fQ+kDcR8g4x4UNVIgLuzOyIhe7T3AJv7c1zuZysbrz8yNcr0c+eO+rbaaqXkbI82LXY7VesD4myyPuhugWxZxck/jZFCPSD3I6cue47PPDQIjX7x4UJoQBISNf19X8AwS0CvL3sEyMzL0KTMrQGBoS8J1eFWxvXH32mEn3tuaX5vQXYzTDyLrcP6T6/BJ1ivB68mLrwl9bKTa3Sdm8lfP59X5jTMYMDSwh+UiBzsoQ0Yg0kjjuMQBFkHScwAPBxgWxsMgcfVJ9//Bh3BUBC8tfX/ZElbhIj5Qu/S4oiAFxlwPtRyG1o2Zh/aoTdBQDkb214ByG3UuTdiT7414/1KcU1IMBnxMjmE6dqvjl35cij/fnPNbSxunoZhEsgTrTf7V+akJZj6rvnjNAisMB2I7K2yRzYpsI/voxrGpCEHN4w6/1WsBEiZw+/If8fvTQVwht4itPsH8MUNnfd2PSWSvS7aVXvj1mQK4V8KJABN8tHhE98dnODTtqQxLO2IJsqJfy8Y+4s5KquJFhM9aWl9Nx+xnL7mYt84mk9P4cZP5ZoO7yGvyhIBIDlDgLL/58K/4Rg3BQAAeneUPfenJGNoPxeSZ9/CI26P2z/922IKkLeDxY2Hemse/OIrwOgeW3+qSrI1SAf8hcFxXPijAlCsKKQ3Ccia3t6Gr9RCeEHgJ6Bk2spvEqI2UPeliyuCIaQ28+8tTnxSljLHQPWLmr91cFH1eefGIyLC0BA8tfNeqsVcweAt8po9CPFDLUERXi/qZZrmtb1jvjxywSk+1NN/0UMPweRd8Wj//71Y35/vxH7l41X9z5Qqb3s8v/YssVCVoswVOhpJn65vns8PuAfS/uWynULgB1GeH7jRw7/apDhKGPImCsAAtK1YdabYeQLAvm9Ub1YLFvgvHy9z2Lt7I29v5Hk/DVk8jc3vNNCboXg90Ukh3ijYT8O2oK9uG11b0VSfbyro66nt2+NhawJLKjAFHGvPQRFEK9fTqAQQGT9gKvwigu2npJc7vLGv97/E93Ac2Ixpi4AAenunPW7wBgIv3fB0AwN3QISH6ox2NxzY8PCSlymaU3vI6BsEODHJAtgbMIkaC2eLxBLKiX8PZ+pb+/u6VtlrVwh8Pct9P9FTY7U/Qld3HKbUj9oJ+N8IHRxou5DP4DHmeOqxr/er7v3TkDGTAGQkN7r6t9Na74oZgyEP3Jx91+gCM4vWNzac2P96ytxiZa1+Ydo5ToQj8YEgSB+CXLdkZp8Re7n3/d3czpsTc3lQiwRQZPvjgNwhFTSFcFgATz3fdbGpmnxheDa9MIDHCCx3VJuaK46/H0RXeQzERkTFyBI9QluEgwz1VdJoqO+H6hgitBzB0TwLk90nhFg8wkzsyLR/p7P1LfbmprLAS6BIEz1lbi/IOEWBHWGmdt3XYOUtKGnArYXDNa3zTii9/NPYEbdAiAheS/VJxxBqq+SuJF64E8gFUwRFt2BKyzxEIjfCLGpUsLPT3fUFaqqrwD5SRCnJVwc966+hFkfk/ByLYIU6ykSF0jPLOyg5FZ8T4V/wjPqFsALnZjRauvWWGAFRBpG+3rDgUC/AN+htetaNx3/WQXak/ytjb9t+828lr7ux2QjKvKsvq7bW26C4AoImkrfXxAW0v0YvKa44uJUzIr2J66VtDoI7LBV9n+2Ptf1jOja/gnPmLgA+TUNbbaanyNwfvHGlIkHAQvBfxaAa2dvPFqRVBVje2WOhPztTVusmJUiqCrrRqOYgAaKIFI/7hYMJ20YviGwY8Dg/NmXaKpvsjBmacCuzqZm4cCdlvLBiiz6GSUocm8uh9WN1/c+W4kU4UjZc8fcWfXHjq61MOtEYNypOPKA4SzhHNQa8I+4nyWpUEq8J1AQyM9gsKTpksPbNdo/eRjTdQBdnU3NYOEOEn8ughljee2hQPLfczTrmjb1/mY8+5Hf0tBma3ClQK6CSGNywY4g1Rrw66Ssg8g+PswgIdBP8IkBmvXtlx1+SKP9k4sxXQfQsjHfDcktNuA9ACu2YUelEZHzCwa3HNpQmRThcNi3ua6jUCXLSLOUkEYAyYAcGa6/ISJxwORy3likL3E8JW0YNBqrGx7yUn24ob1ZU32TkTFfCQgAh9bNPCNXZdaBuBAi9ePRhzK5n7Zqfeum7hEHBodCz2fq2601l1NkCeCm+ryXVDN/CG5B5GC83H3Ndg28VN/jYmVDU5tG+ycr46IASMiJDTMW9JuqlQXwYhGpG49+lEEBxLfB3IaWTfmfjsUFD97S1lCDvmssZKlIeD9/YOpnmftBYVFI3ck6Oz7gfkg5DmS6BiR+QdrLW9q7H1Hhn7yMy92AIuDMTSd3DdiBW4zgHhIVec7dKJAj8H5IYdORDc2/OxYXrGLfKhLLBZgT5NyJ8GlEfio+1dwP8/Suix/fnTw1t4+w3cFcA9LuoLV/07K/+2EV/snNuFgAPgSkt7O+jRZ/Z4EPQ1Aznv0pQQHAtwoWK9s3Hf3laF2ka3PTFgqvFUG1F+MrEluFF8zJWea+OG8E6YHCEm5BJFsQaZMguWMglzu/ffHhX0+ELIkyMsZVAfhMmhQh5N6cxerGTZVNEe66DTMbTjasp8g6AcQVzqSp7/nhdCbmchVBvI577iBpQwJWgJ/1W/lE+7LDT2iqb2owIR4M0rIx302puhTgN8DKrJobDQT8y4LYLT0bKnMXIQB039bY2nSqYY0AVwYhtpKmPgNJDkTVteJT3QIbugWlsgUxl8DJLvTD8vF+yDXthw4/qcI/dZgQFoDPkZtbmnCs729FsAiYsIFBEHJvoZ/rZn9qZCsG922u66i1WEoxlxuRdgCJAF58hk64BTETP1E3NfIvpbMF/vti2wMQedySG1s7unQDzynGhFIAAHCsc+bpfdZsAPDRiZwiJHA/rF0/3HsHjnbOOu1ULne5EVwK4LRQaFN8fZ/MLEDGOSXThsXX1GyB35bAAtxesOa6trlHvqvCP/WYcAqAhHRtmLFATNVKgBdNVCXgPcX2W8NJEea3NLQVBrgBkL8RoD0+80pW0M8pLqUI6NZLq1tu2hD4mc3xypY5Gu2fqkyIGICLCNiy6eQumoFbCXwFxPHx7lMaIjB+irBrXdN/Kfc8dqKq0IdNhvi4AEWzP5aio4W3k24ypRdP5UX9+dB3l4y60Tb8+EDyseckd9Dai1sOdv9IhX/qMuEsAB8Csq+zbvYMK7cRWCQTNEXo7Xn3rZzFysZBUoTcilz3jrovEuYCEVRLLMUekJjlJfI5kQaM+/NutgCxGEHJdKDx/H4v1XelpvqmOhNWAfhMmhQh5Rs5YlVWinB/J+qrC3V/Jzn5OIBASONPM4qQ8PmH6BbEFccgqwmLqT75OcGPN1+d/6lG+6c+E84FiOOnCA34DRIVeVz2aCDCv6DYLQfXNLw6fqx3bV1Hla2/WYxcEE+50XtYiMRvtoFbLzDLEbgFTK8XXc0X1vddCbd+ZBMhiwEQj1viquZ8/ikV/unBhLcAfHhzS1PXsb6/A7BIRGaNd3+yIOXrOYO1TRuLzx04vH7GfMlVrRDwEkCaIpVjAbzkI83S6gXVkRbASw8o+uXOIh+nnCw+q8/kcEPTvB6N9k8jJo0CAICj62bN7c/JdZjgKUIQ91UZrj8Fe8TY3AqAF4lIa2b9DJM98e2k+vyDuwWlVhNagQXxuAWvb/stFf7pxqRSAJMoRUgA3xfgJYJ/LiItZZ/sBPEqahGkBv8EIJ+yVeaqlvka7Z+OTCoFAHhK4IYZ88Xm1gHyUQgmpDvgrRPow3B2PopZBIMqgrhFEM8WpNQVAWi5A8Zc2Hw8/5Ru4Dk9mXQKACjGs46urZtTqMathPwVgNrx7tOo4Kb0gOyMQblpQ6dNkjsK1Ty//Zqjv9aA3/RlUioAH97c0pQ/3vfFiZ4iHBEpQbzU9QOpwb9kfKDo9vMXsObi5g35n2mef3oz4dOApZA1XXlK1aUQ/l9M4BThiIjfCYji8w0TacP4asL0tOEAyO0s8Mrm/vzPVfiVSW0B+HgpwtsAnC8SS7VNJWJpQ/+lnNWEAPoBbi/Q3ND2857vylc14KdMcgvAR9Z05Xv7q1Za4g6SR8a7P6NGZJYvYQ24dYvWgCX5pC3YjW2vVeFXQqaEBeDTu7muo+8klguwuGTefaoQtwjC9H7kGMmfFsBr2p8+9gMVfsUlN94dqCRbvtd/vPDHNU8P9KNWgDeKyIR9+EhF8YXdk3w3109yR8Gaj7fz6KPyj5rqU6JMKQvA56U1TS311YVOI7wUE3hnoYoTWz9AckcBOL/9Bk31KelMiRhAnDNvznf1mLY1BO4h0Tfe/RkzfJ/fkrT8BcAL2jeq8CvZTEkFAACv2vjSyby0L7fgv0zUTUVGA//GHhCXt+DY05rqU0oxJV0Alxc6z5zRWDh8izG8KHE33tSjn+B2Q3Q2/erY9zTgpwzGlFcAgLfb8PGBdaC9dKpmBwgUQD5O8IbWNx3X3XuVspgWCgAA9q2t66iumropQhJPFixXtu889n2d+ZVymVJpwFJ8+kf9x3vPrd6Ro9RgiqUICewwMJe15o7+WFN9ylCYNhaAh+zqbGypK9jrRHjZBH4q8RDgD0VyH2u6oec5jfYrQ2W6KQAAxcBgkz30WUAunqi7DQ9GcdMRPix24MKWzadeGO/+KJOTKZsGLMWrNr50Mm/alwP8lwn8aPJMSPQD2CaGVzRvPvXiePdHmbxMSwUABErgSgJ3kuwZ7/4MgVMQPETIhuaNx/V+fmVETEsXwOVQZ2ujsX0bAF46pL37xgESAyLYZomNrTcefUR9fmWkTHsFAAC9nXVz+iyumugpQgLbLAtr22468ROd+ZVKMG3SgKXY8v1JkCIkf0SDZW03Hn9KhV+pFGoBeBCQPWsaWmdWc70BL5tYW47zh5TC37RuPLlrvHuiTC2mbRAwjgCcd3Pv4R7Tto7APZgodxGSP2Kh8FEVfmU0UAUQw8sOXMViivDkePWDRD+J75Fc1rJZhV8ZHVQBpBCkCAV3YnxShKcgeChnZH3LpuO6e68yamgMoAQHV7Y15Gacum4sU4QE+gE8RE31KWOAKoBB2NtZN6dmDFOEtNhmbWFd2+YTj6nwK6ONKoBBICAHOmd1VFOWk1hiRJpH7VqWD1cDS+s36U4+ytigMYBBEIBzNh7ff0LkNlK+RPLo6FyJP0SucIEKvzKWqAUwBNh55owuHvpbUD5WybsIafkwcoULNNWnjDVqAQwBKW40ejWKG42OOEVIoJ8W26qBpS0bT+6uRB8VZSioAhgiforQkl8CmR9BU6cAPGRtYZ2a/cp4oS7AMNn76Y66Gb1HV5FyiQjmD+VcEqdE+C1L+bSm+pTxRBXACDjU2dpoCif/AmI+KoJ3ABh0izESewjehQF8pXXLsR0q/Mp4ogpghOy8ArWntda/qt/iDwTypwDfKYI5bh2SvYDsIPmgiDwoJvezlo357vHqs6L4qAKoELwCtfvr65pNzjTOEM6xwnkAqgrCwwJ5hQOFLttX1z3704d7x7uviuKjCmCUIL2/bfGR3WrmK4qiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKIqiKMD/B1hrit5mtqL3AAAAAElFTkSuQmCC \ No newline at end of file diff --git a/libresvip/plugins/tsmsln/value_tree.py b/libresvip/plugins/tsmsln/value_tree.py new file mode 100644 index 000000000..e5fc51d24 --- /dev/null +++ b/libresvip/plugins/tsmsln/value_tree.py @@ -0,0 +1,136 @@ +import math +import struct +from typing import BinaryIO, Union + +import more_itertools +from construct import ( + Byte, + BytesInteger, + Computed, + Construct, + Container, + CString, + Float64l, + GreedyBytes, + Int64sl, + LazyBound, + Prefixed, + PrefixedArray, + SizeofError, + Struct, + Switch, + this, +) +from construct import Enum as CSEnum +from construct import Path as CSPath +from construct_typed import Context +from typing_extensions import Never + +Int32sl = BytesInteger(4, swapped=True, signed=True) +Variant = Union[bool, int, float, str, bytes, list["Variant"]] +Node = dict[str, Union[Variant, "Node", list["Node"]]] + +JUCEVarTypes = CSEnum( + Byte, + INT=1, + BOOL_TRUE=2, + BOOL_FALSE=3, + DOUBLE=4, + STRING=5, + INT64=6, + ARRAY=7, + BINARY=8, + UNDEFINED=9, +) + + +class JUCECompressedIntStruct(Construct): + def _sizeof(self, context: Context, path: CSPath) -> Never: + msg = "JUCECompressedInt has no static size" + raise SizeofError(msg) + + def _parse(self, stream: BinaryIO, context: Context, path: CSPath) -> int: + byte = stream.read(1) + if not byte: + raise EOFError + width = struct.unpack(" int: + if obj < 0: + msg = "Negative numbers not supported" + raise ValueError(msg) + width = max(math.ceil(obj.bit_length() / 8), 1) + try: + content = obj.to_bytes(width, "little", signed=False) + stream.write(struct.pack(" Variant: + if isinstance(variant.value, list): + return [build_variant(x) for x in variant.value] + return variant.value + + +def build_tree_dict(node: Container) -> Node: + attr_dict: Node = { + attr.name: ( + build_tree_dict(JUCENode.parse(attr.data.value)) + if isinstance(attr.data.value, bytes) + else build_variant(attr.data) + ) + for attr in node.attrs + } + buckets = more_itertools.bucket( + (build_tree_dict(child) for child in node.children), + key=lambda item: next(iter(item.keys())), + ) + children_dict: Node = { + key: [next(iter(item.values())) for item in buckets[key]] for key in buckets + } + return {node.name: children_dict | attr_dict} diff --git a/libresvip/plugins/tsmsln/voisona_mobile_converter.py b/libresvip/plugins/tsmsln/voisona_mobile_converter.py new file mode 100644 index 000000000..25d5e59c4 --- /dev/null +++ b/libresvip/plugins/tsmsln/voisona_mobile_converter.py @@ -0,0 +1,23 @@ +import pathlib + +from libresvip.extension import base as plugin_base +from libresvip.model.base import Project + +from .model import VoiSonaMobileProject, model_to_value_tree +from .options import InputOptions, OutputOptions +from .value_tree import JUCENode, build_tree_dict +from .voisona_mobile_generator import VoiSonaMobileGenerator +from .voisona_mobile_parser import VoiSonaMobileParser + + +class VoiSonaMobileConverter(plugin_base.SVSConverterBase): + def load(self, path: pathlib.Path, options: InputOptions) -> Project: + value_tree = JUCENode.parse(path.read_bytes()) + tree_dict = build_tree_dict(value_tree) + tsmsln_project = VoiSonaMobileProject.model_validate(tree_dict["MobileSongEditor"]) + return VoiSonaMobileParser(options).parse_project(tsmsln_project) + + def dump(self, path: pathlib.Path, project: Project, options: OutputOptions) -> None: + tsmsln_project = VoiSonaMobileGenerator(options).generate_project(project) + value_tree = model_to_value_tree(tsmsln_project, name="MobileSongEditor") + path.write_bytes(JUCENode.build(value_tree)) diff --git a/libresvip/plugins/tsmsln/voisona_mobile_generator.py b/libresvip/plugins/tsmsln/voisona_mobile_generator.py new file mode 100644 index 000000000..6bba68ec6 --- /dev/null +++ b/libresvip/plugins/tsmsln/voisona_mobile_generator.py @@ -0,0 +1,154 @@ +import dataclasses +from typing import Optional + +from libresvip.core.constants import KEY_IN_OCTAVE +from libresvip.core.lyric_phoneme.japanese import is_kana, is_romaji +from libresvip.core.time_sync import TimeSynchronizer +from libresvip.core.warning_types import show_warning +from libresvip.model.base import ( + Note, + ParamCurve, + Project, + SingingTrack, + SongTempo, + TimeSignature, +) +from libresvip.utils.translation import gettext_lazy as _ + +from .constants import DEFAULT_PHONEME, OCTAVE_OFFSET, TICK_RATE +from .model import ( + VoiSonaMobileBeatItem, + VoiSonaMobileNoteItem, + VoiSonaMobileParameterItem, + VoiSonaMobileParametersItem, + VoiSonaMobilePointData, + VoiSonaMobileProject, + VoiSonaMobileScoreItem, + VoiSonaMobileSinger, + VoiSonaMobileSongItem, + VoiSonaMobileSoundItem, + VoiSonaMobileTempoItem, + VoiSonaMobileTimeItem, +) +from .options import OutputOptions +from .voisona_mobile_pitch import generate_for_voisona + + +@dataclasses.dataclass +class VoiSonaMobileGenerator: + options: OutputOptions + first_bar_length: int = dataclasses.field(init=False) + time_synchronizer: TimeSynchronizer = dataclasses.field(init=False) + + def generate_project(self, project: Project) -> VoiSonaMobileProject: + if self.options.track_index < 0: + first_singing_track = next( + (track for track in project.track_list if isinstance(track, SingingTrack)), + None, + ) + else: + first_singing_track = project.track_list[self.options.track_index] + assert isinstance(first_singing_track, SingingTrack) + voisona_project = VoiSonaMobileProject() + self.time_synchronizer = TimeSynchronizer(project.song_tempo_list) + self.first_bar_length = int(project.time_signature_list[0].bar_length()) + default_time_signatures = self.generate_time_signatures(project.time_signature_list) + default_tempos = self.generate_tempos(project.song_tempo_list) + if first_singing_track is not None: + singing_track = VoiSonaMobileSinger() + song_item = VoiSonaMobileSongItem( + beat=[default_time_signatures], + tempo=[default_tempos], + score=[ + VoiSonaMobileScoreItem(note=self.generate_notes(first_singing_track.note_list)) + ], + ) + singing_track.mobile_singer_data.state_information.song = [song_item] + voisona_project.mobile_singer.append(singing_track) + if log_f0 := self.generate_pitch( + first_singing_track.edited_params.pitch, project.song_tempo_list + ): + singing_track.mobile_singer_data.state_information.parameter = [ + VoiSonaMobileParametersItem(log_f0=[log_f0]) + ] + return voisona_project + + def generate_tempos(self, tempos: list[SongTempo]) -> VoiSonaMobileTempoItem: + return VoiSonaMobileTempoItem( + sound=[ + VoiSonaMobileSoundItem( + clock=round(tempo.position * TICK_RATE) if i else 0, + tempo=tempo.bpm, + ) + for i, tempo in enumerate(tempos) + ] + ) + + def generate_time_signatures( + self, time_signatures: list[TimeSignature] + ) -> VoiSonaMobileBeatItem: + beat = VoiSonaMobileBeatItem( + time=[ + VoiSonaMobileTimeItem( + clock=0, + beats=time_signatures[0].numerator, + beat_type=time_signatures[0].denominator, + ) + ] + ) + tick = 0.0 + prev_time_signature = time_signatures[0] + for time_signature in time_signatures[1:]: + if time_signature.bar_index > prev_time_signature.bar_index: + tick += ( + time_signature.bar_index - prev_time_signature.bar_index + ) * prev_time_signature.bar_length() + beat.time.append( + VoiSonaMobileTimeItem( + clock=int(tick * TICK_RATE), + beats=time_signature.numerator, + beat_type=time_signature.denominator, + ) + ) + prev_time_signature = time_signature + return beat + + def generate_notes(self, notes: list[Note]) -> list[VoiSonaMobileNoteItem]: + voisona_notes = [] + for note in notes: + lyric = note.lyric + phoneme = "" + if note.pronunciation: + phoneme = note.pronunciation + elif not is_kana(lyric) and not is_romaji(lyric): + phoneme = DEFAULT_PHONEME + msg_prefix = _("Unsupported lyric: ") + show_warning(f"{msg_prefix} {lyric}") + voisona_notes.append( + VoiSonaMobileNoteItem( + clock=int(note.start_pos * TICK_RATE), + duration=int(note.length * TICK_RATE), + lyric=note.lyric, + pitch_octave=note.key_number // KEY_IN_OCTAVE + OCTAVE_OFFSET, + pitch_step=note.key_number % KEY_IN_OCTAVE, + syllabic=0, + phoneme=phoneme, + ) + ) + return voisona_notes + + def generate_pitch( + self, pitch: ParamCurve, tempo_list: list[SongTempo] + ) -> Optional[VoiSonaMobileParameterItem]: + if (data := generate_for_voisona(pitch, tempo_list, self.first_bar_length)) is not None: + return VoiSonaMobileParameterItem( + length=data.length, + data=[ + VoiSonaMobilePointData( + index=each.idx, + repeat=each.repeat, + value=each.value, + ) + for each in data.events + ], + ) diff --git a/libresvip/plugins/tsmsln/voisona_mobile_parser.py b/libresvip/plugins/tsmsln/voisona_mobile_parser.py new file mode 100644 index 000000000..c6dad2772 --- /dev/null +++ b/libresvip/plugins/tsmsln/voisona_mobile_parser.py @@ -0,0 +1,178 @@ +import dataclasses +import itertools +import operator +from typing import Optional + +import more_itertools +from wanakana import PROLONGED_SOUND_MARK + +from libresvip.core.tick_counter import ( + shift_beat_list, + shift_tempo_list, + skip_beat_list, + skip_tempo_list, +) +from libresvip.core.time_sync import TimeSynchronizer +from libresvip.model.base import ( + Note, + Project, + SingingTrack, + SongTempo, + TimeSignature, +) + +from .constants import OCTAVE_OFFSET, TICK_RATE +from .model import ( + VoiSonaMobilePointData, + VoiSonaMobileProject, + VoiSonaMobileStateInformation, +) +from .options import InputOptions +from .voisona_mobile_pitch import ( + VoiSonaMobileParamEvent, + VoiSonaMobileTrackPitchData, + pitch_from_voisona_track, +) + + +@dataclasses.dataclass +class VoiSonaMobileParser: + options: InputOptions + time_synchronizer: TimeSynchronizer = dataclasses.field(init=False) + + def parse_project(self, voisona_project: VoiSonaMobileProject) -> Project: + time_signatures = [] + tempos = [] + tracks = [] + for track in voisona_project.mobile_singer: + if parse_result := self.parse_singing_track(track.mobile_singer_data.state_information): + singing_track, tempo_part, time_signature_part = parse_result + tracks.append(singing_track) + tempos.extend(tempo_part) + time_signatures.extend(time_signature_part) + tempos = self.merge_tempos(tempos) + self.time_synchronizer = TimeSynchronizer(tempos) + time_signatures = self.merge_time_signatures(time_signatures) + return Project( + time_signature_list=skip_beat_list(time_signatures, 0), + song_tempo_list=skip_tempo_list(tempos, 0), + track_list=tracks, + ) + + def merge_tempos(self, tempos: list[SongTempo]) -> list[SongTempo]: + buckets = more_itertools.bucket(tempos, key=operator.attrgetter("position")) + return [next(buckets[key]) for key in buckets] or [SongTempo()] + + def merge_time_signatures(self, time_signatures: list[TimeSignature]) -> list[TimeSignature]: + buckets = more_itertools.bucket(time_signatures, key=operator.attrgetter("bar_index")) + return [next(buckets[key]) for key in buckets] or [TimeSignature()] + + def parse_singing_track( + self, state_information: VoiSonaMobileStateInformation + ) -> Optional[tuple[SingingTrack, list[SongTempo], list[TimeSignature]]]: + if state_information.song is None: + return None + time_signatures = [ + TimeSignature(bar_index=0, numerator=4, denominator=4), + ] + prev_tick = 0 + tempos = [] + notes = [] + + tick_prefix = int(time_signatures[0].bar_length()) + for song in state_information.song: + for beat in song.beat: + for time_node in beat.time: + tick = int(time_node.clock / TICK_RATE) + numerator = time_node.beats + denominator = time_node.beat_type + + ticks_in_measure = time_signatures[-1].bar_length() + tick_diff = tick - prev_tick + measure_diff = tick_diff / ticks_in_measure + time_signatures.append( + TimeSignature( + bar_index=int( + time_signatures[-1].bar_index + measure_diff, + ), + numerator=numerator, + denominator=denominator, + ) + ) + prev_tick = tick + for tempo in song.tempo: + for tempo_node in tempo.sound: + tick = int(tempo_node.clock / TICK_RATE) + bpm = float(tempo_node.tempo) + tempos.append(SongTempo(position=tick, bpm=bpm)) + for score in song.score: + for note_node in score.note: + pitch_octave = note_node.pitch_octave - OCTAVE_OFFSET + phoneme = None + if note_node.phoneme: + phoneme = note_node.phoneme.replace(",", " ") + notes.append( + Note( + key_number=note_node.pitch_step + pitch_octave * 12, + lyric="-" + if note_node.lyric == chr(PROLONGED_SOUND_MARK) + else note_node.lyric, + start_pos=(note_node.clock // TICK_RATE), + length=note_node.duration // TICK_RATE, + pronunciation=phoneme, + ) + ) + tempos = shift_tempo_list(tempos, tick_prefix) + voisona_track_pitch_data = None + if state_information.parameter is not None: + for parameter in state_information.parameter: + if parameter.log_f0 is not None: + pitch_data_nodes = itertools.chain.from_iterable( + curve.data for curve in parameter.log_f0 + ) + vibrato_amplitude_nodes = itertools.chain.from_iterable( + curve.data for curve in parameter.vib_amp or [] + ) + vibrato_frequency_nodes = itertools.chain.from_iterable( + curve.data for curve in parameter.vib_frq or [] + ) + pitch_datas = [ + pitch_data + for data_node in pitch_data_nodes + if (pitch_data := self.parse_param_data(data_node)) + ] + vibrato_amplitude_data = [ + vibrato_amplitude + for vibrato_amplitude_node in vibrato_amplitude_nodes + if (vibrato_amplitude := self.parse_param_data(vibrato_amplitude_node)) + ] + vibrato_frequency_data = [ + vibrato_frequency + for vibrato_frequency_node in vibrato_frequency_nodes + if (vibrato_frequency := self.parse_param_data(vibrato_frequency_node)) + ] + voisona_track_pitch_data = VoiSonaMobileTrackPitchData( + events=pitch_datas, + tempos=tempos, + tick_prefix=tick_prefix, + vibrato_amplitude_events=vibrato_amplitude_data, + vibrato_frequency_events=vibrato_frequency_data, + ) + time_signatures = shift_beat_list(time_signatures, 1) + singing_track = SingingTrack(note_list=notes) + if ( + self.options.import_pitch + and voisona_track_pitch_data is not None + and (pitch := pitch_from_voisona_track(voisona_track_pitch_data)) is not None + ): + singing_track.edited_params.pitch = pitch + return singing_track, tempos, time_signatures + + @staticmethod + def parse_param_data( + data_element: VoiSonaMobilePointData, + ) -> Optional[VoiSonaMobileParamEvent]: + value = float(data_element.value) + index = data_element.index or None + repeat = data_element.repeat or None + return VoiSonaMobileParamEvent(idx=index, repeat=repeat, value=value) diff --git a/libresvip/plugins/tsmsln/voisona_mobile_pitch.py b/libresvip/plugins/tsmsln/voisona_mobile_pitch.py new file mode 100644 index 000000000..e796515c2 --- /dev/null +++ b/libresvip/plugins/tsmsln/voisona_mobile_pitch.py @@ -0,0 +1,472 @@ +# mypy: disable-error-code="operator" +from __future__ import annotations + +import dataclasses +import functools +import itertools +import math +from typing import NamedTuple, Optional, cast + +import more_itertools +import portion + +from libresvip.core.tick_counter import shift_tempo_list +from libresvip.core.time_interval import PiecewiseIntervalDict +from libresvip.core.time_sync import TimeSynchronizer +from libresvip.core.warning_types import show_warning +from libresvip.model.base import ParamCurve, Points, SongTempo +from libresvip.model.point import Point +from libresvip.utils.music_math import hz2midi, midi2hz +from libresvip.utils.search import find_last_index +from libresvip.utils.translation import gettext_lazy as _ + +from .constants import ( + MIN_DATA_LENGTH, + TEMP_VALUE_AS_NULL, + TIME_UNIT_AS_TICKS_PER_BPM, +) + + +class VoiSonaMobileParamEvent(NamedTuple): + idx: Optional[int] + repeat: Optional[int] + value: float + + +class VoiSonaMobileParamEventFloat(NamedTuple): + idx: Optional[float] + repeat: Optional[float] + value: Optional[float] + + @classmethod + def from_event(cls, event: VoiSonaMobileParamEvent) -> VoiSonaMobileParamEventFloat: + return cls( + float(event.idx) if event.idx is not None else None, + float(event.repeat) if event.repeat is not None else None, + event.value, + ) + + +@dataclasses.dataclass +class VoiSonaMobileTrackPitchData: + events: list[VoiSonaMobileParamEvent] + tempos: list[SongTempo] + tick_prefix: int + vibrato_amplitude_events: list[VoiSonaMobileParamEvent] = dataclasses.field( + default_factory=list + ) + vibrato_frequency_events: list[VoiSonaMobileParamEvent] = dataclasses.field( + default_factory=list + ) + + @property + def length(self) -> int: + last_has_index = find_last_index(self.events, lambda event: event.idx is not None) + length = self.events[last_has_index].idx + sum( + event.repeat or 1 for event in self.events[last_has_index:] + ) + return length + MIN_DATA_LENGTH + + +def pitch_from_voisona_track( + data: VoiSonaMobileTrackPitchData, +) -> Optional[ParamCurve]: + converted_points = [Point.start_point()] + current_value = -100 + + synchronizer = TimeSynchronizer(data.tempos) + vibrato_amplitude_interval_dict = build_voisona_param_interval_dict( + data.vibrato_amplitude_events, synchronizer, data.tick_prefix + ) + vibrato_value_interval_dict = build_voisona_wave_interval_dict( + data.vibrato_frequency_events, synchronizer, data.tick_prefix + ) + + events_normalized = shape_events( + normalize_to_tick(append_ending_points(data.events), data.tempos, data.tick_prefix) + ) + + next_pos = None + for event in events_normalized: + pos = int(cast(float, event.idx)) - data.tick_prefix + secs = synchronizer.get_actual_secs_from_ticks(pos) + length = event.repeat + try: + value = round(hz2midi(math.e**event.value) * 100) if event.value is not None else -100 + if value != current_value or next_pos != pos: + converted_points.append(Point(x=round(pos), y=value)) + if value == -100: + converted_points.append(Point(x=round(pos), y=value)) + current_value = value + secs_step = synchronizer.get_duration_secs_from_ticks(pos, pos + 5) + for pos_x in range(pos, int(cast(float, pos + length)), 5): + if value_diff := vibrato_value_interval_dict.get(secs): + value_diff *= vibrato_amplitude_interval_dict.get(secs, 1) + converted_points.append(Point(x=pos_x, y=round(value + value_diff))) + else: + break + secs += secs_step + except OverflowError: + show_warning(_("Pitch value is out of bounds")) + next_pos = pos + length + converted_points.append(Point.end_point()) + + return ParamCurve(points=Points(root=converted_points)) if len(converted_points) > 2 else None + + +def append_ending_points( + events: list[VoiSonaMobileParamEvent], +) -> list[VoiSonaMobileParamEvent]: + result = [] + next_pos = None + for event in events: + pos = event.idx if event.idx is not None else next_pos + if pos is None: + continue + length = event.repeat if event.repeat is not None else 1 + if next_pos is not None and next_pos < pos: + result.append(VoiSonaMobileParamEvent(next_pos, None, TEMP_VALUE_AS_NULL)) + result.append(VoiSonaMobileParamEvent(pos, length, event.value)) + next_pos = pos + length + if next_pos is not None: + result.append(VoiSonaMobileParamEvent(next_pos, None, TEMP_VALUE_AS_NULL)) + return result + + +def normalize_to_tick( + events: list[VoiSonaMobileParamEvent], + tempo_list: list[SongTempo], + tick_prefix: int, +) -> list[VoiSonaMobileParamEventFloat]: + tempos = expand(tempo_list, tick_prefix) + events_normalized: list[VoiSonaMobileParamEventFloat] = [] + current_tempo_index = 0 + next_pos = 0.0 + next_tick_pos = 0.0 + for _event in events: + event = VoiSonaMobileParamEventFloat.from_event(_event) + pos = event.idx if event.idx is not None else next_pos + if event.idx is None: + tick_pos = next_tick_pos + else: + while ( + current_tempo_index + 1 < len(tempos) + and tempos[current_tempo_index + 1][0] <= event.idx + ): + current_tempo_index += 1 + ticks_in_time_unit = TIME_UNIT_AS_TICKS_PER_BPM * tempos[current_tempo_index][2] + tick_pos = ( + tempos[current_tempo_index][1] + + (event.idx - tempos[current_tempo_index][0]) * ticks_in_time_unit + ) + repeat = event.repeat if event.repeat is not None else 1.0 + remaining_repeat = repeat + repeat_in_ticks = 0.0 + while ( + current_tempo_index + 1 < len(tempos) + and tempos[current_tempo_index + 1][0] < pos + repeat + ): + repeat_in_ticks += tempos[current_tempo_index + 1][1] - max( + tempos[current_tempo_index][1], tick_pos + ) + remaining_repeat -= tempos[current_tempo_index + 1][0] - max( + tempos[current_tempo_index][0], pos + ) + current_tempo_index += 1 + repeat_in_ticks += ( + remaining_repeat * TIME_UNIT_AS_TICKS_PER_BPM * tempos[current_tempo_index][2] + ) + next_pos = pos + repeat + next_tick_pos = tick_pos + repeat_in_ticks + events_normalized.append( + VoiSonaMobileParamEventFloat(tick_pos, repeat_in_ticks, event.value) + ) + return [ + VoiSonaMobileParamEventFloat( + tick.idx + tick_prefix, + tick.repeat, + tick.value if tick.value != TEMP_VALUE_AS_NULL else None, + ) + for tick in events_normalized + ] + + +def shape_events( + events_with_full_params: list[VoiSonaMobileParamEventFloat], +) -> list[VoiSonaMobileParamEventFloat]: + result: list[VoiSonaMobileParamEventFloat] = [] + for event in events_with_full_params: + if event.repeat is not None and event.repeat > 0: + if result: + last = result[-1] + if last.idx == event.idx: + result[-1] = event + else: + result.append(event) + else: + result.append(event) + return result + + +def expand(tempos: list[SongTempo], tick_prefix: int) -> list[tuple[int, int, float]]: + result: list[tuple[int, int, float]] = [] + for i, tempo in enumerate(tempos): + if i == 0: + result.append((0, tick_prefix, tempo.bpm)) + else: + last_pos, last_tick_pos, last_bpm = result[-1] + ticks_in_time_unit = TIME_UNIT_AS_TICKS_PER_BPM * last_bpm + new_pos = last_pos + (tempo.position - last_tick_pos) / ticks_in_time_unit + result.append((int(new_pos), tempo.position, tempo.bpm)) + return result + + +def vibrato_curve(value: float, shift: float, omega: float, phase: float) -> float: + return math.sin(omega * (value - shift) + phase) + + +def build_voisona_param_interval_dict( + events: list[VoiSonaMobileParamEvent], + synchronizer: TimeSynchronizer, + tick_prefix: int, +) -> PiecewiseIntervalDict: + param_interval_dict = PiecewiseIntervalDict() + for continuous_part in more_itertools.split_before( + events, + lambda event: event.idx is not None, + ): + for prev_event, next_event in more_itertools.windowed( + more_itertools.prepend( + None, + normalize_to_tick( + append_ending_points(continuous_part), + synchronizer.tempo_list, + tick_prefix, + ), + ), + 2, + ): + if next_event is None: + continue + next_start = synchronizer.get_actual_secs_from_ticks(next_event.idx - tick_prefix) + if ( + prev_event is not None + and ( + prev_end := synchronizer.get_actual_secs_from_ticks( + prev_event.idx + (prev_event.repeat or 1) - tick_prefix + ) + ) + and prev_end < next_start + ): + param_interval_dict[portion.closedopen(prev_end, next_start)] = next_event.value + param_interval_dict[ + portion.closedopen( + next_start, + synchronizer.get_actual_secs_from_ticks( + next_event.idx + (next_event.repeat or 1) - tick_prefix + ), + ) + ] = next_event.value + return param_interval_dict + + +def build_voisona_wave_interval_dict( + events: list[VoiSonaMobileParamEvent], + synchronizer: TimeSynchronizer, + tick_prefix: int, +) -> PiecewiseIntervalDict: + param_interval_dict = PiecewiseIntervalDict() + omega = math.tau * 6 + for continuous_part in more_itertools.split_before( + events, + lambda event: event.idx is not None, + ): + phase = 0.0 + for prev_event, next_event in more_itertools.windowed( + more_itertools.prepend( + None, + normalize_to_tick( + append_ending_points(continuous_part), + synchronizer.tempo_list, + tick_prefix, + ), + ), + 2, + ): + next_start = synchronizer.get_actual_secs_from_ticks(next_event.idx - tick_prefix) + next_end = synchronizer.get_actual_secs_from_ticks( + next_event.idx + (next_event.repeat or 1) - tick_prefix + ) + if ( + prev_event is not None + and ( + prev_end := synchronizer.get_actual_secs_from_ticks( + prev_event.idx + (prev_event.repeat or 1) - tick_prefix + ) + ) + and prev_end < next_start + ): + prev_start = synchronizer.get_actual_secs_from_ticks(prev_event.idx - tick_prefix) + param_interval_dict[portion.closedopen(prev_end, next_start)] = vibrato_curve( + prev_end, prev_start, omega, phase + ) + omega = math.tau * (next_event.value or 6) + param_interval_dict[portion.closedopen(next_start, next_end)] = functools.partial( + vibrato_curve, shift=next_start, omega=omega, phase=phase + ) + phase += (next_end - next_start) * omega + return param_interval_dict + + +def generate_for_voisona( + pitch: ParamCurve, tempos: list[SongTempo], tick_prefix: int +) -> Optional[VoiSonaMobileTrackPitchData]: + events_with_full_params = [] + for i, this_point in enumerate(pitch.points.root): + next_point = pitch.points[i + 1] if i + 1 < len(pitch.points) else None + end_tick = next_point.x - tick_prefix if next_point else None + index = this_point.x - tick_prefix + repeat = end_tick - index if end_tick else 1 + repeat = max(repeat, 1) + value = math.log(midi2hz(this_point.y / 100)) if this_point.y != -100 else None + if value is not None and (next_point is None or next_point.y != -100): + events_with_full_params.append( + VoiSonaMobileParamEventFloat(float(index), float(repeat), float(value)) + ) + are_events_connected_to_next = [ + this_event.idx + this_event.repeat >= next_event.idx if next_event else False + for this_event, next_event in zip( + events_with_full_params, events_with_full_params[1:] + [None] + ) + ] + events = denormalize_from_tick(events_with_full_params, tempos, tick_prefix) + events = restore_connection(events, are_events_connected_to_next) + events = merge_events_if_possible(events) + events = remove_redundant_index(events) + events = remove_redundant_repeat(events) + if not events: + return None + last_event_with_index = next( + (event for event in reversed(events) if event.idx is not None), None + ) + if last_event_with_index is not None: + length = last_event_with_index.idx + for event in events[events.index(last_event_with_index) :]: + length += event.repeat or 1 + return VoiSonaMobileTrackPitchData(events, [], tick_prefix) + + +def denormalize_from_tick( + events_with_full_params: list[VoiSonaMobileParamEventFloat], + tempos_in_ticks: list[SongTempo], + tick_prefix: int, +) -> list[VoiSonaMobileParamEvent]: + tempos = expand( + shift_tempo_list(tempos_in_ticks, tick_prefix), + tick_prefix, + ) + events_with_full_params = [ + event if event.idx is None else event._replace(idx=event.idx + tick_prefix) + for event in events_with_full_params + ] + events = [] + current_tempo_index = 0 + tick_pos = None + for event_double in events_with_full_params: + if event_double.idx is not None: + tick_pos = event_double.idx + if tick_pos is None or event_double.idx is None: + msg = "Invalid event" + raise ValueError(msg) + while ( + current_tempo_index + 1 < len(tempos) and tempos[current_tempo_index + 1][1] < tick_pos + ): + current_tempo_index += 1 + ticks_per_time_unit = tempos[current_tempo_index][2] * TIME_UNIT_AS_TICKS_PER_BPM + pos = ( + tempos[current_tempo_index][0] + + (event_double.idx - tempos[current_tempo_index][1]) / ticks_per_time_unit + ) + repeat_in_ticks = event_double.repeat + repeat = 0.0 + while (current_tempo_index + 1 < len(tempos)) and ( + tempos[current_tempo_index + 1][1] < tick_pos + repeat_in_ticks + ): + repeat += tempos[current_tempo_index + 1][0] - max(tempos[current_tempo_index][0], pos) + repeat_in_ticks -= tempos[current_tempo_index + 1][1] - max( + tempos[current_tempo_index][1], tick_pos + ) + current_tempo_index += 1 + repeat += repeat_in_ticks / (TIME_UNIT_AS_TICKS_PER_BPM * tempos[current_tempo_index][2]) + events.append( + VoiSonaMobileParamEvent(round(pos), int(round(max(repeat, 1))), event_double.value or 0) + ) + return events + + +def restore_connection( + events: list[VoiSonaMobileParamEvent], are_events_connected_to_next: list[bool] +) -> list[VoiSonaMobileParamEvent]: + new_events = [] + for (prev_event, next_event), is_connected_to_next in zip( + more_itertools.windowed(itertools.chain(events, [None]), 2), + are_events_connected_to_next, + ): + if next_event is None or not is_connected_to_next: + new_events.append(prev_event) + else: + new_events.append(prev_event._replace(repeat=next_event.idx - prev_event.idx)) + return new_events + + +def merge_events_if_possible( + events: list[VoiSonaMobileParamEvent], +) -> list[VoiSonaMobileParamEvent]: + new_events: list[VoiSonaMobileParamEvent] = [] + for event in events: + if not new_events: + new_events.append(event) + else: + last_event = new_events[-1] + overlapped_len = last_event.idx + last_event.repeat - event.idx + if overlapped_len > 0: + new_events[-1] = new_events[-1]._replace(repeat=event.idx - last_event.idx) + event = event._replace( + idx=overlapped_len + event.idx, + repeat=event.repeat - overlapped_len, + ) + last_event = VoiSonaMobileParamEvent( + event.idx, overlapped_len, event.value + last_event.value + ) + new_events.append(last_event) + if last_event.value == event.value and last_event.idx + last_event.repeat == event.idx: + new_events[-1] = new_events[-1]._replace(repeat=last_event.repeat + event.repeat) + else: + new_events.append(event) + return new_events + + +def remove_redundant_index( + events: list[VoiSonaMobileParamEvent], +) -> list[VoiSonaMobileParamEvent]: + new_events: list[VoiSonaMobileParamEvent] = [] + for event in events: + if not new_events: + new_events.append(event) + else: + prev_event = new_events[-1] + if ( + prev_event.idx is not None + and prev_event.repeat is not None + and prev_event.idx + prev_event.repeat == event.idx + ): + new_events.append(event._replace(idx=None)) + else: + new_events.append(event) + return new_events + + +def remove_redundant_repeat( + events: list[VoiSonaMobileParamEvent], +) -> list[VoiSonaMobileParamEvent]: + return [event if event.repeat != 1 else event._replace(repeat=None) for event in events] diff --git a/libresvip/plugins/tssln/voisona_converter.py b/libresvip/plugins/tssln/voisona_converter.py index 188ac38aa..3cf3a34f0 100644 --- a/libresvip/plugins/tssln/voisona_converter.py +++ b/libresvip/plugins/tssln/voisona_converter.py @@ -6,11 +6,11 @@ from .model import VoiSonaProject, model_to_value_tree from .options import InputOptions, OutputOptions from .value_tree import JUCENode, build_tree_dict -from .voisona_generator import VoisonaGenerator +from .voisona_generator import VoiSonaGenerator from .voisona_parser import VoiSonaParser -class VoisonaConverter(plugin_base.SVSConverterBase): +class VoiSonaConverter(plugin_base.SVSConverterBase): def load(self, path: pathlib.Path, options: InputOptions) -> Project: value_tree = JUCENode.parse(path.read_bytes()) tree_dict = build_tree_dict(value_tree) @@ -18,6 +18,6 @@ def load(self, path: pathlib.Path, options: InputOptions) -> Project: return VoiSonaParser(options).parse_project(tssln_project) def dump(self, path: pathlib.Path, project: Project, options: OutputOptions) -> None: - tssln_project = VoisonaGenerator(options).generate_project(project) + tssln_project = VoiSonaGenerator(options).generate_project(project) value_tree = model_to_value_tree(tssln_project) path.write_bytes(JUCENode.build(value_tree)) diff --git a/libresvip/plugins/tssln/voisona_generator.py b/libresvip/plugins/tssln/voisona_generator.py index 6b973598e..bf05ec34d 100644 --- a/libresvip/plugins/tssln/voisona_generator.py +++ b/libresvip/plugins/tssln/voisona_generator.py @@ -38,7 +38,7 @@ @dataclasses.dataclass -class VoisonaGenerator: +class VoiSonaGenerator: options: OutputOptions first_bar_length: int = dataclasses.field(init=False) time_synchronizer: TimeSynchronizer = dataclasses.field(init=False) diff --git a/libresvip/plugins/tssln/voisona_pitch.py b/libresvip/plugins/tssln/voisona_pitch.py index a57bdf5fd..84ec45f1a 100644 --- a/libresvip/plugins/tssln/voisona_pitch.py +++ b/libresvip/plugins/tssln/voisona_pitch.py @@ -323,7 +323,7 @@ def generate_for_voisona( repeat = end_tick - index if end_tick else 1 repeat = max(repeat, 1) value = math.log(midi2hz(this_point.y / 100)) if this_point.y != -100 else None - if value is not None: + if value is not None and (next_point is None or next_point.y != -100): events_with_full_params.append( VoiSonaParamEventFloat(float(index), float(repeat), float(value)) )