完善新的音量设计,现在我们通过了标准的类型检查

This commit is contained in:
2026-07-23 01:21:00 +08:00
parent debd42c633
commit 62db7e585f
10 changed files with 434 additions and 193 deletions
+19 -35
View File
@@ -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):
@@ -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
+111 -20
View File
@@ -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 是 500000tpb 在
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)
@@ -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,
},
),
@@ -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,
)
@@ -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
+49 -33
View File
@@ -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)))
+220 -85
View File
@@ -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
+1 -1
View File
@@ -126,4 +126,4 @@
[tool.pyright]
typeCheckingMode = "basic"
typeCheckingMode = "standard"
+3 -1
View File
@@ -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)