diff --git a/.gitignore b/.gitignore index 2eba8e0..bc8c252 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,10 @@ vanilla_assets/ /*.mid /*.midi +# Fack Apple +.DS_Store +# Fack Microsoft +Thumbs.db # Byte-compiled / optimized __pycache__/ diff --git a/Musicreater/__init__.py b/Musicreater/__init__.py index a86d371..a121fae 100644 --- a/Musicreater/__init__.py +++ b/Musicreater/__init__.py @@ -27,7 +27,7 @@ https://gitee.com/TriM-Organization/Musicreater/blob/master/LICENSE.md。 # 若需转载或借鉴 许可声明请查看仓库目录下的 License.md -__version__ = "3.0.0-alpha" +__version__ = "3.0.0-beta" __author__ = ( ("金羿", "Eilles"), @@ -44,6 +44,8 @@ from .data import ( SoundAtmos, MineNote, CurvableParam, + Instrument, + DefaultInstrument, ) from .plugins import load_plugin_from_module @@ -64,7 +66,10 @@ __all__ = [ "SoundAtmos", "MineNote", "CurvableParam", + "Instrument", # 工程项目相关 "load_plugin_from_module", "MusiCreater", + # 默认乐器 + "DefaultInstrument", ] diff --git a/Musicreater/_utils.py b/Musicreater/_utils.py index df69e93..8f8b7f0 100644 --- a/Musicreater/_utils.py +++ b/Musicreater/_utils.py @@ -62,3 +62,47 @@ def incremental_save_path( ).exists(): now_appendix += 1 return ss_path + + +def volume_weight_to_sound_distance( + max_weight: float, minimal_loadness: int, weight: float, loadness: int +) -> float: + """ + 通过音量权计算声源距离 + + Parameters + ========== + max_weight: float + 全曲最大音量权 + minimal_loadness: int + 曲目中响度最小乐器的响度 + weight: float + 当前乐器的音量权 + loadness: int + 当前乐器的响度 + + Returns + ======= + float + 声源距离 + + Notes + ===== + + 以下关于距离的计算逻辑请见[计算过程](./resources/响度公式/计算过程.jpg)\n + 关于响度的计算方法请见[此脚本](./resources/experiments/exam_loadness.py)\n + 最终公式为:\n + 声源距离 = 48 - (48 * 音量权 * ( 10 ** ((当前乐器的响度 - 响度最小乐器的响度) / 20 ))) / 全曲最大音量权\n + D_t = 48 - \\frac{48 \\cdot P_t \\cdot 10^{\\frac{V_t - V_x}{20}}}{P_m} + 由于我们存储的响度都是 百-LUFS,所以下面的计算除数为 2000 + """ + + if weight == 0: + return 48 + elif weight == max_weight: + # print("音量权最大,差比:", ((loadness - minimal_loadness) / 2000), ",率:", (1 - pow(10, (minimal_loadness - loadness)/2000))) + return 48 * (1 - pow(10, (minimal_loadness - loadness) / 2000)) + else: + return 48 * ( + 1 - pow(10, (minimal_loadness - loadness) / 2000) * weight / max_weight + ) diff --git a/Musicreater/builtin_plugins/midi_read/__init__.py b/Musicreater/builtin_plugins/midi_read/__init__.py index 847ad50..ddbf9f8 100644 --- a/Musicreater/builtin_plugins/midi_read/__init__.py +++ b/Musicreater/builtin_plugins/midi_read/__init__.py @@ -33,20 +33,13 @@ from .constants import ( MM_NBS_PERCUSSION_INSTRUMENT_TABLE, ) -from .utils import ( - volume_2_distance_natural, - volume_2_distance_straight, - panning_2_rotation_linear, - panning_2_rotation_trigonometric, -) +from .utils import panning_2_rotation_linear, panning_2_rotation_trigonometric __all__ = [ # 插件参数和插件本体类 "MidiImportConfig", "MidiImport2MusicPlugin", # 内置的拟合函数 - "volume_2_distance_straight", - "volume_2_distance_natural", "panning_2_rotation_linear", "panning_2_rotation_trigonometric", # Midi 相关默认值 diff --git a/Musicreater/builtin_plugins/midi_read/main.py b/Musicreater/builtin_plugins/midi_read/main.py index 72e1ecb..a370573 100644 --- a/Musicreater/builtin_plugins/midi_read/main.py +++ b/Musicreater/builtin_plugins/midi_read/main.py @@ -23,7 +23,15 @@ from enum import Enum from pathlib import Path from typing import BinaryIO, Optional, Dict, List, Callable, Tuple, Mapping -from Musicreater import SingleMusic, SingleTrack, SingleNote, SoundAtmos +from Musicreater import ( + SingleMusic, + SingleTrack, + SingleNote, + SoundAtmos, + CurvableParam, + ParamCurve, + Instrument, +) from Musicreater.plugins import ( music_input_plugin, PluginConfig, @@ -31,7 +39,9 @@ from Musicreater.plugins import ( PluginTypes, MusicInputPluginBase, ) +from Musicreater.constants import SoundInformation, MC_INSTRUMENT_SOUND_INFO_TABLE from Musicreater.exceptions import ZeroSpeedError, IllegalMinimumVolumeError +from Musicreater.paramcurve import Keyframe as ParamKeyframe from Musicreater._utils import enumerated_stuffcopy_dictionary from .constants import ( @@ -46,7 +56,6 @@ from .exceptions import ( LyricMismatchError, ) from .utils import ( - volume_2_distance_natural, panning_2_rotation_trigonometric, midi_msgs_to_noteinfo, ) @@ -76,9 +85,10 @@ class MidiImportConfig(PluginConfig): pitched_note_reference_table: Mapping[int, str] = None # type: ignore percussion_note_reference_table: Mapping[int, str] = None # type: ignore note_replacement_table: Mapping[str, str] = None # type: ignore + instrument_information_table: Mapping[str, SoundInformation] = None # type: ignore - # 参数转换函数 - volume_process_function: Callable[[float], float] = volume_2_distance_natural + # 参数转换相关 + keep_velocity_to_param_curve: bool = True panning_processing_function: Callable[[float], float] = ( panning_2_rotation_trigonometric ) @@ -104,6 +114,11 @@ class MidiImportConfig(PluginConfig): self.note_replacement_table = ( self.note_replacement_table if self.note_replacement_table else {} ) + self.instrument_information_table = ( + self.instrument_information_table + if self.instrument_information_table + else MC_INSTRUMENT_SOUND_INFO_TABLE + ) class ControlerKeys(Enum): @@ -140,6 +155,8 @@ class TrackDivisionDict( division_by_volume: bool = False division_by_panning: bool = False + create_volume_curve: bool = True + def __init__( self, *args, @@ -173,7 +190,9 @@ class TrackDivisionDict( try: return super().__getitem__(key) except KeyError: - self[key] = SingleTrack() + self[key] = SingleTrack(precise_time=True) + if self.create_volume_curve: + self[key].argument_curves[CurvableParam.VOLUME] = ParamCurve() return self[key] @@ -401,50 +420,51 @@ class MidiImport2MusicPlugin(MusicInputPluginBase): # 更新结果信息 - that_note, sound_name, orign_distance, sound_rotation = ( - midi_msgs_to_noteinfo( - inst=( - msg.note - if (_is_percussion := (msg.channel == 9)) - else _program - ), - note=(_program if _is_percussion else msg.note), - percussive=_is_percussion, - volume=_volume, - velocity=_velocity, - panning=_panning, - start_time=_start_ms, # 微秒 - duration=microseconds - _start_ms, # 微秒 - play_speed=config.speed_multiplier, - midi_reference_table=( - config.percussion_note_reference_table - if _is_percussion - else config.pitched_note_reference_table - ), - volume_processing_method=config.volume_process_function, - panning_processing_method=config.panning_processing_function, - note_table_replacement=config.note_replacement_table, - lyric_line=( - _lyric.encode("latin1").decode( - config.string_encoding - ) - if config.string_encoding - else _lyric - ), - ) + that_note, sound_name, sound_rotation = midi_msgs_to_noteinfo( + inst=( + msg.note + if (_is_percussion := (msg.channel == 9)) + else _program + ), + note=(_program if _is_percussion else msg.note), + percussive=_is_percussion, + volume=_volume, + velocity=_velocity, + panning=_panning, + start_time=_start_ms, # 微秒 + duration=microseconds - _start_ms, # 微秒 + play_speed=config.speed_multiplier, + midi_reference_table=( + config.percussion_note_reference_table + if _is_percussion + else config.pitched_note_reference_table + ), + panning_processing_method=config.panning_processing_function, + note_table_replacement=config.note_replacement_table, + lyric_line=( + _lyric.encode("latin1").decode(config.string_encoding) + if config.string_encoding + else _lyric + ), ) # print(that_note.start_time, end=", ") - divided_tracks[ + now_track = divided_tracks[ ( track_no, msg.channel, sound_name, - orign_distance, + _volume, sound_rotation, ) - ].add(that_note) + ] + now_track.add(that_note) + if config.keep_velocity_to_param_curve: + now_track.argument_curves[CurvableParam.VOLUME].add_key( # type: ignore + time=that_note.precise_start_time, + value=_velocity / 127, + ) # 更新统计信息 note_count += 1 @@ -500,11 +520,15 @@ class MidiImport2MusicPlugin(MusicInputPluginBase): ): # 音轨编号 every_single_track.name = track_name if track_properties[2]: # 乐器名称 - every_single_track.instrument = track_properties[2] - if track_properties[3]: # 音量 - every_single_track.sound_position.sound_distance = track_properties[3] + every_single_track.instrument = Instrument( + track_properties[2], + config.instrument_information_table[track_properties[2]]["C-LUFS"], + ) + if track_properties[3]: # 音量权 + every_single_track.__volume_weight = track_properties[3] if track_properties[4]: # 声相 - every_single_track.sound_position.sound_azimuth = track_properties[4] - final_music.append(every_single_track) - + every_single_track.sound_position = track_properties[4] + super(SingleMusic, final_music).append(every_single_track) + final_music._calc_values() + # print("所有声源距离表:",{i:t.sound_distance for i, t in enumerate(final_music)}) return final_music diff --git a/Musicreater/builtin_plugins/midi_read/utils.py b/Musicreater/builtin_plugins/midi_read/utils.py index 6ba03fd..cfae24d 100644 --- a/Musicreater/builtin_plugins/midi_read/utils.py +++ b/Musicreater/builtin_plugins/midi_read/utils.py @@ -24,50 +24,6 @@ from typing import Callable, Dict, List, Optional, Sequence, Tuple, Mapping from Musicreater import SingleNote, SoundAtmos -def volume_2_distance_natural( - vol: float, -) -> float: - """ - Midi 力度值/音量值拟合成的距离函数,一种更加自然的听感? - - Parameters - ---------- - vol: int - Midi 音符力度值(0~127) - - Returns - ------- - float播放中心到玩家的距离 - """ - return ( - -8.081720684086314 - * math.log( - vol + 14.579508825070013, - ) - + 37.65806375944386 - if vol < 60.64 - else 0.2721359356095803 * ((vol + 2592.272889454798) ** 1.358571233418649) - + -6.313841334963396 * (vol + 2592.272889454798) - + 4558.496367823575 - ) - - -def volume_2_distance_straight(vol: float) -> float: - """ - Midi 力度值/音量值拟合成的距离函数,线性转换 - - Parameters - ---------- - vol: int - Midi 音符力度值(0~127) - - Returns - ------- - float播放中心到玩家的距离 - """ - return (vol + 1) / -8 + 16 - - def panning_2_rotation_linear(pan_: float) -> float: """ Midi 左右平衡偏移值线性转为声源旋转角度 @@ -147,11 +103,10 @@ def midi_msgs_to_noteinfo( duration: int, play_speed: float, midi_reference_table: Mapping[int, str], - volume_processing_method: Callable[[float], float], panning_processing_method: Callable[[float], float], note_table_replacement: Mapping[str, str] = {}, lyric_line: str = "", -) -> Tuple[SingleNote, str, float, Tuple[float, float]]: +) -> Tuple[SingleNote, str, Tuple[float, float]]: """ 将 Midi信息转为音符对象 @@ -177,8 +132,6 @@ def midi_msgs_to_noteinfo( 曲目播放速度 midi_reference_table: Dict[int, str] 转换对照表 - volume_processing_method: Callable[[float], float] - 音量处理函数 panning_processing_method: Callable[[float], float] 立体声相偏移处理函数 note_table_replacement: Dict[str, str] @@ -191,7 +144,6 @@ def midi_msgs_to_noteinfo( Tuple[ MineNote我的世界音符对象, str我的世界声音名, - float播放中心到玩家的距离, Tuple[float, float]声源旋转角度 ] """ @@ -204,7 +156,6 @@ def midi_msgs_to_noteinfo( return ( SingleNote( note_pitch=note, - note_volume=velocity, # 需要重新设计 start_tick=(tk := int(start_time / float(play_speed) / 50000)), keep_tick=round(duration / float(play_speed) / 50000), mass_precision_time=round( @@ -212,11 +163,11 @@ def midi_msgs_to_noteinfo( ), extra_information={ "LYRIC_TEXT": lyric_line, + "VELOCITY_VALUE": velocity, "VOLUME_VALUE": volume, "PIN_VALUE": panning, }, ), note_table_replacement.get(mc_sound_ID, mc_sound_ID), - volume_processing_method(volume), (panning_processing_method(panning), 0), ) diff --git a/Musicreater/builtin_plugins/to_commands/main.py b/Musicreater/builtin_plugins/to_commands/main.py index 35c521d..a52edbd 100644 --- a/Musicreater/builtin_plugins/to_commands/main.py +++ b/Musicreater/builtin_plugins/to_commands/main.py @@ -510,7 +510,6 @@ class NoteDataConvert2CommandPlugin(LibraryPluginBase): ( relative_coordinates, - volume_percentage, mc_pitch, ) = minenote_to_command_parameters( note, @@ -527,10 +526,9 @@ class NoteDataConvert2CommandPlugin(LibraryPluginBase): .replace("(", r"{") .replace(")", r"}") ) - + r"playsound {} @s ^{} ^{} ^{} {} {} {}".format( + + "playsound {} @s ^{} ^{} ^{} 3.0 {} {}".format( track.instrument, *relative_coordinates, - volume_percentage, 1.0 if note.percussive else mc_pitch, minimum_volume, ) @@ -595,7 +593,6 @@ class NoteDataConvert2CommandPlugin(LibraryPluginBase): ( relative_coordinates, - volume_percentage, mc_pitch, ) = minenote_to_command_parameters( note, @@ -606,10 +603,9 @@ class NoteDataConvert2CommandPlugin(LibraryPluginBase): MineCommand( command=( execute_command_head.format(player_selector) - + "playsound {} @s ^{} ^{} ^{} {} {} {}".format( + + "playsound {} @s ^{} ^{} ^{} 3.0 {} {}".format( note.instrument, *relative_coordinates, - volume_percentage, 1.0 if note.percussive else mc_pitch, minimum_volume, ) diff --git a/Musicreater/builtin_plugins/to_commands/utils.py b/Musicreater/builtin_plugins/to_commands/utils.py index a6c563e..cf80bfc 100644 --- a/Musicreater/builtin_plugins/to_commands/utils.py +++ b/Musicreater/builtin_plugins/to_commands/utils.py @@ -38,7 +38,6 @@ def minenote_to_command_parameters( pitch_deviation: float = 0, ) -> Tuple[ Tuple[float, float, float], - float, Union[float, Literal[None]], ]: """ @@ -53,13 +52,12 @@ def minenote_to_command_parameters( 返回 ---- - tuple[float, float, float], float, float - 播放视角坐标, 指令音量参数, 指令音调参数 + tuple[float, float, float], float + 播放视角坐标, 指令音调参数 """ return ( mine_note.position.position_displacement, - mine_note.volume / 127, ( None if mine_note.percussive diff --git a/Musicreater/constants.py b/Musicreater/constants.py index ed13b5b..c1c44a2 100644 --- a/Musicreater/constants.py +++ b/Musicreater/constants.py @@ -17,7 +17,7 @@ Terms & Conditions: License.md in the root directory # 若需转载或借鉴 许可声明请查看仓库目录下的 License.md # from .types import Dict, List, Tuple, MidiInstrumentTableType, MidiNoteNameTableType -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, TypedDict x = "x" """ @@ -438,7 +438,17 @@ MC_INSTRUMENT_BLOCKS_TABLE: Dict[str, Tuple[str, ...]] = { } """MC乐器对音符盒下垫方块对照表""" -MC_INSTRUMENT_SOUND_INFO_TABLE: Dict[str, Dict[str, float]] = { +SoundInformation = TypedDict( + "SoundInformation", + { + "C-LUFS": int, + "MS": float, + "SR": int, + }, +) +"""MC乐器信息类定义""" + +MC_INSTRUMENT_SOUND_INFO_TABLE: Dict[str, SoundInformation] = { "note.bass": {"C-LUFS": -1178, "MS": 532.0, "SR": 7218}, "note.bassattack": {"C-LUFS": -1188, "MS": 484.8, "SR": 7921}, "note.snare": {"C-LUFS": -1173, "MS": 80.0, "SR": 48000}, @@ -514,6 +524,7 @@ MC_INSTRUMENT_VOLUME_BALANCE_TABLE: Dict[str, float] = { "mob.zombie.wood": 0.32247793193163765, } """乐器响度平衡表,倍率""" +# 上表用处不大 MC_EILLES_RT261_INSTRUMENT_REPLACE_TABLE: Dict[str, str] = { "note.trumpet": "note.flute", @@ -625,8 +636,4 @@ MM_INSTRUMENT_DEVIATION_TABLE: Dict[str, int] = { # 金羿ELS 音符方块对照表 -MN_EILLES_NOTE_BLOCK_TABLE: Dict[int, str] = { -} - - - +MN_EILLES_NOTE_BLOCK_TABLE: Dict[int, str] = {} diff --git a/Musicreater/data.py b/Musicreater/data.py index 3f76ddb..4c38879 100644 --- a/Musicreater/data.py +++ b/Musicreater/data.py @@ -29,6 +29,7 @@ from typing import ( Optional, Any, List, + SupportsIndex, Tuple, Union, Dict, @@ -42,10 +43,17 @@ from typing import ( Hashable, TypeVar, Mapping, + overload, ) from enum import Enum -from .exceptions import SingleNoteDecodeError, ParameterTypeError, ParameterValueError +from ._utils import volume_weight_to_sound_distance +from .exceptions import ( + SingleNoteDecodeError, + ParameterTypeError, + ParameterValueError, + ParameterCacheUnfreshError, +) from .paramcurve import ParamCurve T = TypeVar("T") @@ -149,6 +157,44 @@ class SoundAtmos: return SoundAtmos(self.sound_distance, self.sound_azimuth) +@dataclass +class Instrument: + """乐器类""" + + name: str + """乐器名称""" + + loadness: int + """乐器响度 单位`百 LUFS`""" + + def copy(self) -> "Instrument": + return Instrument(self.name, self.loadness) + + +class DefaultInstrument(Enum): + BASS = Instrument("note.bass", -1178) + BASSATTACK = Instrument("note.bassattack", -1188) + SNARE = Instrument("note.snare", -1173) + HAT = Instrument("note.hat", -1480) + BD = Instrument("note.bd", -957) + BELL = Instrument("note.bell", -2510) + FLUTE = Instrument("note.flute", -2076) + CHIME = Instrument("note.chime", -2984) + GUITAR = Instrument("note.guitar", -2539) + XYLOPHONE = Instrument("note.xylophone", -2419) + IRON_XYLOPHONE = Instrument("note.iron_xylophone", -2078) + COW_BELL = Instrument("note.cow_bell", -1427) + DIDGERIDOO = Instrument("note.didgeridoo", -2062) + BIT = Instrument("note.bit", -2677) + BANJO = Instrument("note.banjo", -2207) + PLING = Instrument("note.pling", -1528) + TRUMPET = Instrument("note.trumpet", -1678) + TRUMPET_EXPOSED = Instrument("note.trumpet_exposed", -1682) + TRUMPET_OXIDIZED = Instrument("note.trumpet_oxidized", -2225) + TRUMPET_WEATHERED = Instrument("note.trumpet_weathered", -1875) + HARP = Instrument("note.harp", -1448) + + @dataclass(init=False) class SingleNote: """存储单个音符的类""" @@ -156,9 +202,6 @@ class SingleNote: midi_pitch: int """Midi 音高""" - volume: int - """力度/播放响度 0~127 百廿七分比""" - start_time: int """开始之时 命令刻""" @@ -174,7 +217,6 @@ class SingleNote: def __init__( self, note_pitch: Optional[int], - note_volume: int, start_tick: int, keep_tick: int, mass_precision_time: int = 0, @@ -187,8 +229,6 @@ class SingleNote: ------------ midi_pitch: int Midi 音高 - note_volume: int - 响度或曰力度(百廿七分比, 0~127) start_time: int 开始之时(命令刻) 注:此处的时间是用从乐曲开始到当前的刻数 @@ -208,8 +248,6 @@ class SingleNote: self.midi_pitch: int = 66 if note_pitch is None else note_pitch """Midi 音高""" - self.volume: int = note_volume - """响度(力度)""" self.start_time: int = start_tick """开始之时 命令刻""" self.duration: int = keep_tick @@ -228,19 +266,17 @@ class SingleNote: def decode(cls, code_buffer: bytes, is_high_time_precision: bool = True): """自字节码析出 SingleNote 类""" duration_ = ( - group_1 := int.from_bytes(code_buffer[:6], "big") + group_1 := int.from_bytes(code_buffer[:5], "big") ) & 0b11111111111111111 - start_tick_ = (group_1 := group_1 >> 17) & 0b11111111111111111 - note_volume_ = (group_1 := group_1 >> 17) & 0b1111111 - note_pitch_ = (group_1 := group_1 >> 7) & 0b1111111 + start_tick_ = (group_1 := group_1 >> 17) & 0b111111111111111111 + note_pitch_ = (group_1 := group_1 >> 18) & 0b1111111 try: return cls( note_pitch=note_pitch_, - note_volume=note_volume_, start_tick=start_tick_, keep_tick=duration_, - mass_precision_time=code_buffer[6] if is_high_time_precision else 0, + mass_precision_time=code_buffer[5] if is_high_time_precision else 0, ) except Exception as e: # 我也不知道为什么这里要放一个异常处理 @@ -275,20 +311,15 @@ class SingleNote: # SingleNote 的字节码 # note_pitch 7 位 支持到 127 - # volume 长度 7 位 支持到 127 - # start_tick 17 位 支持到 131071 即 109.22583 分钟 合 1.8204305 小时 + # start_tick 18 位 支持到 131071 即 109.22583 分钟 合 1.8204305 小时 # duration 17 位 支持到 131071 即 109.22583 分钟 合 1.8204305 小时 - # 共 48 位 合 6 字节 + # 共 40 位 合 5 字节 # high_time_precision(可选)长度 8 位 支持到 255 合 1 字节 单位为 1/5000 秒] return ( ( - ( - ((((self.midi_pitch << 7) + self.volume) << 17) + self.start_time) - << 17 - ) - + self.duration + ((((self.midi_pitch) << 18) + self.start_time) << 17) + self.duration ).to_bytes(6, "big") # + self.track_no.to_bytes(1, "big") + ( @@ -320,37 +351,38 @@ class SingleNote: return self.extra_info.get(key, default) def stringize(self, include_extra_data: bool = False) -> str: - return "TrackedNote(Pitch = {}, Volume = {}, StartTick = {}, Duration = {}, TimeOffset = {}".format( + return "Note(Pitch = {}, StartTick = {}, Duration = {}, TimeOffset = {}".format( self.midi_pitch, - self.volume, self.start_time, self.duration, self.high_precision_start_time, - ) + ( - ", ExtraData = {})".format(self.extra_info) if include_extra_data else ")" - ) + ) + (", ExtraData = {})".format(self.extra_info) if include_extra_data else ")") - def copy(self) -> "SingleNote": + def copy( + self, + note_pitch: Optional[int] = None, + start_tick: Optional[int] = None, + keep_tick: Optional[int] = None, + mass_precision_time: Optional[int] = None, + ) -> "SingleNote": return SingleNote( - note_pitch=self.midi_pitch, - note_volume=self.volume, - start_tick=self.start_time, - keep_tick=self.duration, - mass_precision_time=self.high_precision_start_time, + note_pitch=note_pitch or self.midi_pitch, + start_tick=start_tick or self.start_time, + keep_tick=keep_tick or self.duration, + mass_precision_time=mass_precision_time or self.high_precision_start_time, ) # def __list__(self) -> List[int]: # 我不认为这个类应当被作为列表使用 def __bool__(self) -> bool: - return bool(self.volume) + return self.duration > 0 def __tuple__( self, - ) -> Tuple[int, int, int, int, int]: + ) -> Tuple[int, int, int, int]: return ( self.midi_pitch, - self.volume, self.start_time, self.duration, self.high_precision_start_time, @@ -359,7 +391,6 @@ class SingleNote: def __dict__(self): return { "Pitch": self.midi_pitch, - "Volume": self.volume, "StartTick": self.start_time, "Duration": self.duration, "TimeOffset": self.high_precision_start_time, @@ -395,8 +426,6 @@ class MineNote: """Midi 音高""" instrument: str """乐器标识""" - volume: float - """力度或曰播放音量、响度 0~127 百廿七分比""" start_tick: int """开始之时 命令刻""" duration_tick: int @@ -418,30 +447,57 @@ class MineNote: cls, note: SingleNote, note_instrument: str, + note_volume_weight: float, is_persiced_time: bool, is_percussive_note: bool, sound_position: SoundAtmos, adjust_note_pitch: float = 0.0, - adjust_note_volume: float = 0.0, + adjust_note_volume_weight: float = 0.0, adjust_note_sound_distance: float = 0.0, adjust_note_leftright_panning_degree: float = 0.0, adjust_note_updown_panning_degree: float = 0.0, ) -> "MineNote": """从SingleNote对象创建MineNote对象""" - sound_position.sound_distance += adjust_note_sound_distance - sound_position.sound_azimuth = ( - sound_position.sound_azimuth[0] + adjust_note_leftright_panning_degree, - sound_position.sound_azimuth[1] + adjust_note_updown_panning_degree, - ) return cls( pitch=note.midi_pitch + adjust_note_pitch, instrument=note_instrument, - volume=note.volume + adjust_note_volume, start_tick=note.start_time, duration_tick=note.duration, start_time_offset=note.high_precision_start_time if is_persiced_time else 0, percussive=is_percussive_note, - position=sound_position, + position=( + SoundAtmos( + sound_position.sound_distance + + adjust_note_sound_distance + + ( + ( + ( + ( + (sound_position.sound_distance - 48) + * (note_volume_weight + adjust_note_volume_weight) + / note_volume_weight + ) + + 48 + ) + ) + if adjust_note_volume_weight + else 0 + ), + ( + sound_position.sound_azimuth[0] + + adjust_note_leftright_panning_degree, + sound_position.sound_azimuth[1] + + adjust_note_updown_panning_degree, + ), + ) + if ( + adjust_note_volume_weight + or adjust_note_sound_distance + or adjust_note_leftright_panning_degree + or adjust_note_updown_panning_degree + ) + else sound_position + ), ) @@ -449,7 +505,7 @@ class CurvableParam(str, Enum): """可曲线化的参数 枚举类""" PITCH = "adjust_note_pitch" - VOLUME = "adjust_note_volume" + VOLUME = "adjust_note_volume_weight" DISTANCE = "adjust_note_sound_distance" LR_PANNING = "adjust_note_leftright_panning_degree" UD_PANNING = "adjust_note_updown_panning_degree" @@ -464,7 +520,7 @@ class SingleTrack(List[SingleNote]): is_enabled: bool = True """该音轨是否启用""" - instrument: str + instrument: Instrument """乐器ID""" is_high_time_precision: bool @@ -473,8 +529,14 @@ class SingleTrack(List[SingleNote]): is_percussive: bool """该音轨是否标记为打击乐器轨道""" - sound_position: SoundAtmos - """声像方位""" + __volume_weight: float = 10 + """音量权""" + + __sound_distance: float = -1 + """声源距离""" + + sound_position: Tuple[float, float] + """声像方位 球坐标系角度(rV左右 rH上下)""" argument_curves: Dict[CurvableParam, Union[ParamCurve, Literal[None]]] """参数曲线""" @@ -482,20 +544,50 @@ class SingleTrack(List[SingleNote]): extra_info: Dict[str, Any] """你觉得放什么好?""" + # 构建类 + def __init__( self, *args: SingleNote, track_name: str = "未命名音轨", - track_instrument: str = "", + track_instrument: Optional[Instrument] = None, precise_time: bool = True, percussion: bool = False, - sound_direction: Optional[SoundAtmos] = None, + volume_weight: float = 10.0, + sound_direction: Tuple[float, float] = (0, 0), extra_information: Dict[str, Any] = {}, ): + """ + 构建音轨 + + Parameters + ========== + *args: SingleNote + 音符列表 + track_name: str + 音轨名称 + track_instrument: str + 音轨所使用的乐器名称 + precise_time: bool + 该音轨是否使用高精度时间 + percussion: bool + 该音轨的乐器是否为打击乐器(无音高调整) + volume_weight: float + 音量权,用以表明音轨的音量应当占有的权重大小。值越大,听起来声音越大。 + 默认为 10 + sound_direction: Tuple[float, float] + 声源方位 + 注:此参数为tuple,包含两个元素,分别表示: + `rV` 发声源在竖直(上下)轴上,从玩家视角正前方开始,向顺时针旋转的角度 + `rH` 发声源在水平(左右)轴上,从玩家视角正前方开始,向上 + (到达玩家正上方顶点后变为向下,以此类推的旋转)旋转的角度 + """ self.name = track_name """音轨名称""" - self.instrument = track_instrument + self.instrument = ( + track_instrument if track_instrument else DefaultInstrument.HARP.value + ) """乐器ID""" self.is_high_time_precision = precise_time @@ -504,8 +596,13 @@ class SingleTrack(List[SingleNote]): self.is_percussive = percussion """是否为打击乐器""" - # 如果不这样的话,所有的新的 SingleTrack 类都会有一个共同的声像方位 - self.sound_position = sound_direction if sound_direction else SoundAtmos() + self.__volume_weight = volume_weight + """音量权""" + + self.__sound_distance = -1 + """声源距离""" + + self.sound_position = sound_direction """声像方位""" self.extra_info = extra_information if extra_information else {} @@ -520,10 +617,11 @@ class SingleTrack(List[SingleNote]): cls, note_list: Iterable[SingleNote], track_name: str = "未命名音轨", - track_instrument: str = "", + track_instrument: Optional[Instrument] = None, precise_time: bool = True, percussion: bool = False, - sound_direction: Optional[SoundAtmos] = None, + volume_weight: float = 10.0, + sound_direction: Tuple[float, float] = (0, 0), extra_information: Dict[str, Any] = {}, ) -> "SingleTrack": """从音符列表创建 SingleTrack 对象""" @@ -533,12 +631,36 @@ class SingleTrack(List[SingleNote]): track_instrument=track_instrument, precise_time=precise_time, percussion=percussion, + volume_weight=volume_weight, sound_direction=sound_direction, extra_information=extra_information, ) single_track.update(note_list) return single_track + # 类参数 + + @property + def volume_weight(self) -> float: + """音量权""" + return self.__volume_weight + + @property + def sound_distance(self) -> float: + """声源距离""" + if self.__sound_distance < 0: + raise ParameterCacheUnfreshError( + "未设置声源距离(`{}`),操作时不应直接访问 `__volume_weight` 属性".format( + self.__sound_distance + ) + ) + return self.__sound_distance + + @property + def note_amount(self) -> int: + """音符数""" + return len(self) + def disable(self) -> None: """禁用音轨""" @@ -554,6 +676,36 @@ class SingleTrack(List[SingleNote]): self.is_enabled = not self.is_enabled + def set_volume_weight( + self, max_weight: float, minimal_loadness: int, weight + ) -> float: + """设置音量权 + + 以下关于距离的计算逻辑请见[计算过程](./resources/响度公式/计算过程.jpg)\n + 关于响度的计算方法请见[此脚本](./resources/experiments/exam_loadness.py)\n + 最终公式为:\n + 声源距离 = 48 - (48 * 音量权 * ( 10 ** ((当前乐器的响度 - 响度最小乐器的响度) / 20 ))) / 全曲最大音量权\n + D_t = 48 - \\frac{48 \\cdot P_t \\cdot 10^{\\frac{V_t - V_x}{20}}}{P_m} + 由于我们存储的响度都是 百-LUFS,所以下面的计算除数为 2000 + """ + + self.__volume_weight = weight + + self.__sound_distance = volume_weight_to_sound_distance( + max_weight, minimal_loadness, weight, self.instrument.loadness + ) + # print( + # "收到音量权:", + # weight, + # ",最大权:", + # max_weight, + # ",设置声源距离为:", + # self.__sound_distance, + # ) + return self.__sound_distance + + # 类操作 + def append(self, object: SingleNote) -> None: """ 添加一个音符,推荐使用 add 方法 @@ -594,12 +746,18 @@ class SingleTrack(List[SingleNote]): 通过时间范围来复制音轨,返回一个音轨 """ single_track = SingleTrack.from_note_list( - [x for x in self if start_time <= x.precise_start_time <= end_time], - track_instrument=self.instrument, + ( + [x for x in self if start_time <= x.precise_start_time <= end_time] + if self.is_high_time_precision + else [x for x in self if start_time <= x.start_time <= end_time] + ), + track_instrument=self.instrument.copy(), precise_time=self.is_high_time_precision, percussion=self.is_percussive, - sound_direction=self.sound_position.copy(), + volume_weight=self.volume_weight, + sound_direction=self.sound_position, ) + single_track.__sound_distance = self.sound_distance if with_argument_curve: single_track.argument_curves = { item: ( @@ -616,6 +774,75 @@ class SingleTrack(List[SingleNote]): } return single_track + def paste( + self, + items: Iterable[SingleNote], + offset_time: int, + offset_mass_precision_time: int = 0, + is_passin_time_precise: bool = True, + ): + """ + 将一个音轨粘贴到当前音轨的指定时间 + """ + note_iterator = items.__iter__() + first_note = next(note_iterator) + original_start_time = first_note.start_time + super().append( + first_note.copy( + start_tick=offset_time, + mass_precision_time=( + offset_mass_precision_time if self.is_high_time_precision else 0 + ), + ) + ) + if self.is_high_time_precision: + for note in note_iterator: + super().append( + note.copy( + start_tick=note.start_time + - original_start_time + + offset_time + + ( + ( + offset_mass_precision_time + + note.high_precision_start_time + ) + // 250 + ), + mass_precision_time=( + offset_mass_precision_time + note.high_precision_start_time + ) + % 250, + ) + ) + else: + if is_passin_time_precise: + for note in note_iterator: + super().append( + note.copy( + start_tick=note.start_time + - original_start_time + + offset_time + + ( + ( + offset_mass_precision_time + + note.high_precision_start_time + ) + // 250 + ) + ) + ) + else: + for note in note_iterator: + super().append( + note.copy( + start_tick=note.start_time + - original_start_time + + offset_time + ) + ) + self.sort() # =========================== TODO 需要优化 + def delete( self, start_time: float, end_time: float, with_argument_curve: bool = False ): @@ -636,6 +863,35 @@ class SingleTrack(List[SingleNote]): ): del self[j - i] + # 音符操作 + + @property + def notes(self) -> List[SingleNote]: + """音符列表""" + return self + + @property + def minenotes(self) -> Iterator[MineNote]: + """ + 直接返回当前音轨所有音符的我的世界数据形式 + """ + return ( + MineNote.from_single_note( + note=_note, + note_instrument=self.instrument.name, + note_volume_weight=self.volume_weight, + is_persiced_time=self.is_high_time_precision, + is_percussive_note=self.is_percussive, + sound_position=SoundAtmos(self.sound_distance, self.sound_position), + **{ + item.value: argcrv.value_at(_note.precise_start_time) + for item in CurvableParam + if (argcrv := self.argument_curves[item]) + }, + ) + for _note in self + ) + def get(self, time: int) -> Generator[SingleNote, None, None]: """通过确切的开始时间来获取音符""" @@ -673,10 +929,11 @@ class SingleTrack(List[SingleNote]): for _note in self.get_notes(range_start_time, range_end_time): yield MineNote.from_single_note( note=_note, - note_instrument=self.instrument, + note_instrument=self.instrument.name, + note_volume_weight=self.volume_weight, is_persiced_time=self.is_high_time_precision, is_percussive_note=self.is_percussive, - sound_position=self.sound_position, + sound_position=SoundAtmos(self.sound_distance, self.sound_position), **{ item.value: argcrv.value_at(_note.precise_start_time) for item in CurvableParam @@ -684,36 +941,7 @@ class SingleTrack(List[SingleNote]): }, ) - @property - def note_amount(self) -> int: - """音符数""" - return len(self) - - @property - def notes(self) -> List[SingleNote]: - """音符列表""" - return self - - @property - def minenotes(self) -> Iterator[MineNote]: - """ - 直接返回当前音轨所有音符的我的世界数据形式 - """ - return ( - MineNote.from_single_note( - note=_note, - note_instrument=self.instrument, - is_persiced_time=self.is_high_time_precision, - is_percussive_note=self.is_percussive, - sound_position=self.sound_position, - **{ - item.value: argcrv.value_at(_note.precise_start_time) - for item in CurvableParam - if (argcrv := self.argument_curves[item]) - }, - ) - for _note in self - ) + # 其他 def set_info(self, key: Union[str, Sequence[str]], value: Any): """设置附加信息""" @@ -759,6 +987,31 @@ class SingleMusic(List[SingleTrack]): extra_info: Dict[str, Any] """这还得放东西?""" + __max_volume_weight: float = -1 + """全曲最大音量权""" + + __minimal_loadness: int = 1 + """全曲响度最小的乐器采样的响度""" + + # 前置方法 + + def _calc_values(self): + for track in self: + self.__max_volume_weight = max( + track.volume_weight, self.__max_volume_weight + ) + self.__minimal_loadness = min( + track.instrument.loadness, self.__minimal_loadness + ) + for track in self: + track.set_volume_weight( + max_weight=self.__max_volume_weight, + minimal_loadness=self.__minimal_loadness, + weight=track.volume_weight, + ) + + # 类的构建方法 + def __init__( self, *args: SingleTrack, @@ -788,6 +1041,8 @@ class SingleMusic(List[SingleTrack]): super().__init__(*args) + self._calc_values() + @classmethod def from_track_list( cls, @@ -810,6 +1065,95 @@ class SingleMusic(List[SingleTrack]): single_music.extend(track_list) return single_music + # 类操作 + + def append(self, track: SingleTrack) -> None: + """ + 将一个音轨添加到全曲中,排列在所有音轨之后。 + + Append object to the end of the list. + """ + super().append(track) + self._calc_values() + + def extend(self, tracks: Sequence[SingleTrack]) -> None: + """ + 将一个音轨列表添加到全曲中,依顺序在所有音轨之后排列。 + + Extend list by appending elements from the iterable. + """ + super().extend(tracks) + self._calc_values() + + def insert(self, index: int, track: SingleTrack) -> None: + """ + 将一个音轨插入到全曲中,在指定位置。 + + Insert object before index. + """ + super().insert(index, track) + self._calc_values() + + def remove(self, track: SingleTrack) -> None: + """ + 从全曲中删除一个音轨。 + + Remove first occurrence of value. + + 当音轨不存在时,会抛出 `ValueError`。 + Raises `ValueError` if the value is not present. + """ + super().remove(track) + self._calc_values() + + def clear(self) -> None: + """ + 清空全曲。 + + Remove all items from list. + """ + super().clear() + self.__max_volume_weight = -1 + self.__minimal_loadness = 1 + + def pop(self, index: SupportsIndex = -1) -> SingleTrack: + """ + 从全曲中删除一个音轨。 + + Remove and return item at index (default last). + + 当音轨不存在时,会抛出 `IndexError`。 + + Raises IndexError if list is empty or index is out of range. + """ + to_pop = super().pop(index) + self._calc_values() + return to_pop + + @overload + def __setitem__(self, key: SupportsIndex, value: SingleTrack) -> None: ... + + @overload + def __setitem__(self, key: slice, value: Sequence[SingleTrack]) -> None: ... + + def __setitem__(self, key, value) -> None: + """ + 设置一个音轨 + + Set self[key] to value. + """ + super().__setitem__(key, value) + self._calc_values() + + def __delitem__(self, key: SupportsIndex | slice) -> None: + """ + 删除一个音轨 + + Delete self[key]. + """ + super().__delitem__(key) + self._calc_values() + def copy(self) -> "SingleMusic": # 没想到真的会用到全曲复制…… @@ -827,6 +1171,8 @@ class SingleMusic(List[SingleTrack]): credits=self.music_credits, ) + # 类属性 + @property def track_amount(self) -> int: """音轨数""" @@ -837,6 +1183,36 @@ class SingleMusic(List[SingleTrack]): """音轨列表,不包含被禁用的音轨""" return (track for track in self if track.is_enabled) + def set_loadness(self, track: Union[int, SingleTrack], weight: float): + if weight > self.__max_volume_weight: + if isinstance(track, int): + self[track].__volume_weight = weight + elif isinstance(track, SingleTrack): + if track in self: + track.__volume_weight = weight + else: + raise ParameterValueError( + "音轨:`{}`不属于此曲目".format(track.name) + ) + self._calc_values() + else: + if isinstance(track, int): + self[track].set_volume_weight( + max_weight=self.__max_volume_weight, + minimal_loadness=self.__minimal_loadness, + weight=weight, + ) + elif isinstance(track, SingleTrack): + track.set_volume_weight( + max_weight=self.__max_volume_weight, + minimal_loadness=self.__minimal_loadness, + weight=weight, + ) + + raise ParameterTypeError("音轨不得为`{}`类型".format(type(track))) + + # 音符操作 + @staticmethod def yield_from_tracks( tracks: Sequence[Iterator[T]], @@ -938,6 +1314,8 @@ class SingleMusic(List[SingleTrack]): sort_key=lambda x: x.start_tick, ) + # 其他 + def set_info(self, key: Union[str, Sequence[str]], value: Any): """设置附加信息""" if isinstance(key, str): diff --git a/Musicreater/exceptions.py b/Musicreater/exceptions.py index 0402abb..907c970 100644 --- a/Musicreater/exceptions.py +++ b/Musicreater/exceptions.py @@ -73,6 +73,15 @@ class InnerlyParameterError(MusicreaterInnerlyError): super().__init__("传参错误 - ", *args) +class ParameterCacheUnfreshError(InnerlyParameterError): + """参数缓存未刷新""" + + def __init__(self, *args): + """参数缓存未刷新""" + super().__init__("参数缓存未刷新:", *args) + + + class ParameterTypeError(InnerlyParameterError, TypeError): """参数类型错误""" diff --git a/Musicreater/paramcurve.py b/Musicreater/paramcurve.py index d64ca18..67f95eb 100644 --- a/Musicreater/paramcurve.py +++ b/Musicreater/paramcurve.py @@ -268,7 +268,7 @@ class ParamCurve: if idx < len(self._keys) and self._keys[idx].time == time: return idx, self._keys[idx] else: - print("[警告] ParamCurve.find_key: 找不到指定时间点所对应之关键帧") + # print("[警告] ParamCurve.find_key: 找不到指定时间点所对应之关键帧") return idx, None def copy( diff --git a/old-things/Musicreater/old_utils.py b/old-things/Musicreater/old_utils.py index e8d2971..f576513 100644 --- a/old-things/Musicreater/old_utils.py +++ b/old-things/Musicreater/old_utils.py @@ -15,6 +15,7 @@ Terms & Conditions: License.md in the root directory # Email TriM-Organization@hotmail.com # 若需转载或借鉴 许可声明请查看仓库目录下的 License.md +import math import random # from io import BytesIO @@ -48,6 +49,52 @@ from .subclass import MineNote, mctick2timestr, SingleNoteBox from .old_types import MidiInstrumentTableType, MineNoteChannelType, FittingFunctionType + +def volume_2_distance_natural( + vol: float, +) -> float: + """ + Midi 力度值/音量值拟合成的距离函数,一种更加自然的听感? + + Parameters + ---------- + vol: int + Midi 音符力度值(0~127) + + Returns + ------- + float播放中心到玩家的距离 + """ + return ( + -8.081720684086314 + * math.log( + vol + 14.579508825070013, + ) + + 37.65806375944386 + if vol < 60.64 + else 0.2721359356095803 * ((vol + 2592.272889454798) ** 1.358571233418649) + + -6.313841334963396 * (vol + 2592.272889454798) + + 4558.496367823575 + ) + + +def volume_2_distance_straight(vol: float) -> float: + """ + Midi 力度值/音量值拟合成的距离函数,线性转换 + + Parameters + ---------- + vol: int + Midi 音符力度值(0~127) + + Returns + ------- + float播放中心到玩家的距离 + """ + return (vol + 1) / -8 + 16 + + + def inst_to_sould_with_deviation( instrumentID: int, reference_table: MidiInstrumentTableType, diff --git a/resources/响度公式/计算过程.jpg b/resources/响度公式/计算过程.jpg new file mode 100644 index 0000000..fdff04c Binary files /dev/null and b/resources/响度公式/计算过程.jpg differ diff --git a/resources/响度公式/计算过程.pdn b/resources/响度公式/计算过程.pdn new file mode 100644 index 0000000..5028c35 Binary files /dev/null and b/resources/响度公式/计算过程.pdn differ