mirror of
https://github.com/TriM-Organization/Musicreater.git
synced 2026-07-24 01:02:32 +00:00
426 lines
16 KiB
Python
426 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
"""
|
|
音·创 v3 内置的 音乐合成预览功能
|
|
"""
|
|
|
|
"""
|
|
版权所有 © 2026 金羿、鱼旧梦
|
|
Copyright © 2026 Eilles, ElapsingDreams
|
|
|
|
继承自:
|
|
|
|
版权所有 © 2026 鱼旧梦
|
|
Copyright © 2026 ElapsingDreams
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
"""
|
|
|
|
# 睿乐组织 开发交流群 861684859
|
|
# Email TriM-Organization@hotmail.com
|
|
# 本插件依照 Apache 2.0 协议开放源代码,若需转载或借鉴
|
|
# 许可声明请查看 http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
import os
|
|
|
|
from math import inf
|
|
from typing import BinaryIO, Optional, Iterator, Generator, Any, Tuple, Literal
|
|
from pathlib import Path
|
|
|
|
import soundfile as sf
|
|
import librosa
|
|
import numpy as np
|
|
|
|
|
|
from Musicreater import SingleMusic, SingleTrack, SingleNote, SoundAtmos, MineNote
|
|
from Musicreater.constants import MM_INSTRUMENT_DEVIATION_TABLE
|
|
|
|
|
|
class MusicPreview:
|
|
|
|
# 当前的资源存储方法过于鸡肋
|
|
# 应当依托于 SingleMusic 和 SingleTrack 提供的 extra_info 参数,得到更优的解法
|
|
# 或者更理想的情况是把当前插件作为一个 Service 插件,然后通过 serve API 进行调用
|
|
# 但是还没设计好,等未来看看
|
|
|
|
"""
|
|
原始资源存储规范:
|
|
Dict[str"ID1": Dict[str"SoundID": ndarray[]]]
|
|
转换资源存储规范:
|
|
Dict[str"ID1": Dict[str"SoundID": Dict[int"Pitch": ndarray]]]
|
|
测试阶段,请直接vanilla作为默认传入,不考虑载入
|
|
"""
|
|
|
|
res_raw_data: dict = {} # 原始资源存储
|
|
# res_raw_index: list = [] # 资源转换情况,key: [88] 留着优化
|
|
res_data: dict = {} # 转换后资源存储
|
|
res_data_sr: int # 转换后采样率,和输出采样率一致
|
|
|
|
@staticmethod
|
|
def res_pitch_shift(y: np.ndarray, *, sr: float, n_steps: float, **kwargs: Any):
|
|
return librosa.effects.pitch_shift(y, sr=sr, n_steps=n_steps)
|
|
|
|
@staticmethod
|
|
def res_time_stretch(y: np.ndarray, *, rate: float, **kwargs: Any):
|
|
return librosa.effects.time_stretch(y, rate=rate)
|
|
|
|
@staticmethod
|
|
def res_resample(
|
|
y: np.ndarray,
|
|
*,
|
|
orig_sr: float,
|
|
target_sr: float,
|
|
fix: bool = True,
|
|
**kwargs: Any,
|
|
):
|
|
return librosa.resample(y, orig_sr=orig_sr, target_sr=target_sr, fix=fix)
|
|
|
|
def __init__(
|
|
self,
|
|
resource_folder: Path,
|
|
synthesis_mode: Literal[0, 1, 2, 3, 4] = 1,
|
|
overlay_mode: Literal[1, 2] = 1,
|
|
sample_rate: int = 44100,
|
|
value_get_method: Literal[0, 1] = 1,
|
|
pitch_accuracy_decimals: int = 0,
|
|
music_volume: float = 1,
|
|
music_deviation: float = 0,
|
|
):
|
|
|
|
self.mode = synthesis_mode
|
|
self.overlay_mode_c = overlay_mode
|
|
self.output_sample_rate = sample_rate
|
|
self.get_value_method = value_get_method
|
|
self.resources_path = resource_folder
|
|
|
|
# 在当前算法下,小数点精度必须为 0
|
|
# 此处 TODO,需待旧梦来完善
|
|
self.pitch_precision_decimals = 0
|
|
|
|
self.music_volume = music_volume
|
|
self.pitch_deviation = music_deviation
|
|
|
|
for file in self.resources_path.iterdir():
|
|
if file.is_file():
|
|
self.res_raw_data[file.stem] = librosa.load(file, sr=None)
|
|
|
|
# print(
|
|
# file.stem,": { 'AVG':",
|
|
# np.average(np.absolute(self.res_raw_data[file.stem][0])),", 'MID' :",
|
|
# np.median(np.absolute(self.res_raw_data[file.stem][0])),", 'MAX' :",
|
|
# np.max(np.absolute(self.res_raw_data[file.stem][0])),", 'MIN' :",
|
|
# np.min(np.absolute(self.res_raw_data[file.stem][0])),", 'VAR' :",
|
|
# np.var(np.absolute(self.res_raw_data[file.stem][0])),"}",
|
|
# flush=True,
|
|
# )
|
|
|
|
def res_shift(
|
|
self,
|
|
sound_name: str,
|
|
percussive: bool,
|
|
pitch: int,
|
|
duration: int,
|
|
# rate: int
|
|
):
|
|
original_y, original_sample_rate = self.res_raw_data[sound_name]
|
|
if not percussive:
|
|
if self.mode == 1:
|
|
# 变调, 时域压扩, 重采样 mc方法
|
|
self.res_data[sound_name][pitch] = librosa.resample(
|
|
librosa.effects.time_stretch(
|
|
librosa.effects.pitch_shift(
|
|
original_y, sr=original_sample_rate, n_steps=pitch
|
|
),
|
|
rate=2 ** (pitch / 12),
|
|
),
|
|
orig_sr=original_sample_rate,
|
|
target_sr=self.output_sample_rate,
|
|
fix=False,
|
|
)
|
|
elif self.mode == 0:
|
|
# 重采样, 变调
|
|
self.res_data[sound_name][pitch] = librosa.resample(
|
|
librosa.effects.pitch_shift(
|
|
original_y, sr=original_sample_rate, n_steps=pitch
|
|
),
|
|
orig_sr=original_sample_rate,
|
|
target_sr=self.output_sample_rate,
|
|
fix=False,
|
|
)
|
|
elif self.mode == 4:
|
|
|
|
# 变调, 时域压扩, 重采样 MIDI-FFT
|
|
if self.overlay_mode_c == 2:
|
|
rate = duration / 20 / (len(original_y[0]) / original_sample_rate)
|
|
else:
|
|
rate = duration / 20 / (len(original_y) / original_sample_rate)
|
|
rate = rate if rate != 0 else 1
|
|
self.res_data[sound_name][pitch] = librosa.resample(
|
|
librosa.effects.time_stretch(
|
|
librosa.effects.pitch_shift(
|
|
original_y, sr=original_sample_rate, n_steps=pitch
|
|
),
|
|
rate=rate,
|
|
),
|
|
orig_sr=original_sample_rate,
|
|
target_sr=self.output_sample_rate,
|
|
fix=False,
|
|
)
|
|
elif self.mode == 2:
|
|
# 变调, 时域压扩, 重采样 MIDI-cut
|
|
if self.overlay_mode_c == 2:
|
|
deal = librosa.effects.pitch_shift(
|
|
original_y, sr=original_sample_rate, n_steps=pitch
|
|
)[
|
|
...,
|
|
: (
|
|
int(duration / 20 * original_sample_rate)
|
|
if duration / 20 * original_sample_rate > len(original_y[0])
|
|
else len(original_y[0])
|
|
),
|
|
]
|
|
else:
|
|
deal = librosa.effects.pitch_shift(
|
|
original_y, sr=original_sample_rate, n_steps=pitch
|
|
)[
|
|
: (
|
|
int(duration / 20 * original_sample_rate)
|
|
if duration / 20 * original_sample_rate > len(original_y)
|
|
else len(original_y)
|
|
)
|
|
]
|
|
self.res_data[sound_name][pitch] = librosa.resample(
|
|
deal,
|
|
orig_sr=original_sample_rate,
|
|
target_sr=self.output_sample_rate,
|
|
fix=False,
|
|
)
|
|
else:
|
|
# if self.mode == 1:
|
|
# 重采样, 不变调
|
|
# print(">>", sound_name, pitch)
|
|
self.res_data[sound_name][pitch] = librosa.resample(
|
|
original_y,
|
|
orig_sr=original_sample_rate,
|
|
target_sr=self.output_sample_rate,
|
|
fix=False, # 尽管这样不变调,但是也不应将 pitch 与打击乐器联系在一起
|
|
) # 已经在下面重新用 0 取代了打击乐器的 pitch
|
|
"""elif self.mode == 0:
|
|
# 重采样, 不变调, 衰弱
|
|
self.cache_dict[raw_name] = librosa_resample(
|
|
y_orig,
|
|
orig_sr=sr_orig,
|
|
target_sr=self.out_sr,
|
|
fix=False
|
|
)"""
|
|
|
|
def make_pitch_accurate(self, pitch: float) -> int:
|
|
"""
|
|
相信你知道的,小数并不准确
|
|
"""
|
|
return int((pitch + self.pitch_deviation) * (10**self.pitch_precision_decimals))
|
|
|
|
# 获取资源方法,需要指定采样率,采样率变了即全局重载
|
|
# 资源存储类里面有就取,没就写入并标识已有数据
|
|
def get_res(
|
|
self,
|
|
sound_name: str,
|
|
pitch: float,
|
|
percussive: bool,
|
|
duration: int,
|
|
):
|
|
|
|
# print(
|
|
# sound_name,
|
|
# pitch,
|
|
# ">>",
|
|
# (pitch := self.make_pitch_accurate(pitch)),
|
|
# percussive,
|
|
# duration,
|
|
# )
|
|
pitch = 0 if percussive else self.make_pitch_accurate(pitch)
|
|
|
|
if sound_name in self.res_data:
|
|
if pitch in self.res_data[sound_name]:
|
|
return self.res_data[sound_name][pitch]
|
|
else:
|
|
self.res_shift(
|
|
sound_name=sound_name,
|
|
percussive=percussive,
|
|
pitch=pitch,
|
|
duration=duration,
|
|
)
|
|
# print(2, self.res_data[packageID][soundID][pitch])
|
|
return self.res_data[sound_name][pitch]
|
|
else:
|
|
self.res_data[sound_name] = {}
|
|
self.res_shift(
|
|
sound_name=sound_name,
|
|
percussive=percussive,
|
|
pitch=pitch,
|
|
duration=duration,
|
|
)
|
|
# print(2, self.res_data[packageID][soundID][pitch])
|
|
return self.res_data[sound_name][pitch]
|
|
|
|
def convert(self, music: SingleMusic, start_tick: float = 0, end_tick: float = inf):
|
|
|
|
if self.overlay_mode_c == 1:
|
|
|
|
def overlay(seg_overlay: np.ndarray, pos_tick: int):
|
|
pos_ = int(out_sr * pos_tick * 0.05)
|
|
wav_model[pos_ : seg_overlay.size + pos_] += seg_overlay
|
|
|
|
# print(0)
|
|
wav_model = np.zeros(
|
|
int(
|
|
max(
|
|
[
|
|
(
|
|
i.start_tick * 0.05 * self.output_sample_rate
|
|
+ len(
|
|
self.get_res(
|
|
sound_name=i.instrument,
|
|
pitch=i.pitch
|
|
- 60
|
|
- MM_INSTRUMENT_DEVIATION_TABLE.get(
|
|
i.instrument, 6
|
|
),
|
|
percussive=i.percussive,
|
|
duration=i.duration_tick,
|
|
)
|
|
)
|
|
)
|
|
for i in music.get_minenotes(
|
|
start_time=start_tick, end_time=end_tick
|
|
)
|
|
]
|
|
)
|
|
),
|
|
dtype=np.float32,
|
|
)
|
|
|
|
elif self.overlay_mode_c == 2:
|
|
|
|
def overlay(seg_overlay: np.ndarray, pos_tick: int):
|
|
pos_ = int(out_sr * pos_tick * 0.05)
|
|
wav_model[..., pos_ : len(seg_overlay[0]) + pos_] += seg_overlay
|
|
|
|
wav_model = np.zeros(
|
|
(
|
|
2,
|
|
int(
|
|
max(
|
|
[
|
|
(
|
|
i.start_tick * 0.05 * self.output_sample_rate
|
|
+ len(
|
|
self.get_res(
|
|
sound_name=i.instrument,
|
|
pitch=i.pitch
|
|
- 60
|
|
- MM_INSTRUMENT_DEVIATION_TABLE.get(
|
|
i.instrument, 6
|
|
),
|
|
percussive=i.percussive,
|
|
duration=i.duration_tick,
|
|
)
|
|
)
|
|
)
|
|
for i in music.get_minenotes(
|
|
start_time=start_tick, end_time=end_tick
|
|
)
|
|
]
|
|
)
|
|
),
|
|
),
|
|
dtype=np.float32,
|
|
)
|
|
|
|
else:
|
|
raise ValueError("错误的 overlay_mode 参数")
|
|
|
|
out_sr = self.output_sample_rate
|
|
|
|
for note in music.get_minenotes(start_time=start_tick, end_time=end_tick):
|
|
accurate_pitch = (
|
|
0
|
|
if note.percussive
|
|
else self.make_pitch_accurate(
|
|
note.pitch
|
|
- 60
|
|
- MM_INSTRUMENT_DEVIATION_TABLE.get(note.instrument, 6)
|
|
)
|
|
)
|
|
# print(
|
|
# ":",
|
|
# note.instrument,
|
|
# note.pitch,
|
|
# ">>",
|
|
# accurate_pitch,
|
|
# note.percussive,
|
|
# note.duration_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
|
|
max_val = np.max(np.abs(wav_model))
|
|
if not max_val == 0:
|
|
wav_model = wav_model / max_val
|
|
elif self.get_value_method == 1:
|
|
wav_model[wav_model > 1] = 1
|
|
wav_model[wav_model < -1] = -1
|
|
if self.overlay_mode_c == 2:
|
|
return wav_model.T
|
|
else:
|
|
return wav_model[:, np.newaxis]
|
|
|
|
def stream(self, music: SingleMusic):
|
|
pass
|
|
|
|
# debug
|
|
def to_wav_file(self, single_music: SingleMusic, out_file_path: Path):
|
|
|
|
sf.write(
|
|
out_file_path,
|
|
self.convert(single_music),
|
|
samplerate=self.output_sample_rate,
|
|
format="wav",
|
|
)
|
|
return out_file_path
|
|
|
|
def to_wav_file_byte(self, single_music: SingleMusic, output_file: BinaryIO):
|
|
sf.write(
|
|
output_file,
|
|
self.convert(single_music),
|
|
samplerate=self.output_sample_rate,
|
|
format="wav",
|
|
)
|