diff --git a/.gitignore b/.gitignore index 0aaf06b..2eba8e0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,6 @@ # mystuff /*.zip /.vscode -/*.mid -/*.midi /*.mcpack /*.bdx /*.msq @@ -28,6 +26,13 @@ RES.txt /fcwslib test_lyric-mido.py test_doing_things.py +TEST-*.* + +# Copyrighted +vanilla_assets/ +/*.mid +/*.midi + # Byte-compiled / optimized __pycache__/ diff --git a/Musicreater/builtin_plugins/commands_to_structure/main.py b/Musicreater/builtin_plugins/commands_to_structure/main.py index b791462..98c7242 100644 --- a/Musicreater/builtin_plugins/commands_to_structure/main.py +++ b/Musicreater/builtin_plugins/commands_to_structure/main.py @@ -87,7 +87,7 @@ class McstructureExportConfig(PluginConfig): class MusicExportToMcstructureWithDelayPlayerPlugin(MusicOutputPluginBase): metainfo = PluginMetaInformation( - name="导出全曲结构插件(mcstructure结构、延迟播放器)", + name="导出全曲至结构(mcstructure结构、延迟播放器)", author="金羿、玉衡Alioth", description="将音·创 v3 的整首音乐数据,以指令方块延迟的播放形式,导出为基岩版 MCSTRUCTURE 结构", version=(0, 0, 1), diff --git a/Musicreater/builtin_plugins/midi_read/main.py b/Musicreater/builtin_plugins/midi_read/main.py index 2fc44c6..505744b 100644 --- a/Musicreater/builtin_plugins/midi_read/main.py +++ b/Musicreater/builtin_plugins/midi_read/main.py @@ -177,7 +177,7 @@ class MidiImport2MusicPlugin(MusicInputPluginBase): """Midi 音乐数据导入插件""" metainfo = PluginMetaInformation( - name="Midi 导入插件", + name="从 Midi 导入全曲", author="金羿、玉衡Alioth", description="从 Midi 文件导入音乐数据", version=(0, 0, 1), diff --git a/Musicreater/builtin_plugins/music_preview/__init__.py b/Musicreater/builtin_plugins/music_preview/__init__.py index 8160a1e..dc64b8c 100644 --- a/Musicreater/builtin_plugins/music_preview/__init__.py +++ b/Musicreater/builtin_plugins/music_preview/__init__.py @@ -31,3 +31,119 @@ limitations under the License. # 本插件依照 Apache 2.0 协议开放源代码,若需转载或借鉴 # 许可声明请查看 http://www.apache.org/licenses/LICENSE-2.0 +from io import BytesIO +from pathlib import Path +from time import perf_counter_ns +from dataclasses import dataclass +from typing import BinaryIO, Optional, Iterator, Generator, Any, Tuple, Literal + + +from Musicreater import SingleMusic, SingleTrack, SingleNote, SoundAtmos, MineNote +from Musicreater.plugins import ( + PluginConfig, + PluginMetaInformation, + PluginTypes, + music_output_plugin, + MusicOutputPluginBase, + track_output_plugin, + TrackOutputPluginBase, +) + + +from .main import MusicPreview + + +@dataclass +class PcmConversionConfig(PluginConfig): + + assets_path: Path + """ + 生成音频文件所使用的资源文件路径 + """ + + synthesis_mode: Literal[0, 1, 2, 3, 4] = 1 + """ + 音频合成模式 + - 0 原始长度,不变化时长 + - 1 拉伸至 mc 播放器定义(我的世界效果) + - 2 根据 midi 音符长度裁剪 + - 3 混音预留 + - 4 匹配 midi 音符长度(最佳效果) + """ + overlay_mode: Literal[1, 2] = 1 + """ + 没看懂这个参数的意思 + """ + sample_rate: int = 44100 + """ + 目标输出的采样率 + """ + value_get_method: Literal[0, 1] = 1 + """ + 采样取值方法,没看懂什么意思 + - 0 均值化 + - 1 钳制位 + """ + + pitch_accuracy_decimals: int = 0 + """ + 音调处理精度,小数点后位数 + """ + + global_deviation: float = 0 + """ + 全曲音调偏移 + """ + + +@music_output_plugin("music_to_pcm_plugin") +class NoteDataConvert2PcmPlugin(MusicOutputPluginBase): + metainfo = PluginMetaInformation( + name="全曲预览播放·PCM", + author="金羿、鱼旧梦", + description="从全曲的音符数据转换为可以用于播放的 PCM 编码数据", + version=(0, 0, 1), + type=PluginTypes.FUNCTION_MUSIC_EXPORT, + license="Apache 2.0", + ) + + def dump(self, data: SingleMusic, file_path: Path, config: PcmConversionConfig): + + pr = perf_counter_ns() + music_preview = MusicPreview( + resource_folder=config.assets_path, + synthesis_mode=config.synthesis_mode, + overlay_mode=config.overlay_mode, + sample_rate=config.sample_rate, + value_get_method=config.value_get_method, + pitch_accuracy_decimals=config.pitch_accuracy_decimals, + music_deviation=config.global_deviation, + ) + music_preview.to_wav_file(data, file_path) + af = perf_counter_ns() + print("合成用时", af - pr, "纳秒,即", (af - pr) / 1000_000_000, "秒") + + def stream_dump( + self, data: SingleMusic, config: PcmConversionConfig + ) -> Iterator[bytes]: + + pr = perf_counter_ns() + music_preview = MusicPreview( + resource_folder=config.assets_path, + synthesis_mode=config.synthesis_mode, + overlay_mode=config.overlay_mode, + sample_rate=config.sample_rate, + value_get_method=config.value_get_method, + pitch_accuracy_decimals=config.pitch_accuracy_decimals, + music_deviation=config.global_deviation, + ) + + b_out = BytesIO() + + music_preview.to_wav_file_byte(data, b_out) + af = perf_counter_ns() + print("合成用时", af - pr, "纳秒,即", (af - pr) / 1000_000_000, "秒") + + b_out.seek(0) + + yield from b_out diff --git a/Musicreater/builtin_plugins/music_preview/main.py b/Musicreater/builtin_plugins/music_preview/main.py index 844d4cd..ac14b1c 100644 --- a/Musicreater/builtin_plugins/music_preview/main.py +++ b/Musicreater/builtin_plugins/music_preview/main.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ -音·创 v3 内置的 音乐预览插件 +音·创 v3 内置的 音乐合成预览功能 """ """ @@ -33,8 +33,10 @@ limitations under the License. import os -import pathlib -from typing import Any, Union + +from math import inf +from typing import BinaryIO, Optional, Iterator, Generator, Any, Tuple, Literal +from pathlib import Path import soundfile as sf import librosa @@ -42,31 +44,345 @@ import numpy as np from Musicreater import SingleMusic, SingleTrack, SingleNote, SoundAtmos, MineNote -from Musicreater.plugins import ( - library_plugin, - PluginConfig, - PluginMetaInformation, - PluginTypes, - LibraryPluginBase, -) -from Musicreater.exceptions import ZeroSpeedError, IllegalMinimumVolumeError -from Musicreater._utils import enumerated_stuffcopy_dictionary +from Musicreater.constants import MM_INSTRUMENT_DEVIATION_TABLE +class MusicPreview: + """ + 原始资源存储规范: + Dict[str"ID1": Dict[str"SoundID": ndarray[]]] + 转换资源存储规范: + Dict[str"ID1": Dict[str"SoundID": Dict[int"Pitch": ndarray]]] + 测试阶段,请直接vanilla作为默认传入,不考虑载入 + """ + res_raw_data: dict = {} # 原始资源存储 + # res_raw_index: list = [] # 资源转换情况,key: [88] 留着优化 + res_data: dict = {} # 转换后资源存储 + res_data_sr: int # 转换后采样率,和输出采样率一致 + @staticmethod + def res_pitch_shift(y: np.ndarray, *, sr: float, n_steps: float, **kwargs: Any): + return librosa.effects.pitch_shift(y, sr=sr, n_steps=n_steps) + @staticmethod + def res_time_stretch(y: np.ndarray, *, rate: float, **kwargs: Any): + return librosa.effects.time_stretch(y, rate=rate) + @staticmethod + def res_resample( + y: np.ndarray, + *, + orig_sr: float, + target_sr: float, + fix: bool = True, + **kwargs: Any, + ): + return librosa.resample(y, orig_sr=orig_sr, target_sr=target_sr, fix=fix) -@library_plugin("notedata_to_pcm_plugin") -class NoteDataConvert2PcmPlugin(LibraryPluginBase): - metainfo = PluginMetaInformation( - name="音符数据预览播放支持插件·PCM", - author="金羿、鱼旧梦", - description="从音符数据转换为可以用于播放的 PCM 编码数据", - version=(0, 0, 1), - type=PluginTypes.LIBRARY, - license="Apache 2.0", - ) + def __init__( + self, + resource_folder: Path, + synthesis_mode: Literal[0, 1, 2, 3, 4] = 1, + overlay_mode: Literal[1, 2] = 1, + sample_rate: int = 44100, + value_get_method: Literal[0, 1] = 1, + pitch_accuracy_decimals: int = 0, + music_deviation: float = 0, + ): + self.mode = synthesis_mode + self.overlay_mode_c = overlay_mode + self.output_sample_rate = sample_rate + self.get_value_method = value_get_method + self.resources_path = resource_folder + self.pitch_precision_decimals = pitch_accuracy_decimals + self.pitch_deviation = music_deviation + + for file in self.resources_path.iterdir(): + if file.is_file(): + self.res_raw_data[file.stem] = librosa.load(file, sr=None) + + def res_shift( + self, + sound_name: str, + percussive: bool, + pitch: int, + duration: int, + # rate: int + ): + y_orig, sr_orig = self.res_raw_data[sound_name] + if not percussive: + if self.mode == 1: + # 变调, 时域压扩, 重采样 mc方法 + self.res_data[sound_name][pitch] = librosa.resample( + librosa.effects.time_stretch( + librosa.effects.pitch_shift(y_orig, sr=sr_orig, n_steps=pitch), + rate=2 ** (pitch / 12), + ), + orig_sr=sr_orig, + target_sr=self.output_sample_rate, + fix=False, + ) + elif self.mode == 0: + # 重采样, 变调 + self.res_data[sound_name][pitch] = librosa.resample( + librosa.effects.pitch_shift(y_orig, sr=sr_orig, n_steps=pitch), + orig_sr=sr_orig, + target_sr=self.output_sample_rate, + fix=False, + ) + elif self.mode == 4: + + # 变调, 时域压扩, 重采样 MIDI-FFT + if self.overlay_mode_c == 2: + rate = duration / 20 / (len(y_orig[0]) / sr_orig) + else: + rate = duration / 20 / (len(y_orig) / sr_orig) + rate = rate if rate != 0 else 1 + self.res_data[sound_name][pitch] = librosa.resample( + librosa.effects.time_stretch( + librosa.effects.pitch_shift(y_orig, sr=sr_orig, n_steps=pitch), + rate=rate, + ), + orig_sr=sr_orig, + target_sr=self.output_sample_rate, + fix=False, + ) + elif self.mode == 2: + # 变调, 时域压扩, 重采样 MIDI-cut + if self.overlay_mode_c == 2: + deal = librosa.effects.pitch_shift( + y_orig, sr=sr_orig, n_steps=pitch + )[ + ..., + : ( + int(duration / 20 * sr_orig) + if duration / 20 * sr_orig > len(y_orig[0]) + else len(y_orig[0]) + ), + ] + else: + deal = librosa.effects.pitch_shift( + y_orig, sr=sr_orig, n_steps=pitch + )[ + : ( + int(duration / 20 * sr_orig) + if duration / 20 * sr_orig > len(y_orig) + else len(y_orig) + ) + ] + self.res_data[sound_name][pitch] = librosa.resample( + deal, orig_sr=sr_orig, target_sr=self.output_sample_rate, fix=False + ) + else: + # if self.mode == 1: + # 重采样, 不变调 + print(">>", sound_name, pitch) + self.res_data[sound_name][pitch] = librosa.resample( + y_orig, + orig_sr=sr_orig, + target_sr=self.output_sample_rate, + fix=False, # 尽管这样不变调,但是也不应将 pitch 与打击乐器联系在一起 + ) + """elif self.mode == 0: + # 重采样, 不变调, 衰弱 + self.cache_dict[raw_name] = librosa_resample( + y_orig, + orig_sr=sr_orig, + target_sr=self.out_sr, + fix=False + )""" + + def make_pitch_accurate(self, pitch: float) -> int: + """ + 相信你知道的,小数并不准确 + """ + return int((pitch + self.pitch_deviation) * (10**self.pitch_precision_decimals)) + + # 获取资源方法,需要指定采样率,采样率变了即全局重载 + # 资源存储类里面有就取,没就写入并标识已有数据 + def get_res( + self, + sound_name: str, + pitch: float, + percussive: bool, + duration: int, + ): + + print( + sound_name, + pitch, + ">>", + (pitch := self.make_pitch_accurate(pitch)), + percussive, + duration, + ) + + if sound_name in self.res_data: + if pitch in self.res_data[sound_name]: + return self.res_data[sound_name][pitch] + else: + self.res_shift( + sound_name=sound_name, + percussive=percussive, + pitch=pitch, + duration=duration, + ) + # print(2, self.res_data[packageID][soundID][pitch]) + return self.res_data[sound_name][pitch] + else: + self.res_data[sound_name] = {} + self.res_shift( + sound_name=sound_name, + percussive=percussive, + pitch=pitch, + duration=duration, + ) + # print(2, self.res_data[packageID][soundID][pitch]) + return self.res_data[sound_name][pitch] + + def convert(self, music: SingleMusic, start_tick: float = 0, end_tick: float = inf): + + if self.overlay_mode_c == 1: + + def overlay(seg_overlay: np.ndarray, pos_tick: int): + pos_ = int(out_sr * pos_tick * 0.05) + wav_model[pos_ : seg_overlay.size + pos_] += seg_overlay + + # print(0) + wav_model = np.zeros( + int( + max( + [ + ( + i.start_tick * 0.05 * self.output_sample_rate + + len( + self.get_res( + sound_name=i.instrument, + pitch=i.pitch + - 60 + - MM_INSTRUMENT_DEVIATION_TABLE.get( + i.instrument, 6 + ), + percussive=i.percussive, + duration=i.duration_tick, + ) + ) + ) + for i in music.get_minenotes( + start_time=start_tick, end_time=end_tick + ) + ] + ) + ), + dtype=np.float32, + ) + + elif self.overlay_mode_c == 2: + + def overlay(seg_overlay: np.ndarray, pos_tick: int): + pos_ = int(out_sr * pos_tick * 0.05) + wav_model[..., pos_ : len(seg_overlay[0]) + pos_] += seg_overlay + + wav_model = np.zeros( + ( + 2, + int( + max( + [ + ( + i.start_tick * 0.05 * self.output_sample_rate + + len( + self.get_res( + sound_name=i.instrument, + pitch=i.pitch + - 60 + - MM_INSTRUMENT_DEVIATION_TABLE.get( + i.instrument, 6 + ), + percussive=i.percussive, + duration=i.duration_tick, + ) + ) + ) + for i in music.get_minenotes( + start_time=start_tick, end_time=end_tick + ) + ] + ) + ), + ), + dtype=np.float32, + ) + + else: + raise ValueError("错误的 overlay_mode 参数") + + out_sr = self.output_sample_rate + + for note in music.get_minenotes(start_time=start_tick, end_time=end_tick): + accurate_pitch = self.make_pitch_accurate( + note.pitch + - 60 + - ( + 0 + if note.percussive + else MM_INSTRUMENT_DEVIATION_TABLE.get(note.instrument, 6) + ) + ) + print( + ":", + note.instrument, + note.pitch, + ">>", + accurate_pitch, + note.percussive, + note.duration_tick, + ) + if not note.percussive: + overlay( + self.res_data[note.instrument][accurate_pitch] * note.volume / 127, + note.start_tick, + ) + else: + overlay( + self.res_data[note.instrument][accurate_pitch] * note.volume / 127, + note.start_tick, + ) + + if self.get_value_method == 0: + # 归一化,抚摸耳朵 (bushi + max_val = np.max(np.abs(wav_model)) + if not max_val == 0: + wav_model = wav_model / max_val + elif self.get_value_method == 1: + wav_model[wav_model > 1] = 1 + wav_model[wav_model < -1] = -1 + if self.overlay_mode_c == 2: + return wav_model.T + else: + return wav_model[:, np.newaxis] + + def stream(self, music: SingleMusic): + pass + + # debug + def to_wav_file(self, single_music: SingleMusic, out_file_path: Path): + + sf.write( + out_file_path, + self.convert(single_music), + samplerate=self.output_sample_rate, + format="wav", + ) + return out_file_path + + def to_wav_file_byte(self, single_music: SingleMusic, output_file: BinaryIO): + sf.write( + output_file, + self.convert(single_music), + samplerate=self.output_sample_rate, + format="wav", + ) diff --git a/Musicreater/builtin_plugins/music_preview/utils.py b/Musicreater/builtin_plugins/music_preview/utils.py new file mode 100644 index 0000000..e69de29 diff --git a/Musicreater/builtin_plugins/to_commands/main.py b/Musicreater/builtin_plugins/to_commands/main.py index 658a371..35c521d 100644 --- a/Musicreater/builtin_plugins/to_commands/main.py +++ b/Musicreater/builtin_plugins/to_commands/main.py @@ -96,7 +96,7 @@ class MineCommand: @library_plugin("notedata_to_command_plugin") class NoteDataConvert2CommandPlugin(LibraryPluginBase): metainfo = PluginMetaInformation( - name="音符数据指令支持插件", + name="音符数据的基岩版指令支持", author="金羿、玉衡Alioth", description="从音符数据转换为我的世界指令相关格式", version=(0, 0, 1), diff --git a/Musicreater/data.py b/Musicreater/data.py index 5371f92..3da853f 100644 --- a/Musicreater/data.py +++ b/Musicreater/data.py @@ -174,7 +174,7 @@ class SingleNote: midi_pitch: int Midi 音高 note_volume: int - 响度/力度(百廿七分比, 0~127) + 响度或曰力度(百廿七分比, 0~127) start_time: int 开始之时(命令刻) 注:此处的时间是用从乐曲开始到当前的刻数 @@ -377,9 +377,9 @@ class MineNote: pitch: float """Midi 音高""" instrument: str - """乐器 ID""" + """乐器标识""" volume: float - """力度/播放音量 0~127 百廿七分比""" + """力度或曰播放音量、响度 0~127 百廿七分比""" start_tick: int """开始之时 命令刻""" duration_tick: int @@ -387,7 +387,7 @@ class MineNote: start_time_offset: int """高精度开始时间偏量 1/5000 秒""" percussive: bool - """是否作为打击乐器启用""" + """是否作为打击乐器使用""" position: SoundAtmos """声像方位""" @@ -619,7 +619,7 @@ class SingleTrack(List[SingleNote]): return (x for x in self if x.precise_start_time == time) def get_notes( - self, start_time: float, end_time: float = inf + self, start_time: float = 0, end_time: float = inf ) -> Iterator[SingleNote]: """通过开始时间和结束时间来获取音符""" if end_time < start_time: @@ -791,7 +791,12 @@ class SingleMusic(List[SingleTrack]): # 没想到真的会用到全曲复制…… return self.from_track_list( - track_list=[tr.copy(with_argument_curve=True,) for tr in self], + track_list=[ + tr.copy( + with_argument_curve=True, + ) + for tr in self + ], name=self.music_name, creator=self.music_creator, original_author=self.music_original_author, @@ -875,13 +880,13 @@ class SingleMusic(List[SingleTrack]): # yield _remain[1] def get_tracked_notes( - self, start_time: float, end_time: float = inf + self, start_time: float = 0, end_time: float = inf ) -> Generator[Iterator[SingleNote], Any, None]: """获取指定时间段的各个音轨的音符数据""" return (track.get_notes(start_time, end_time) for track in self.music_tracks) def get_tracked_minenotes( - self, start_time: float, end_time: float = inf + self, start_time: float = 0, end_time: float = inf ) -> Generator[Iterator[MineNote], Any, None]: """获取指定时间段的各个音轨的,供我的世界播放的音符数据类""" return ( @@ -889,7 +894,7 @@ class SingleMusic(List[SingleTrack]): ) def get_notes( - self, start_time: float, end_time: float = inf + self, start_time: float = 0, end_time: float = inf ) -> Iterator[SingleNote]: """获取指定时间段的所有音符数据,按照时间顺序""" if self.track_amount == 0: @@ -900,7 +905,7 @@ class SingleMusic(List[SingleTrack]): ) def get_minenotes( - self, start_time: float, end_time: float = inf + self, start_time: float = 0, end_time: float = inf ) -> Iterator[MineNote]: """获取指定时间段所有的,供我的世界播放的音符数据类,按照时间顺序""" if self.track_amount == 0: