Add HeXi bot codebase: custom plugins, web frontends, tests
- hexi core: message handling, rate limiting, cooldown, plugin manager - Custom plugins: BF stats, daily check-in, quotes, persona cards, etc. - Community plugins vendored under hexi/plugins with local fixes - Web admin frontends (learning-chat, persona-admin), unified hexi/web - Tests for rate_limit/cooldown/memes/persona; poetry.lock Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
# -*- coding: UTF-8 -*-
|
||||
#鬼畜音源的活字印刷
|
||||
#作者:DSP_8192
|
||||
|
||||
import soundfile as sf
|
||||
import psola
|
||||
import numpy as np
|
||||
from pypinyin import lazy_pinyin
|
||||
import json
|
||||
from pathlib import Path
|
||||
from io import BytesIO
|
||||
import base64
|
||||
|
||||
|
||||
|
||||
|
||||
#--------------------------------------------
|
||||
#全局变量
|
||||
#--------------------------------------------
|
||||
#目标采样率
|
||||
_targetSR = 44100
|
||||
|
||||
|
||||
|
||||
|
||||
#--------------------------------------------
|
||||
#自定义函数
|
||||
#--------------------------------------------
|
||||
#文件路径转文件夹路径
|
||||
def _fileName2FolderName(fileName):
|
||||
for i in range(len(fileName)-1, -1, -1):
|
||||
if fileName[i] == '\\' or fileName[i] == '/':
|
||||
return fileName[0:i+1]
|
||||
|
||||
|
||||
|
||||
#标准化音频,统一音量
|
||||
def _normalizeAudio(data):
|
||||
rms = np.sqrt(np.mean(data**2))
|
||||
normData = data / rms * 0.2
|
||||
return normData
|
||||
|
||||
|
||||
|
||||
#读取音频文件
|
||||
def _loadAudio(fileDir, norm):
|
||||
data, sampleRate = sf.read(fileDir)
|
||||
#双声道转单声道
|
||||
if (len(data.shape) == 2):
|
||||
#左右声道相加除以2
|
||||
data = (data[:, 0] + data[:, 1]) / 2
|
||||
#统一采样率
|
||||
if (sampleRate != _targetSR):
|
||||
#计算转换后的长度
|
||||
newLength = int((_targetSR / sampleRate) * len(data))
|
||||
#转换
|
||||
data = np.interp(np.array(range(newLength)), np.linspace(0,newLength-1,len(data)), data)
|
||||
#标准化
|
||||
if norm:
|
||||
data = _normalizeAudio(data)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
#改变音高和速度
|
||||
def _modifyPitchAndSpeed(data, pitchMultiple, speedMultiple):
|
||||
if (pitchMultiple == 1 and speedMultiple == 1):
|
||||
#没有改动的必要,直接返回
|
||||
return data
|
||||
|
||||
elif (pitchMultiple > 2 or speedMultiple < 0.5):
|
||||
print("过于极端的音调和速度参数可能导致输出结果与预期不符,故不作改动")
|
||||
return data
|
||||
|
||||
else:
|
||||
#第一次拉伸
|
||||
if (speedMultiple / pitchMultiple == 1):
|
||||
#没有拉伸的必要
|
||||
step1 = data
|
||||
else:
|
||||
#不改变音高的同时在时间上拉伸(PSOLA)
|
||||
#constant_stretch过小会导致bug,因此分两次拉伸
|
||||
step1 = psola.vocode(data, _targetSR, constant_stretch=1/pitchMultiple)
|
||||
step1 = psola.vocode(step1, _targetSR, constant_stretch=speedMultiple)
|
||||
#第二次拉伸,以改变音高的方式拉伸回来
|
||||
newLength = int(len(data) / speedMultiple)
|
||||
step2 = np.interp(np.array(range(newLength)), np.linspace(0,newLength-1,len(step1)), step1)
|
||||
return step2
|
||||
|
||||
|
||||
|
||||
|
||||
#--------------------------------------------
|
||||
#活字印刷类
|
||||
#--------------------------------------------
|
||||
class huoZiYinShua:
|
||||
def __init__(self, configuration):
|
||||
try:
|
||||
#读取设置文件
|
||||
dictFile = open(configuration["dictFile"], encoding="utf8") #读取单字词典 (json)
|
||||
ysddTableFile = open(configuration["ysddTableFile"], encoding="utf8") #读取原声大碟文本与文件名对照表 (json)
|
||||
|
||||
self.__voicePath = configuration["sourceDirectory"] #单字音频文件存放目录
|
||||
self.__ysddPath = configuration["ysddSourceDirectory"] #原声大碟音频文件存放目录
|
||||
self.__dictionary = json.load(dictFile) #定义非中文字符读法的词典
|
||||
self.__ysddTable = json.load(ysddTableFile) #原声大碟文本与文件名对照表
|
||||
|
||||
#统一为小写
|
||||
dictItems = list(self.__ysddTable.items())
|
||||
#转换为list是为了切断dictItems与self.__ysddTable的联系,否则报错dictionary changed size during iteration
|
||||
for i in dictItems:
|
||||
self.__ysddTable[i[0].lower()] = self.__ysddTable.pop(i[0])
|
||||
#从长到短排序,越长片段优先级越高
|
||||
self.__ysddTable = sorted(self.__ysddTable.items(),
|
||||
key=lambda x:len(x[0]),
|
||||
reverse=True)
|
||||
self.__ysddTable = dict(self.__ysddTable)
|
||||
|
||||
self.__configSucceed = True
|
||||
|
||||
except Exception as e:
|
||||
self.__configSucceed = False
|
||||
print(e)
|
||||
|
||||
|
||||
|
||||
#配置是否成功
|
||||
def configSucceed(self):
|
||||
return self.__configSucceed
|
||||
|
||||
|
||||
|
||||
|
||||
#返回b64音频
|
||||
def export(self, rawData, inYsddMode=False,
|
||||
pitchMult=1, speedMult=1, norm=False, reverse=False):
|
||||
self.__concatenate(rawData, inYsddMode, pitchMult, speedMult, norm, reverse)
|
||||
base64_data = base64.b64encode(self.__export().getvalue())
|
||||
return base64_data
|
||||
|
||||
|
||||
|
||||
#生成中间文件
|
||||
def __concatenate(self, rawData, inYsddMode, pitchMult, speedMult, norm, reverse):
|
||||
missingPinyin = []
|
||||
self.__concatenated = np.array([])
|
||||
|
||||
#预处理,转为小写
|
||||
rawData = rawData.lower()
|
||||
pronunciations = []
|
||||
|
||||
#分割使用活字印刷的部分和使用原声大碟的部分
|
||||
splitted = [[rawData, False]] #[文本, 是否使用原声大碟]
|
||||
#遍历要匹配的句子
|
||||
if inYsddMode:
|
||||
for ysdd in self.__ysddTable.items():
|
||||
#遍历文本
|
||||
i = -1
|
||||
while i < (len(splitted) - 1):
|
||||
i += 1
|
||||
if splitted[i][1]: #已经被划分为原声大碟部分
|
||||
continue
|
||||
#存在匹配
|
||||
if ysdd[0] in splitted[i][0]:
|
||||
indexBegin = splitted[i][0].index(ysdd[0]) #获取开始位置
|
||||
#分割
|
||||
splitted.insert(i+1, [splitted[i][0][indexBegin:indexBegin+len(ysdd[0])], True])
|
||||
splitted.insert(i+2, [splitted[i][0][indexBegin+len(ysdd[0]):], False])
|
||||
splitted[i][0] = splitted[i][0][0:indexBegin]
|
||||
|
||||
|
||||
#转换自定义的字符
|
||||
for i in range(0, len(splitted)):
|
||||
pronunciations.append([])
|
||||
#使用活字印刷
|
||||
if (not splitted[i][1]):
|
||||
pronunciations[i].append("")
|
||||
for ch in splitted[i][0]:
|
||||
if ch in self.__dictionary:
|
||||
#词典中存在匹配,转换
|
||||
pronunciations[i][0] += self.__dictionary[ch] + " "
|
||||
else:
|
||||
#保持不变
|
||||
pronunciations[i][0] += ch + " "
|
||||
pronunciations[i].append(False) #标记
|
||||
#使用原声大碟
|
||||
else:
|
||||
pronunciations[i].append(splitted[i][0]) #直接复制
|
||||
pronunciations[i].append(True) #标记
|
||||
|
||||
|
||||
#拼接音频
|
||||
for i in range(0, len(pronunciations)):
|
||||
#使用活字印刷
|
||||
if (not pronunciations[i][1]):
|
||||
#将汉字转换成拼音
|
||||
pinyin = lazy_pinyin(pronunciations[i][0])
|
||||
#拆成单独的字
|
||||
for text in pinyin:
|
||||
for word in text.split():
|
||||
#拼接每一个字
|
||||
try:
|
||||
self.__concatenated = np.concatenate((self.__concatenated,
|
||||
_loadAudio(self.__voicePath
|
||||
+ word + ".wav",
|
||||
norm)))
|
||||
#如果出现错误
|
||||
except Exception as e:
|
||||
if word != ",":
|
||||
print(e) #显示错误信息
|
||||
#加入缺失素材列表
|
||||
if word not in missingPinyin:
|
||||
missingPinyin.append(word)
|
||||
#以空白音频代替
|
||||
self.__concatenated = np.concatenate((self.__concatenated,
|
||||
np.zeros(int(_targetSR/4))))
|
||||
|
||||
#使用原声大碟
|
||||
else:
|
||||
#拼接
|
||||
try:
|
||||
self.__concatenated = np.concatenate((self.__concatenated,
|
||||
_loadAudio(self.__ysddPath
|
||||
+ self.__ysddTable[pronunciations[i][0]],
|
||||
norm)))
|
||||
#如果出现错误
|
||||
except Exception as e:
|
||||
print(e) #显示错误信息
|
||||
#加入缺失素材列表
|
||||
if self.__ysddTable[pronunciations[i][0]] not in missingPinyin:
|
||||
missingPinyin.append(self.__ysddTable[pronunciations[i][0]])
|
||||
#以空白音频代替
|
||||
self.__concatenated = np.concatenate((self.__concatenated,
|
||||
np.zeros(int(_targetSR/4))))
|
||||
|
||||
|
||||
#音高偏移
|
||||
self.__concatenated = _modifyPitchAndSpeed(self.__concatenated, pitchMult, speedMult)
|
||||
|
||||
#倒放
|
||||
if(reverse):
|
||||
self.__concatenated = np.flip(self.__concatenated)
|
||||
|
||||
#如果缺失拼音,则发出警告
|
||||
#if len(missingPinyin) != 0:
|
||||
# print("警告:缺失或未定义{}".format(missingPinyin))
|
||||
|
||||
|
||||
|
||||
#返回二进制音频
|
||||
def __export(self):
|
||||
bytesout = BytesIO()
|
||||
sf.write(bytesout, self.__concatenated, _targetSR, format="MP3")
|
||||
return bytesout
|
||||
Reference in New Issue
Block a user