From 62db7e585fd86e7740215cf1b1479efa9bb71bbe Mon Sep 17 00:00:00 2001 From: Eilles Date: Thu, 23 Jul 2026 01:21:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=96=B0=E7=9A=84=E9=9F=B3?= =?UTF-8?q?=E9=87=8F=E8=AE=BE=E8=AE=A1=EF=BC=8C=E7=8E=B0=E5=9C=A8=E6=88=91?= =?UTF-8?q?=E4=BB=AC=E9=80=9A=E8=BF=87=E4=BA=86=E6=A0=87=E5=87=86=E7=9A=84?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Musicreater/_plugin_abc.py | 54 ++-- .../commands_to_structure/main.py | 2 +- Musicreater/builtin_plugins/midi_read/main.py | 131 ++++++-- .../builtin_plugins/midi_read/utils.py | 5 + .../builtin_plugins/music_preview/__init__.py | 7 + .../builtin_plugins/music_preview/main.py | 35 +- Musicreater/data.py | 82 +++-- Musicreater/paramcurve.py | 305 +++++++++++++----- pyproject.toml | 2 +- test_midi_preview.py | 4 +- 10 files changed, 434 insertions(+), 193 deletions(-) diff --git a/Musicreater/_plugin_abc.py b/Musicreater/_plugin_abc.py index cfdbf73..4d85746 100644 --- a/Musicreater/_plugin_abc.py +++ b/Musicreater/_plugin_abc.py @@ -43,6 +43,7 @@ from typing import ( Set, Type, Mapping, + overload, ) if sys.version_info >= (3, 11): @@ -336,9 +337,7 @@ class MusicInputPluginBase(TopInOutPluginBase, ABC): ) @abstractmethod - def loadbytes( - self, bytes_buffer_in: BinaryIO, config: Optional[PluginConfig] - ) -> "SingleMusic": + def loadbytes(self, bytes_buffer_in: BinaryIO, config) -> "SingleMusic": """从字节流加载数据到完整曲目 参数 @@ -353,10 +352,9 @@ class MusicInputPluginBase(TopInOutPluginBase, ABC): SingleMusic 解析得到的完整曲目对象 """ + ... - pass - - def load(self, file_path: Path, config: Optional[PluginConfig]) -> "SingleMusic": + def load(self, file_path: Path, config) -> "SingleMusic": """从文件加载数据到完整曲目 参数 @@ -390,9 +388,7 @@ class TrackInputPluginBase(TopInOutPluginBase, ABC): ) @abstractmethod - def loadbytes( - self, bytes_buffer_in: BinaryIO, config: Optional[PluginConfig] - ) -> "SingleTrack": + def loadbytes(self, bytes_buffer_in: BinaryIO, config) -> "SingleTrack": """从字节流加载音符数据到单个音轨 参数 @@ -407,9 +403,9 @@ class TrackInputPluginBase(TopInOutPluginBase, ABC): SingleTrack 解析得到的单个音轨对象 """ - pass + ... - def load(self, file_path: Path, config: Optional[PluginConfig]) -> "SingleTrack": + def load(self, file_path: Path, config) -> "SingleTrack": """从文件加载音符数据到单个音轨 参数 @@ -443,9 +439,7 @@ class MusicOperatePluginBase(TopPluginBase, ABC): ) @abstractmethod - def process( - self, data: "SingleMusic", config: Optional[PluginConfig] - ) -> "SingleMusic": + def process(self, data: "SingleMusic", config) -> "SingleMusic": """处理完整曲目的数据 参数 @@ -460,7 +454,7 @@ class MusicOperatePluginBase(TopPluginBase, ABC): SingleMusic 处理后的完整曲目 """ - pass + ... class TrackOperatePluginBase(TopPluginBase, ABC): @@ -478,9 +472,7 @@ class TrackOperatePluginBase(TopPluginBase, ABC): ) @abstractmethod - def process( - self, data: "SingleTrack", config: Optional[PluginConfig] - ) -> "SingleTrack": + def process(self, data: "SingleTrack", config) -> "SingleTrack": """处理单个音轨的音符数据 参数 @@ -495,7 +487,7 @@ class TrackOperatePluginBase(TopPluginBase, ABC): SingleTrack 处理后的单个音轨 """ - pass + ... class MusicOutputPluginBase(TopInOutPluginBase, ABC): @@ -513,9 +505,7 @@ class MusicOutputPluginBase(TopInOutPluginBase, ABC): ) @abstractmethod - def stream_dump( - self, data: "SingleMusic", config: Optional[PluginConfig] - ) -> Iterator[bytes]: + def stream_dump(self, data: "SingleMusic", config) -> Iterator[bytes]: """将完整曲目导出为对应格式的字节流 参数 @@ -530,11 +520,9 @@ class MusicOutputPluginBase(TopInOutPluginBase, ABC): Iterator[bytes] 分块导出的二进制字节串 """ - pass + ... - def dump( - self, data: "SingleMusic", file_path: Path, config: Optional[PluginConfig] - ): + def dump(self, data: "SingleMusic", file_path: Path, config): """将完整曲目导出为对应格式的文件 参数 @@ -567,9 +555,7 @@ class TrackOutputPluginBase(TopInOutPluginBase, ABC): ) @abstractmethod - def stream_dump( - self, data: "SingleTrack", config: Optional[PluginConfig] - ) -> Iterator[bytes]: + def stream_dump(self, data: "SingleTrack", config) -> Iterator[bytes]: """将单个音轨导出为对应格式的字节流 参数 @@ -584,11 +570,9 @@ class TrackOutputPluginBase(TopInOutPluginBase, ABC): Iterator[bytes] 分块导出的二进制字节串 """ - pass + ... - def dump( - self, data: "SingleTrack", file_path: Path, config: Optional[PluginConfig] - ): + def dump(self, data: "SingleTrack", file_path: Path, config): """将单个音轨导出为对应格式的文件 参数 @@ -620,7 +604,7 @@ class ServicePluginBase(TopPluginBase, ABC): ) @abstractmethod - def serve(self, config: Optional[PluginConfig]) -> None: + def serve(self, config=None) -> None: """服务插件的运行逻辑 参数 @@ -628,7 +612,7 @@ class ServicePluginBase(TopPluginBase, ABC): config: Optional[PluginConfig] 插件配置;**可选** """ - pass + ... class LibraryPluginBase(TopPluginBase, ABC): diff --git a/Musicreater/builtin_plugins/commands_to_structure/main.py b/Musicreater/builtin_plugins/commands_to_structure/main.py index 98c7242..0882afe 100644 --- a/Musicreater/builtin_plugins/commands_to_structure/main.py +++ b/Musicreater/builtin_plugins/commands_to_structure/main.py @@ -134,7 +134,7 @@ class MusicExportToMcstructureWithDelayPlayerPlugin(MusicOutputPluginBase): with file_path.open("wb") as f: struct.dump(f) - return size, max_delay + # return size, max_delay def stream_dump( self, data: SingleMusic, config: McstructureExportConfig diff --git a/Musicreater/builtin_plugins/midi_read/main.py b/Musicreater/builtin_plugins/midi_read/main.py index a370573..9430eeb 100644 --- a/Musicreater/builtin_plugins/midi_read/main.py +++ b/Musicreater/builtin_plugins/midi_read/main.py @@ -170,6 +170,8 @@ class TrackDivisionDict( self.division_by_volume = midi_import_config.divide_tracks_by_volume self.division_by_panning = midi_import_config.divide_tracks_by_panning + self.create_volume_curve = midi_import_config.keep_velocity_to_param_curve + def __getitem__( self, key: Tuple[ @@ -192,7 +194,9 @@ class TrackDivisionDict( except KeyError: self[key] = SingleTrack(precise_time=True) if self.create_volume_curve: - self[key].argument_curves[CurvableParam.VOLUME] = ParamCurve() + self[key].argument_curves[CurvableParam.VOLUME] = ParamCurve( + base_value=1.0, value_min=0.0, value_max=1.0 + ) return self[key] @@ -287,12 +291,14 @@ class MidiImport2MusicPlugin(MusicInputPluginBase): ) ) - midi_tempo = config.default_tempo_value - """微秒每拍""" - note_count = 0 + note_count: int = 0 """音符计数""" note_count_per_instrument: Dict[str, int] = {} """乐器使用统计""" + master_volume: int = 16383 + """主音量""" + music_name: str = "" + """音乐名称""" note_queue_A: Dict[int, List[Tuple[int, int]]] = ( enumerated_stuffcopy_dictionary(staff=[]) @@ -312,24 +318,98 @@ class MidiImport2MusicPlugin(MusicInputPluginBase): """Midi 版权列表""" midi_track_name_dict: Dict[int, str] = {} """轨道名称字典 Dict[int轨道编号, str轨道名称]""" + track_0 = midi.tracks[0].copy() + should_insert_controls = len(midi.tracks) > 1 for track_no, message_track in enumerate(midi.tracks): # 每个音轨单独重置 - microseconds = 0 + if track_no == 0 and should_insert_controls: + # 第零个音轨在多轨模式下只是作为全局的控制轨出现的,因此忽略 + # 但是,以防这里出现了不符合的情况,我们做如下控制 + trk = [] + tk0 = [] + for msg in mido.midifiles.tracks._to_abstime(track_0): + if msg.type == "track_name": + music_name = ( + msg.name.encode("latin1").decode(config.string_encoding) + if config.string_encoding + else msg.name + ) + elif (msg.type == "set_tempo") or (msg.type == "sysex"): + tk0.append(msg) + else: + trk.append(msg) + + track_0 = mido.MidiTrack( + mido.midifiles.tracks.fix_end_of_track( + mido.midifiles.tracks._to_reltime( + sorted(tk0, key=lambda a: a.time), skip_checks=True + ), + skip_checks=True, + ) + ) + + midi.tracks.insert( + 1, + mido.MidiTrack( + mido.midifiles.tracks.fix_end_of_track( + mido.midifiles.tracks._to_reltime( + sorted(trk, key=lambda a: a.time), skip_checks=True + ), + skip_checks=True, + ) + ), + ) + del tk0, trk + continue + + # print("正在处理轨道:", track_no, "需要合并:", should_insert_controls, "合并长度:", len(track_0)) + + midi_tempo: int = config.default_tempo_value + """微秒每拍""" + microseconds: int = 0 """当前的微妙时间""" - for msg in message_track: - if msg.type == "set_tempo": - # Tempo 改变是一个全局的控制 - # 而且应该是很早出现的一个 Midi 消息 - midi_tempo = msg.tempo + + for msg in ( + mido.merge_tracks((track_0, message_track), skip_checks=True) + if should_insert_controls + else message_track + ): if msg.time != 0: # 微秒 # 通常情况下,tempo 是 500000,tpb 在 microseconds += msg.time * midi_tempo / midi.ticks_per_beat - if msg.type == "program_change": + if msg.type == "set_tempo": + # Tempo 改变是一个全局的控制 + # 而且应该是很早出现的一个 Midi 消息 + midi_tempo = msg.tempo + # print( + # "Tempo 改变:", + # midi_tempo, + # "出现在轨道:", + # track_no, + # "时间:", + # microseconds, + # flush=True, + # ) + elif msg.type == "sysex": + # 系统执行消息 + data = msg.data # 不包含 F0 和 F7 + # data: (7F, device_id, 04, 01, LSB, MSB) + if ( + len(data) >= 6 + and data[0] == 0x7F + and data[2] == 0x04 + and data[3] == 0x01 + ): + # 检查 Master Volume 消息 + master_volume = (data[5] << 7) | data[ + 4 + ] # 14-bit value: 0 ~ 16383 + elif msg.type == "program_change": # 检测 乐器变化 之 midi 事件 value_controler_per_channel[msg.channel][ ControlerKeys.MIDI_PROGRAM @@ -359,8 +439,10 @@ class MidiImport2MusicPlugin(MusicInputPluginBase): midi_copyright_list.append(msg.text) elif msg.type == "track_name": # 检测轨道名称事件 - midi_track_name_dict[track_no] = msg.name.encode("latin1").decode( - config.string_encoding + midi_track_name_dict[track_no] = ( + msg.name.encode("latin1").decode(config.string_encoding) + if config.string_encoding + else msg.name ) elif msg.type == "note_on" and msg.velocity != 0: # 一个音符开始弹奏 @@ -429,6 +511,7 @@ class MidiImport2MusicPlugin(MusicInputPluginBase): note=(_program if _is_percussion else msg.note), percussive=_is_percussion, volume=_volume, + master_volume=master_volume, velocity=_velocity, panning=_panning, start_time=_start_ms, # 微秒 @@ -455,7 +538,7 @@ class MidiImport2MusicPlugin(MusicInputPluginBase): track_no, msg.channel, sound_name, - _volume, + _volume * master_volume // 16383, sound_rotation, ) ] @@ -465,6 +548,7 @@ class MidiImport2MusicPlugin(MusicInputPluginBase): time=that_note.precise_start_time, value=_velocity / 127, ) + # print("VOLUME:", _velocity / 127, flush=True, end=", ") # 更新统计信息 note_count += 1 @@ -479,7 +563,8 @@ class MidiImport2MusicPlugin(MusicInputPluginBase): print( "[WARRING] MIDI格式错误 音符不匹配`{}`无法在上文`{}`中找到与之匹配的音符开音消息".format( msg, note_queue_A[msg.channel] - ) + ), + flush=True, ) else: raise NoteOnOffMismatchError( @@ -488,15 +573,14 @@ class MidiImport2MusicPlugin(MusicInputPluginBase): "无法在上文中找到与之匹配的音符开音消息。", ) - del midi_tempo - if midi_lyric_cache: # 怎么有歌词多啊 if config.ignore_errors: print( "[WARRING] MIDI 解析错误 歌词对应错误,以下歌词未能填入音符之中,已经填入的仍可能有误 {}".format( midi_lyric_cache - ) + ), + flush=True, ) else: raise LyricMismatchError( @@ -506,6 +590,7 @@ class MidiImport2MusicPlugin(MusicInputPluginBase): ) final_music = SingleMusic( + name=music_name, credits="; ".join(midi_copyright_list), extra_information={ "MIDI_TEXT_LIST": midi_text_list, @@ -518,14 +603,20 @@ class MidiImport2MusicPlugin(MusicInputPluginBase): if track_properties[0] and ( track_name := midi_track_name_dict.get(track_properties[0], "无名音轨") ): # 音轨编号 - every_single_track.name = track_name + every_single_track.name = track_name or "空名称" if track_properties[2]: # 乐器名称 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] + # print( + # "[INFO] 音轨 {} 音量权已设置为 {}".format( + # every_single_track.name, track_properties[3] + # ), + # flush=True, + # ) + every_single_track._volume_weight = track_properties[3] if track_properties[4]: # 声相 every_single_track.sound_position = track_properties[4] super(SingleMusic, final_music).append(every_single_track) diff --git a/Musicreater/builtin_plugins/midi_read/utils.py b/Musicreater/builtin_plugins/midi_read/utils.py index cfae24d..9e7a0c1 100644 --- a/Musicreater/builtin_plugins/midi_read/utils.py +++ b/Musicreater/builtin_plugins/midi_read/utils.py @@ -24,6 +24,7 @@ from typing import Callable, Dict, List, Optional, Sequence, Tuple, Mapping from Musicreater import SingleNote, SoundAtmos + def panning_2_rotation_linear(pan_: float) -> float: """ Midi 左右平衡偏移值线性转为声源旋转角度 @@ -97,6 +98,7 @@ def midi_msgs_to_noteinfo( note: int, percussive: bool, # 是否作为打击乐器启用 volume: int, + master_volume: int, velocity: int, panning: int, start_time: int, @@ -120,6 +122,8 @@ def midi_msgs_to_noteinfo( 是否作为打击乐器启用 volume: int 音量 + master_volume: int + 主音量 velocity: int 力度 panning: int @@ -165,6 +169,7 @@ def midi_msgs_to_noteinfo( "LYRIC_TEXT": lyric_line, "VELOCITY_VALUE": velocity, "VOLUME_VALUE": volume, + "MASTER_VOLUME_VALUE": master_volume, "PIN_VALUE": panning, }, ), diff --git a/Musicreater/builtin_plugins/music_preview/__init__.py b/Musicreater/builtin_plugins/music_preview/__init__.py index 498976f..bad7a1e 100644 --- a/Musicreater/builtin_plugins/music_preview/__init__.py +++ b/Musicreater/builtin_plugins/music_preview/__init__.py @@ -90,6 +90,11 @@ class PcmConversionConfig(PluginConfig): 音调处理精度,小数点后位数 """ + global_volume: float = 1.0 + """ + 全局音量控制 + """ + global_deviation: float = 0 """ 全曲音调偏移 @@ -120,6 +125,7 @@ class NoteDataConvert2PcmPlugin(MusicOutputPluginBase): sample_rate=config.sample_rate, value_get_method=config.value_get_method, pitch_accuracy_decimals=config.pitch_accuracy_decimals, + music_volume=config.global_volume, music_deviation=config.global_deviation, ) music_preview.to_wav_file(data, file_path) @@ -138,6 +144,7 @@ class NoteDataConvert2PcmPlugin(MusicOutputPluginBase): sample_rate=config.sample_rate, value_get_method=config.value_get_method, pitch_accuracy_decimals=config.pitch_accuracy_decimals, + music_volume=config.global_volume, music_deviation=config.global_deviation, ) diff --git a/Musicreater/builtin_plugins/music_preview/main.py b/Musicreater/builtin_plugins/music_preview/main.py index 5808735..fe7e59e 100644 --- a/Musicreater/builtin_plugins/music_preview/main.py +++ b/Musicreater/builtin_plugins/music_preview/main.py @@ -94,6 +94,7 @@ class MusicPreview: sample_rate: int = 44100, value_get_method: Literal[0, 1] = 1, pitch_accuracy_decimals: int = 0, + music_volume: float = 1, music_deviation: float = 0, ): @@ -107,6 +108,7 @@ class MusicPreview: # 此处 TODO,需待旧梦来完善 self.pitch_precision_decimals = 0 + self.music_volume = music_volume self.pitch_deviation = music_deviation for file in self.resources_path.iterdir(): @@ -370,23 +372,22 @@ class MusicPreview: # note.percussive, # note.duration_tick, # ) - if not note.percussive: - overlay( - self.res_data[note.instrument][accurate_pitch] - * (1 - (note.position.sound_distance / 48)), - note.start_tick, - ) - # 上述参数的原顺序应为 - # x * (音量 / 127) * (1 / (距离 + 0.5)) - # 乘法优先是为了提高计算精度,小的数的除法优先同理 - # 下面是后来的注释 - # 这种方式是错误的 —— 金羿 20260720 - else: - overlay( - self.res_data[note.instrument][accurate_pitch] - * (1 - (note.position.sound_distance / 48)), - note.start_tick, - ) + # print(note.position) + # print("参数倍率", self.music_volume, (1-(note.position.sound_distance / 48)), flush=True) + + # print("部分数据(前):",self.res_data[note.instrument][accurate_pitch][:5]) + overlay( + self.res_data[note.instrument][accurate_pitch] + * self.music_volume + * (1 - (note.position.sound_distance / 48)), + note.start_tick, + ) + # print("部分数据(后):", wav_model[:5]) + # 上述参数的原顺序应为 + # x * (音量 / 127) * (1 / (距离 + 0.5)) + # 乘法优先是为了提高计算精度,小的数的除法优先同理 + # 下面是后来的注释 + # 这种方式是错误的 —— 金羿 20260720 if self.get_value_method == 0: # 归一化,抚摸耳朵 (bushi diff --git a/Musicreater/data.py b/Musicreater/data.py index 4c38879..9d64b38 100644 --- a/Musicreater/data.py +++ b/Musicreater/data.py @@ -95,7 +95,7 @@ class SoundAtmos: # 如果指定为零,那么为零,但如果不指定或者指定为负数,则为 0.01 的距离 self.sound_distance = ( - (16 if distance > 16 else (distance if distance >= 0 else 0.01)) + (49 if distance > 48 else (distance if distance >= 0 else 0.01)) if distance is not None else 0.01 ) @@ -110,7 +110,7 @@ class SoundAtmos: """设置声源距离""" self.sound_distance = ( - (16 if distance > 16 else (distance if distance >= 0 else 0.01)) + (49 if distance > 48 else (distance if distance >= 0 else 0.01)) if distance is not None else 0.01 ) @@ -195,7 +195,7 @@ class DefaultInstrument(Enum): HARP = Instrument("note.harp", -1448) -@dataclass(init=False) +@dataclass(init=False, eq=False, order=False) class SingleNote: """存储单个音符的类""" @@ -388,7 +388,9 @@ class SingleNote: self.high_precision_start_time, ) - def __dict__(self): + def __dict__( # pyright: ignore[reportIncompatibleVariableOverride] + self, + ) -> Dict[str, Any]: return { "Pitch": self.midi_pitch, "StartTick": self.start_time, @@ -397,7 +399,9 @@ class SingleNote: "ExtraData": self.extra_info, } - def __eq__(self, other: "SingleNote") -> bool: + def __eq__( # pyright: ignore[reportIncompatibleMethodOverride] + self, other: "SingleNote" + ) -> bool: """比较两个音符是否具有相同的属性,不计附加信息""" if not isinstance(other, self.__class__): return False @@ -452,7 +456,7 @@ class MineNote: is_percussive_note: bool, sound_position: SoundAtmos, adjust_note_pitch: float = 0.0, - adjust_note_volume_weight: float = 0.0, + multiple_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, @@ -467,21 +471,25 @@ class MineNote: percussive=is_percussive_note, position=( SoundAtmos( - sound_position.sound_distance - + adjust_note_sound_distance - + ( + ( ( ( ( - (sound_position.sound_distance - 48) - * (note_volume_weight + adjust_note_volume_weight) + ( + sound_position.sound_distance + + adjust_note_sound_distance + - 48 + ) + * (note_volume_weight * multiple_note_volume_weight) / note_volume_weight ) + 48 ) + ) # 此处调整须保证前提:音量权的增值必须为负 + if multiple_note_volume_weight + else ( + sound_position.sound_distance + adjust_note_sound_distance ) - if adjust_note_volume_weight - else 0 ), ( sound_position.sound_azimuth[0] @@ -491,7 +499,7 @@ class MineNote: ), ) if ( - adjust_note_volume_weight + multiple_note_volume_weight or adjust_note_sound_distance or adjust_note_leftright_panning_degree or adjust_note_updown_panning_degree @@ -505,7 +513,7 @@ class CurvableParam(str, Enum): """可曲线化的参数 枚举类""" PITCH = "adjust_note_pitch" - VOLUME = "adjust_note_volume_weight" + VOLUME = "multiple_note_volume_weight" DISTANCE = "adjust_note_sound_distance" LR_PANNING = "adjust_note_leftright_panning_degree" UD_PANNING = "adjust_note_updown_panning_degree" @@ -529,10 +537,10 @@ class SingleTrack(List[SingleNote]): is_percussive: bool """该音轨是否标记为打击乐器轨道""" - __volume_weight: float = 10 + _volume_weight: float = 10 """音量权""" - __sound_distance: float = -1 + _sound_distance: float = -1 """声源距离""" sound_position: Tuple[float, float] @@ -596,10 +604,10 @@ class SingleTrack(List[SingleNote]): self.is_percussive = percussion """是否为打击乐器""" - self.__volume_weight = volume_weight + self._volume_weight = volume_weight """音量权""" - self.__sound_distance = -1 + self._sound_distance = -1 """声源距离""" self.sound_position = sound_direction @@ -643,18 +651,18 @@ class SingleTrack(List[SingleNote]): @property def volume_weight(self) -> float: """音量权""" - return self.__volume_weight + return self._volume_weight @property def sound_distance(self) -> float: """声源距离""" - if self.__sound_distance < 0: + if self._sound_distance < 0: raise ParameterCacheUnfreshError( "未设置声源距离(`{}`),操作时不应直接访问 `__volume_weight` 属性".format( - self.__sound_distance + self._sound_distance ) ) - return self.__sound_distance + return self._sound_distance @property def note_amount(self) -> int: @@ -689,9 +697,9 @@ class SingleTrack(List[SingleNote]): 由于我们存储的响度都是 百-LUFS,所以下面的计算除数为 2000 """ - self.__volume_weight = weight + self._volume_weight = weight - self.__sound_distance = volume_weight_to_sound_distance( + self._sound_distance = volume_weight_to_sound_distance( max_weight, minimal_loadness, weight, self.instrument.loadness ) # print( @@ -702,7 +710,7 @@ class SingleTrack(List[SingleNote]): # ",设置声源距离为:", # self.__sound_distance, # ) - return self.__sound_distance + return self._sound_distance # 类操作 @@ -757,7 +765,7 @@ class SingleTrack(List[SingleNote]): volume_weight=self.volume_weight, sound_direction=self.sound_position, ) - single_track.__sound_distance = self.sound_distance + single_track._sound_distance = self.sound_distance if with_argument_curve: single_track.argument_curves = { item: ( @@ -996,6 +1004,7 @@ class SingleMusic(List[SingleTrack]): # 前置方法 def _calc_values(self): + # print("收到音乐参数计算请求,当前最大:",self.__max_volume_weight, end=";") for track in self: self.__max_volume_weight = max( track.volume_weight, self.__max_volume_weight @@ -1009,6 +1018,7 @@ class SingleMusic(List[SingleTrack]): minimal_loadness=self.__minimal_loadness, weight=track.volume_weight, ) + # print("计算后最大:",self.__max_volume_weight) # 类的构建方法 @@ -1076,7 +1086,7 @@ class SingleMusic(List[SingleTrack]): super().append(track) self._calc_values() - def extend(self, tracks: Sequence[SingleTrack]) -> None: + def extend(self, tracks: Iterable[SingleTrack]) -> None: """ 将一个音轨列表添加到全曲中,依顺序在所有音轨之后排列。 @@ -1085,7 +1095,7 @@ class SingleMusic(List[SingleTrack]): super().extend(tracks) self._calc_values() - def insert(self, index: int, track: SingleTrack) -> None: + def insert(self, index: SupportsIndex, track: SingleTrack) -> None: """ 将一个音轨插入到全曲中,在指定位置。 @@ -1136,7 +1146,9 @@ class SingleMusic(List[SingleTrack]): @overload def __setitem__(self, key: slice, value: Sequence[SingleTrack]) -> None: ... - def __setitem__(self, key, value) -> None: + def __setitem__( # pyright: ignore[reportIncompatibleMethodOverride] + self, key, value + ) -> None: """ 设置一个音轨 @@ -1183,18 +1195,20 @@ class SingleMusic(List[SingleTrack]): """音轨列表,不包含被禁用的音轨""" return (track for track in self if track.is_enabled) - def set_loadness(self, track: Union[int, SingleTrack], weight: float): + def set_loadness(self, track: Union[int, SingleTrack], weight: float) -> None: if weight > self.__max_volume_weight: if isinstance(track, int): - self[track].__volume_weight = weight + self[track]._volume_weight = weight + elif isinstance(track, SingleTrack): if track in self: - track.__volume_weight = weight + track._volume_weight = weight else: raise ParameterValueError( "音轨:`{}`不属于此曲目".format(track.name) ) self._calc_values() + return else: if isinstance(track, int): self[track].set_volume_weight( @@ -1202,12 +1216,14 @@ class SingleMusic(List[SingleTrack]): minimal_loadness=self.__minimal_loadness, weight=weight, ) + return elif isinstance(track, SingleTrack): track.set_volume_weight( max_weight=self.__max_volume_weight, minimal_loadness=self.__minimal_loadness, weight=weight, ) + return raise ParameterTypeError("音轨不得为`{}`类型".format(type(track))) diff --git a/Musicreater/paramcurve.py b/Musicreater/paramcurve.py index 67f95eb..c61539b 100644 --- a/Musicreater/paramcurve.py +++ b/Musicreater/paramcurve.py @@ -218,6 +218,7 @@ class ParamCurve: 支持动态节点编辑 用户通过添加/修改关键帧(时间-值对)来定义曲线,类自动在相邻关键帧之间生成插值段。 支持多种插值模式:线性('linear')、平滑缓动('smooth')、保持('hold')或自定义函数。 + 支持值域限制:可设定参数值的上限与下限,所有输出及关键帧写入均受约束。 """ base_line: float = 0.0 @@ -229,6 +230,12 @@ class ParamCurve: boundary_behaviour: BoundaryBehaviour """边界行为,控制参数曲线在已定义的范围外的返回值""" + value_min: Optional[float] = None + """参数值下限(None 表示不限制)""" + + value_max: Optional[float] = None + """参数值上限(None 表示不限制)""" + _keys: List[Keyframe] """关键帧列表""" @@ -239,6 +246,8 @@ class ParamCurve: [float], float ] = InterpolationMethod.linear, boundary_mode: BoundaryBehaviour = BoundaryBehaviour.CONSTANT, + value_min: Optional[float] = None, + value_max: Optional[float] = None, ): """ 初始化参数曲线。 @@ -247,19 +256,155 @@ class ParamCurve: ---------- base_value : float 边界外默认值(当 boundary_mode 为 BoundaryBehaviour.CONSTANT 时使用)。 - default_interpolation_function : FittingFunctionType + default_interpolation_function : Callable 新关键帧的默认 out_interp。 boundary_mode : BoundaryBehaviour 范围外行为: - BoundaryBehaviour.CONSTANT: 返回 base_value - BoundaryBehaviour.HOLD: 保持首/尾关键帧值 + value_min : Optional[float] + 参数值下限。None 表示无下限。 + value_max : Optional[float] + 参数值上限。None 表示无上限。 + + Raises + ------ + ValueError + 若 value_min 和 value_max 均非 None 且 value_min > value_max。 """ - self.base_line = base_value + # 先设定值域,再设定 base_line(以便 base_line 受约束) + self._set_value_range_internal(value_min, value_max) + + self.base_line = self._clamp_value(base_value) self.base_interpolation_function = default_interpolation_function self.boundary_behaviour = boundary_mode self._keys: List[Keyframe] = [] + # ────────────────────────────────────────────── + # 值域限制 相关 + # ────────────────────────────────────────────── + + def _set_value_range_internal( + self, + value_min: Optional[float], + value_max: Optional[float], + ): + """ + 内部方法:设定值域并校验合法性。 + + Raises + ------ + ValueError + 若 value_min > value_max。 + """ + if value_min is not None and value_max is not None and value_min > value_max: + raise ValueError(f"值域下限 ({value_min}) 不得大于上限 ({value_max})。") + self.value_min = value_min + self.value_max = value_max + + def _clamp_value(self, value: float) -> float: + """ + 将给定值限制在 [value_min, value_max] 范围内。 + 仅在低频操作(add_key、update_key_value、set_value_range 等)中使用。 + 高频路径 value_at() 使用内联版本。 + + Parameters + ---------- + value : float + 待限制的值。 + + Returns + ------- + float + 限制后的值。若对应边界为 None,则该方向不做限制。 + """ + if self.value_min is not None and value < self.value_min: + return self.value_min + if self.value_max is not None and value > self.value_max: + return self.value_max + return value + + def set_value_range( + self, + value_min: Optional[float] = None, + value_max: Optional[float] = None, + clamp_existing_keys: bool = True, + ): + """ + 设置参数曲线的值域(最大/最小值限制)。 + + Parameters + ---------- + value_min : Optional[float] + 参数值下限。传入 None 表示取消下限限制。 + value_max : Optional[float] + 参数值上限。传入 None 表示取消上限限制。 + clamp_existing_keys : bool + 若为 True(默认),则立即将已有关键帧的值及 base_line + 限制到新的值域范围内。若为 False,则仅影响后续输出与新增关键帧。 + + Raises + ------ + ValueError + 若 value_min > value_max。 + + Examples + -------- + >>> curve = ParamCurve(base_value=0.5) + >>> curve.add_key(0.0, 1.5) + >>> curve.add_key(1.0, -0.3) + >>> curve.set_value_range(0.0, 1.0) # 限制到 [0, 1] + >>> curve.value_at(0.0) + 1.0 + >>> curve.value_at(1.0) + 0.0 + """ + self._set_value_range_internal(value_min, value_max) + + if clamp_existing_keys: + # 限制 base_line + self.base_line = self._clamp_value(self.base_line) + # 限制所有已有关键帧的值 + for i, key in enumerate(self._keys): + clamped = self._clamp_value(key.value) + if clamped != key.value: + self._keys[i] = Keyframe( + time=key.time, + value=clamped, + out_interp=key.out_interp, + in_tangent=key.in_tangent, + out_tangent=key.out_tangent, + use_bezier=key.use_bezier, + ) + + def get_value_range(self) -> Tuple[Optional[float], Optional[float]]: + """ + 获取当前值域设定。 + + Returns + ------- + Tuple[Optional[float], Optional[float]] + (value_min, value_max),None 表示该方向无限制。 + """ + return (self.value_min, self.value_max) + + def clear_value_range(self, clamp_existing_keys: bool = False): + """ + 清除值域限制(恢复为无限制状态)。 + + Parameters + ---------- + clamp_existing_keys : bool + 通常为 False(清除限制无需 clamp)。保留此参数仅为接口对称。 + """ + self.value_min = None + self.value_max = None + + # ────────────────────────────────────────────── + # 原有功能 + # ────────────────────────────────────────────── + def __bool__(self) -> bool: return bool(self._keys) or (self.base_line != 0) @@ -301,6 +446,8 @@ class ParamCurve: self.base_line, self.base_interpolation_function, self.boundary_behaviour, + value_min=self.value_min, + value_max=self.value_max, ) if start >= end: return param_curve @@ -374,7 +521,7 @@ class ParamCurve: time : float 关键帧时间。 value : float - 参数值。 + 参数值(将自动受值域限制约束)。 out_interp : Optional[Callable] 出插值函数(若 use_bezier=False)。 in_tangent : Optional[Tuple[float, float]] @@ -391,11 +538,15 @@ class ParamCurve: Notes ----- 若时间已存在,更新该关键帧的所有属性。 + 写入的 value 会被自动 clamp 到 [value_min, value_max]。 """ interp = ( out_interp if out_interp is not None else self.base_interpolation_function ) - new_key = Keyframe(time, value, interp, in_tangent, out_tangent, use_bezier) + clamped_value = self._clamp_value(value) + new_key = Keyframe( + time, clamped_value, interp, in_tangent, out_tangent, use_bezier + ) idx, old_key = self.find_key(time) if old_key: @@ -421,12 +572,13 @@ class ParamCurve: del self._keys[idx] def update_key_value(self, time: float, new_value: float): - """更新关键帧值,保留其他属性。""" + """更新关键帧值,保留其他属性。值受值域限制约束。""" idx, key = self.find_key(time) if key: + clamped_value = self._clamp_value(new_value) self._keys[idx] = Keyframe( time, - new_value, + clamped_value, key.out_interp, key.in_tangent, key.out_tangent, @@ -471,7 +623,7 @@ class ParamCurve: def make_key_smooth(self, time: float): """ - 将关键帧设为“平滑”模式(自动对称切线,并设为贝塞尔模式)。 + 将关键帧设为"平滑"模式(自动对称切线,并设为贝塞尔模式)。 切线长度基于相邻关键帧的时间和值差。 """ idx, key = self.find_key(time) @@ -524,6 +676,9 @@ class ParamCurve: """ 计算时间 t 处的曲线值。 + 返回值始终受 [value_min, value_max] 值域限制约束。 + 即使贝塞尔插值产生过冲(overshoot),输出也会被 clamp。 + Parameters ---------- t : float @@ -532,57 +687,61 @@ class ParamCurve: Returns ------- float - 插值结果。 + 插值结果(已 clamp)。 """ keys = self._keys if not keys: - return self._get_boundary_value(t) + raw = self._get_boundary_value(t) + else: + if t < keys[0].time or t > keys[-1].time: + raw = self._get_boundary_value(t) + else: + times = [k.time for k in keys] + idx = bisect.bisect_right(times, t) - 1 - if t < keys[0].time or t > keys[-1].time: - return self._get_boundary_value(t) + if idx < 0: + raw = self._get_boundary_value(t) + elif idx >= len(keys) - 1: + raw = keys[-1].value + else: - times = [k.time for k in keys] - idx = bisect.bisect_right(times, t) - 1 + k0 = keys[idx] + k1 = keys[idx + 1] - if idx < 0: - return self._get_boundary_value(t) - if idx >= len(keys) - 1: - return keys[-1].value + if k0.time == k1.time: + raw = k0.value + elif k0.time == t: + raw = k0.value + elif k1.time == t: + raw = k1.value + else: + t0, v0 = k0.time, k0.value + t1, v1 = k1.time, k1.value + u = (t - t0) / (t1 - t0) + u = max(0.0, min(1.0, u)) - k0 = keys[idx] - k1 = keys[idx + 1] + # 贝塞尔模式(高优先级) + if k0.use_bezier or k1.use_bezier: + raw = _evaluate_bezier_segment( + t0, + v0, + t1, + v1, + out_tangent=k0.out_tangent, + in_tangent=k1.in_tangent, + u=u, + ) + # 函数插值模式,优先处理阶梯保持模式 + elif k0.out_interp is InterpolationMethod.hold: + raw = v0 + else: + interp_func = ( + k0.out_interp or self.base_interpolation_function + ) + v_norm = interp_func(u) + raw = v0 + v_norm * (v1 - v0) - if k0.time == k1.time: - return k0.value - if k0.time == t: - return k0.value - if k1.time == t: - return k1.value - - t0, v0 = k0.time, k0.value - t1, v1 = k1.time, k1.value - u = (t - t0) / (t1 - t0) - u = max(0.0, min(1.0, u)) - - # 贝塞尔模式(高优先级) - if k0.use_bezier or k1.use_bezier: - return _evaluate_bezier_segment( - t0, - v0, - t1, - v1, - out_tangent=k0.out_tangent, - in_tangent=k1.in_tangent, # ← 关键:使用下一帧的 in_tangent! - u=u, - ) - - # 函数插值模式,优先处理阶梯保持模式 - elif k0.out_interp is InterpolationMethod.hold: - return v0 - - interp_func = k0.out_interp or self.base_interpolation_function - v_norm = interp_func(u) - return v0 + v_norm * (v1 - v0) + return self._clamp_value(raw) def __call__(self, t: float) -> float: return self.value_at(t) @@ -606,11 +765,11 @@ class ParamCurve: mode : BoundaryBehaviour 边界行为设定 base_value : Optional[float] - 当 mode=BoundaryBehaviour.CONSTANT 时,指定新的默认值。 + 当 mode=BoundaryBehaviour.CONSTANT 时,指定新的默认值(受值域约束)。 """ self.boundary_behaviour = mode if base_value is not None: - self.base_line = base_value + self.base_line = self._clamp_value(base_value) def bake( self, @@ -619,41 +778,12 @@ class ParamCurve: sample_rate: Optional[float] = None, num_samples: Optional[int] = None, dtype: Any = None, - ) -> "np.ndarray": # type: ignore 这里这样用会报错吗?不知道,但是人工智能这样写了都,大抵是能用的吧 + ) -> "np.ndarray": # type: ignore """ - 将参数曲线在指定时间范围内烘焙为 NumPy 数组,用于高性能实时查询或音频渲染。 + 将参数曲线在指定时间范围内烘焙为 NumPy 数组。 - Parameters - ---------- - start : float - 烘焙起始时间(包含)。 - end : float - 烘焙结束时间(不包含)。 - sample_rate : Optional[float] - 采样率(单位:样本/时间单位)。例如,若时间单位为秒,sample_rate=48000 表示每秒 48k 样本。 - 必须与 `num_samples` 二选一提供。 - num_samples : Optional[int] - 输出数组的总样本数。若提供,则忽略 `sample_rate`。 - dtype : Any, optional - 输出数组的数据类型(如 np.float32)。默认为 np.float64。 - - Returns - ------- - np.ndarray - 一维 NumPy 数组,长度为 `num_samples`,`arr[i] ≈ curve(start + i / sample_rate)`。 - - Exceptions - ---------- - ValueError - - 若 `start >= end` - - 若未提供 `sample_rate` 且未提供 `num_samples` - - 若 `num_samples <= 0` - - Notes - ----- - - 内部使用 `np.linspace` 生成时间轴,然后逐点调用 `self.value_at(t)`。 - - 虽然目前是 Python 循环,但对于典型自动化曲线(<1000 关键帧),NumPy 向量化优势主要体现在内存布局和后续处理。 - - 如需极致性能(如 >1M 样本),可未来优化为 C++/Numba 加速,但当前已满足 DAW 自动化需求。 + 当值域限制已启用时,使用 np.clip 进行向量化 clamp, + 避免逐样本调用 Python 层的 clamp 逻辑。 """ if start >= end: raise ValueError("起始值须小于结束值。") @@ -685,4 +815,9 @@ class ParamCurve: for i in range(n): values[i] = self.value_at(float(times[i])) + # 向量化 clamp —— 比逐点 Python 调用快一个数量级 + vmin, vmax = self.value_min, self.value_max + if vmin is not None or vmax is not None: + np.clip(values, vmin, vmax, out=values) + return values diff --git a/pyproject.toml b/pyproject.toml index 0b092e2..3a94245 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,4 +126,4 @@ [tool.pyright] - typeCheckingMode = "basic" + typeCheckingMode = "standard" diff --git a/test_midi_preview.py b/test_midi_preview.py index ad87b89..b79ee0d 100644 --- a/test_midi_preview.py +++ b/test_midi_preview.py @@ -19,7 +19,9 @@ print("当前支持的导出格式:", _global_plugin_registry.supported_output msct = MusiCreater.import_music( Path(input("文件路径:")).resolve(), - plugin_config=MidiImportConfig(), + plugin_config=MidiImportConfig( + divide_tracks_by_volume=True, + ), ) print("全局插件注册表:", _global_plugin_registry)