refactor: QQ → OBv11 重命名 + 平台格式统一抽象
- 所有对外称呼从 QQ 改为 OBv11(注释/提示词/日志/配置项) - 新增 PlatformFormat 结构体,统一管理平台消息标记格式 - defaultPlatformFormats() 注册表替代硬编码 qqTargetRe - extractProactiveMessage 改为 Thinker 方法,遍历格式注册表匹配 - 配置项重命名: QQ_BOT_PORT → OBV11_BOT_PORT, QQBotPort → OBv11BotPort - 标记格式: 【QQ群聊】→【OBv11群聊】、【QQ私聊】→【OBv11私聊】 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
@@ -932,7 +932,7 @@ func handleChat(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Admin private messages: redirect to the main admin session so conversation
|
// Admin private messages: redirect to the main admin session so conversation
|
||||||
// history is shared across platforms (QQ, web UI, etc.).
|
// history is shared across platforms (OBv11, web UI, etc.).
|
||||||
if req.UserID == "admin" && req.Source.ChannelType == "direct" && adminSessionID != "" {
|
if req.UserID == "admin" && req.Source.ChannelType == "direct" && adminSessionID != "" {
|
||||||
req.SessionID = adminSessionID
|
req.SessionID = adminSessionID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,24 +30,59 @@ type PendingThought struct {
|
|||||||
Consumed bool `json:"consumed"`
|
Consumed bool `json:"consumed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProactiveTarget describes the target for proactive platform messages (QQ, etc.).
|
// ProactiveTarget describes the target for proactive platform messages (OBv11, etc.).
|
||||||
// When nil, the message goes through the existing Web push path.
|
// When nil, the message goes through the existing Web push path.
|
||||||
type ProactiveTarget struct {
|
type ProactiveTarget struct {
|
||||||
Platform string // "qq"
|
Platform string // "qq"
|
||||||
ChatType string // "private" or "group"
|
ChatType string // "private" or "group"
|
||||||
UserID string // QQ user number
|
UserID string // OBv11 user number
|
||||||
GroupID string // QQ group number (group chat)
|
GroupID string // OBv11 group number (group chat)
|
||||||
AtUserID string // QQ number to @mention (optional)
|
AtUserID string // OBv11 number to @mention (optional)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlatformChannel represents a platform channel to observe for background thinking.
|
// PlatformChannel represents a platform channel to observe for background thinking.
|
||||||
type PlatformChannel struct {
|
type PlatformChannel struct {
|
||||||
Platform string // qq, telegram, etc.
|
Platform string // qq, telegram, etc.
|
||||||
ChannelType string // group, private
|
ChannelType string // group, private
|
||||||
ChannelID string // group ID or user QQ number
|
ChannelID string // group ID or user OBv11 number
|
||||||
ChannelName string // group name or user display name (resolved at runtime)
|
ChannelName string // group name or user display name (resolved at runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlatformFormat describes how proactive message targets are formatted for a platform.
|
||||||
|
type PlatformFormat struct {
|
||||||
|
// Human-readable platform label (e.g. "OBv11", "Telegram").
|
||||||
|
Label string
|
||||||
|
// Regex pattern with 3 capture groups: (chatType, channelID, atUserID).
|
||||||
|
// chatType group must contain the platform-specific chat type keyword (e.g. "群聊", "私聊").
|
||||||
|
TargetRegex string
|
||||||
|
// Compiled regex (populated at registration time).
|
||||||
|
compiled *regexp.Regexp
|
||||||
|
// Format verbs for building markers in prompts.
|
||||||
|
GroupMarkerFmt string // e.g. "【OBv11群聊:%s】"
|
||||||
|
PrivateMarkerFmt string // e.g. "【OBv11私聊:%s】"
|
||||||
|
GroupAtMarkerFmt string // e.g. "【OBv11群聊:%s@%s】"
|
||||||
|
// Chat type strings used by this platform in regex and markers.
|
||||||
|
GroupKeyword string // e.g. "群聊"
|
||||||
|
PrivateKeyword string // e.g. "私聊"
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultPlatformFormats returns the built-in platform format registry.
|
||||||
|
func defaultPlatformFormats() map[string]*PlatformFormat {
|
||||||
|
formats := make(map[string]*PlatformFormat)
|
||||||
|
qq := &PlatformFormat{
|
||||||
|
Label: "OBv11",
|
||||||
|
TargetRegex: `【OBv11(私聊|群聊):(\d+)(?:@(\d+))?】`,
|
||||||
|
GroupMarkerFmt: "【OBv11群聊:%s】",
|
||||||
|
PrivateMarkerFmt: "【OBv11私聊:%s】",
|
||||||
|
GroupAtMarkerFmt: "【OBv11群聊:%s@%s】",
|
||||||
|
GroupKeyword: "群聊",
|
||||||
|
PrivateKeyword: "私聊",
|
||||||
|
}
|
||||||
|
qq.compiled = regexp.MustCompile(qq.TargetRegex)
|
||||||
|
formats["qq"] = qq
|
||||||
|
return formats
|
||||||
|
}
|
||||||
|
|
||||||
// ParsePlatformChannels parses PLATFORM_CHANNELS env var.
|
// ParsePlatformChannels parses PLATFORM_CHANNELS env var.
|
||||||
// Format: "qq:group:123456,telegram:group:789012"
|
// Format: "qq:group:123456,telegram:group:789012"
|
||||||
// Optional 4th field for display name: "qq:group:123456:群名称"
|
// Optional 4th field for display name: "qq:group:123456:群名称"
|
||||||
@@ -193,6 +228,9 @@ type Thinker struct {
|
|||||||
|
|
||||||
// 平台 Bot UID (platform -> bot's own UID, e.g. "qq" -> "123456789")
|
// 平台 Bot UID (platform -> bot's own UID, e.g. "qq" -> "123456789")
|
||||||
botUIDs map[string]string
|
botUIDs map[string]string
|
||||||
|
|
||||||
|
// 平台格式注册表 (platform -> format)
|
||||||
|
platformFormats map[string]*PlatformFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutonomousToolPolicy 自主思考工具调用安全策略
|
// AutonomousToolPolicy 自主思考工具调用安全策略
|
||||||
@@ -237,14 +275,14 @@ func (t *Thinker) SetMessagePusher(pusher func(string, string, string)) {
|
|||||||
t.messagePusher = pusher
|
t.messagePusher = pusher
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPlatformMessagePusher sets the callback for pushing proactive messages to platform adapters (QQ, etc.).
|
// SetPlatformMessagePusher sets the callback for pushing proactive messages to platform adapters (OBv11, etc.).
|
||||||
func (t *Thinker) SetPlatformMessagePusher(pusher func(ProactiveTarget, string)) {
|
func (t *Thinker) SetPlatformMessagePusher(pusher func(ProactiveTarget, string)) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
t.platformMessagePusher = pusher
|
t.platformMessagePusher = pusher
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetBotUID sets the bot's own platform UID (e.g., QQ number).
|
// SetBotUID sets the bot's own platform UID (e.g., OBv11 account).
|
||||||
func (t *Thinker) SetBotUID(platform, uid string) {
|
func (t *Thinker) SetBotUID(platform, uid string) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
@@ -412,6 +450,7 @@ func NewThinker(
|
|||||||
proactiveGuard: DefaultProactiveGuard(),
|
proactiveGuard: DefaultProactiveGuard(),
|
||||||
platformChannels: cfg.PlatformChannels,
|
platformChannels: cfg.PlatformChannels,
|
||||||
platformThinkInterval: cfg.PlatformSilentThinkInterval,
|
platformThinkInterval: cfg.PlatformSilentThinkInterval,
|
||||||
|
platformFormats: defaultPlatformFormats(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1481,7 +1520,7 @@ func (t *Thinker) buildThinkingUserPrompt(
|
|||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// QQ platform identity and available channels for proactive messaging.
|
// OBv11 platform identity and available channels for proactive messaging.
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
qqChannels := t.platformChannels
|
qqChannels := t.platformChannels
|
||||||
botUIDs := t.botUIDs
|
botUIDs := t.botUIDs
|
||||||
@@ -1489,11 +1528,11 @@ func (t *Thinker) buildThinkingUserPrompt(
|
|||||||
lastMsgTime := t.lastUserMessage
|
lastMsgTime := t.lastUserMessage
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
if len(qqChannels) > 0 {
|
if len(qqChannels) > 0 {
|
||||||
sb.WriteString("\n\n【你的QQ平台身份与可用频道】\n")
|
sb.WriteString("\n\n【你的平台身份与可用频道】\n")
|
||||||
|
|
||||||
// Bot's own identity.
|
// Bot's own identity.
|
||||||
if qqBotUID, ok := botUIDs["qq"]; ok && qqBotUID != "" {
|
if qqBotUID, ok := botUIDs["qq"]; ok && qqBotUID != "" {
|
||||||
sb.WriteString(fmt.Sprintf("你在QQ上的账号是: %s(昔涟),这就是你。\n", qqBotUID))
|
sb.WriteString(fmt.Sprintf("你在OBv11上的账号是: %s(昔涟),这就是你。\n", qqBotUID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active session context with time, group name, and trigger-aware guidance.
|
// Active session context with time, group name, and trigger-aware guidance.
|
||||||
@@ -1518,23 +1557,23 @@ func (t *Thinker) buildThinkingUserPrompt(
|
|||||||
if ch.ChannelType == "group" {
|
if ch.ChannelType == "group" {
|
||||||
if triggerReason == "post_chat" {
|
if triggerReason == "post_chat" {
|
||||||
if timeHint != "" && timeHint != "刚刚" {
|
if timeHint != "" && timeHint != "刚刚" {
|
||||||
sb.WriteString(fmt.Sprintf("【%s】你当前正在QQ群聊 %s 中。刚刚群里有人说了话——如果你想回应,用【主动消息】【QQ群聊:%s】格式输出。\n", timeHint, chLabel, activeChID))
|
sb.WriteString(fmt.Sprintf("【%s】你当前正在OBv11群聊 %s 中。刚刚群里有人说了话——如果你想回应,用【主动消息】【OBv11群聊:%s】格式输出。\n", timeHint, chLabel, activeChID))
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString(fmt.Sprintf("【刚刚】你当前正在QQ群聊 %s 中。群里有人说了话——如果你想回应,用【主动消息】【QQ群聊:%s】格式输出。\n", chLabel, activeChID))
|
sb.WriteString(fmt.Sprintf("【刚刚】你当前正在OBv11群聊 %s 中。群里有人说了话——如果你想回应,用【主动消息】【OBv11群聊:%s】格式输出。\n", chLabel, activeChID))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Autonomous thinking (periodic, silence, startup).
|
// Autonomous thinking (periodic, silence, startup).
|
||||||
if timeHint != "" {
|
if timeHint != "" {
|
||||||
sb.WriteString(fmt.Sprintf("【上次活跃: %s】你当前在QQ群聊 %s 中。现在是自主思考时间——如果你想主动对群里说些什么,用【主动消息】【QQ群聊:%s】格式。\n", timeHint, chLabel, activeChID))
|
sb.WriteString(fmt.Sprintf("【上次活跃: %s】你当前在OBv11群聊 %s 中。现在是自主思考时间——如果你想主动对群里说些什么,用【主动消息】【OBv11群聊:%s】格式。\n", timeHint, chLabel, activeChID))
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString(fmt.Sprintf("你当前在QQ群聊 %s 中。现在是自主思考时间——如果你想主动对群里说些什么,用【主动消息】【QQ群聊:%s】格式。\n", chLabel, activeChID))
|
sb.WriteString(fmt.Sprintf("你当前在OBv11群聊 %s 中。现在是自主思考时间——如果你想主动对群里说些什么,用【主动消息】【OBv11群聊:%s】格式。\n", chLabel, activeChID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if triggerReason == "post_chat" {
|
if triggerReason == "post_chat" {
|
||||||
sb.WriteString(fmt.Sprintf("【刚刚】你当前正在与QQ用户 %s 私聊。想回应ta的话用【主动消息】【QQ私聊:%s】格式。\n", chLabel, activeChID))
|
sb.WriteString(fmt.Sprintf("【刚刚】你当前正在与OBv11用户 %s 私聊。想回应ta的话用【主动消息】【OBv11私聊:%s】格式。\n", chLabel, activeChID))
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString(fmt.Sprintf("你当前在与QQ用户 %s 私聊。现在是自主思考时间——想主动发消息用【主动消息】【QQ私聊:%s】格式。\n", chLabel, activeChID))
|
sb.WriteString(fmt.Sprintf("你当前在与OBv11用户 %s 私聊。现在是自主思考时间——想主动发消息用【主动消息】【OBv11私聊:%s】格式。\n", chLabel, activeChID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -1543,10 +1582,10 @@ func (t *Thinker) buildThinkingUserPrompt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString("\n发送主动消息的格式:\n")
|
sb.WriteString("\n发送主动消息的格式:\n")
|
||||||
sb.WriteString("- QQ私聊: 【主动消息】【QQ私聊:QQ号】消息内容\n")
|
sb.WriteString("- OBv11私聊: 【主动消息】【OBv11私聊:账号】消息内容\n")
|
||||||
sb.WriteString("- QQ群聊: 【主动消息】【QQ群聊:群号】消息内容\n")
|
sb.WriteString("- OBv11群聊: 【主动消息】【OBv11群聊:群号】消息内容\n")
|
||||||
sb.WriteString("- QQ群聊@某人: 【主动消息】【QQ群聊:群号@QQ号】消息内容\n")
|
sb.WriteString("- OBv11群聊@某人: 【主动消息】【OBv11群聊:群号@账号】消息内容\n")
|
||||||
sb.WriteString("\n可用的QQ频道列表:\n")
|
sb.WriteString("\n可用的平台频道列表:\n")
|
||||||
for _, ch := range qqChannels {
|
for _, ch := range qqChannels {
|
||||||
if ch.Platform != "qq" {
|
if ch.Platform != "qq" {
|
||||||
continue
|
continue
|
||||||
@@ -1567,7 +1606,7 @@ func (t *Thinker) buildThinkingUserPrompt(
|
|||||||
sb.WriteString("- 群聊消息要有公共价值,不要当成私聊\n")
|
sb.WriteString("- 群聊消息要有公共价值,不要当成私聊\n")
|
||||||
sb.WriteString("- @某人时:确认这个人在群里有发言过,你的@内容是对他之前说的话的回应\n")
|
sb.WriteString("- @某人时:确认这个人在群里有发言过,你的@内容是对他之前说的话的回应\n")
|
||||||
if triggerReason == "post_chat" {
|
if triggerReason == "post_chat" {
|
||||||
sb.WriteString("- 重要:如果你在反思中想往QQ群发消息,必须用【主动消息】【QQ群聊:群号】格式,不要省略群号!\n")
|
sb.WriteString("- 重要:如果你在反思中想往平台群发消息,必须用【主动消息】【OBv11群聊:群号】格式,不要省略群号!\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1639,8 +1678,8 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
|
|||||||
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-10:]
|
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-10:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract proactive message and optional QQ target.
|
// Extract proactive message and optional platform target.
|
||||||
proactiveMsg, proactiveTarget := extractProactiveMessage(content)
|
proactiveMsg, proactiveTarget := t.extractProactiveMessage(content)
|
||||||
// Prefer active session, fall back to admin main session.
|
// Prefer active session, fall back to admin main session.
|
||||||
pushSessionID := t.activeSessionID
|
pushSessionID := t.activeSessionID
|
||||||
if pushSessionID == "" {
|
if pushSessionID == "" {
|
||||||
@@ -1729,13 +1768,10 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
|
|||||||
//
|
//
|
||||||
// 要求标记独立成行(前面只有空白或行首),避免把自然语言中的提及
|
// 要求标记独立成行(前面只有空白或行首),避免把自然语言中的提及
|
||||||
// 当作指令(如 "不需要写【主动消息】" 这类否定表述)。
|
// 当作指令(如 "不需要写【主动消息】" 这类否定表述)。
|
||||||
// qqTargetRe matches QQ target markers like 【QQ私聊:123456】, 【QQ群聊:789012】, 【QQ群聊:789012@123456】
|
// extractProactiveMessage extracts the 【主动消息】 marker and optional platform target from thinking content.
|
||||||
var qqTargetRe = regexp.MustCompile(`【QQ(私聊|群聊):(\d+)(?:@(\d+))?】`)
|
|
||||||
|
|
||||||
// extractProactiveMessage extracts the 【主动消息】 marker and optional QQ target from thinking content.
|
|
||||||
// Returns the message content and an optional ProactiveTarget for platform delivery.
|
// Returns the message content and an optional ProactiveTarget for platform delivery.
|
||||||
// When target is nil, the message goes through the existing Web push path.
|
// When target is nil, the message goes through the existing Web push path.
|
||||||
func extractProactiveMessage(content string) (string, *ProactiveTarget) {
|
func (t *Thinker) extractProactiveMessage(content string) (string, *ProactiveTarget) {
|
||||||
marker := "【主动消息】"
|
marker := "【主动消息】"
|
||||||
|
|
||||||
// Scan each line; only accept lines where the marker starts the line (ignoring leading whitespace).
|
// Scan each line; only accept lines where the marker starts the line (ignoring leading whitespace).
|
||||||
@@ -1756,33 +1792,38 @@ func extractProactiveMessage(content string) (string, *ProactiveTarget) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse optional QQ target marker right after 【主动消息】.
|
// Parse optional platform target marker right after 【主动消息】.
|
||||||
msg := raw
|
msg := raw
|
||||||
var target *ProactiveTarget
|
var target *ProactiveTarget
|
||||||
if loc := qqTargetRe.FindStringSubmatchIndex(raw); loc != nil && loc[0] == 0 {
|
// Try each registered platform format.
|
||||||
m := qqTargetRe.FindStringSubmatch(raw)
|
for platform, pf := range t.platformFormats {
|
||||||
if len(m) == 4 {
|
if pf.compiled == nil {
|
||||||
chatType := "private"
|
|
||||||
if m[1] == "群聊" {
|
|
||||||
chatType = "group"
|
|
||||||
}
|
|
||||||
target = &ProactiveTarget{
|
|
||||||
Platform: "qq",
|
|
||||||
ChatType: chatType,
|
|
||||||
UserID: m[2],
|
|
||||||
GroupID: m[2],
|
|
||||||
AtUserID: m[3],
|
|
||||||
}
|
|
||||||
// For private chat, UserID is the QQ number; GroupID stays empty.
|
|
||||||
if chatType == "private" {
|
|
||||||
target.GroupID = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Remove the QQ target marker from the message content.
|
|
||||||
msg = strings.TrimSpace(raw[loc[1]:])
|
|
||||||
if msg == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if loc := pf.compiled.FindStringSubmatchIndex(raw); loc != nil && loc[0] == 0 {
|
||||||
|
m := pf.compiled.FindStringSubmatch(raw)
|
||||||
|
if len(m) == 4 {
|
||||||
|
chatType := "private"
|
||||||
|
if m[1] == pf.GroupKeyword {
|
||||||
|
chatType = "group"
|
||||||
|
}
|
||||||
|
target = &ProactiveTarget{
|
||||||
|
Platform: platform,
|
||||||
|
ChatType: chatType,
|
||||||
|
UserID: m[2],
|
||||||
|
GroupID: m[2],
|
||||||
|
AtUserID: m[3],
|
||||||
|
}
|
||||||
|
if chatType == "private" {
|
||||||
|
target.GroupID = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg = strings.TrimSpace(raw[loc[1]:])
|
||||||
|
if msg == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit message length (200 chars max, keep it short).
|
// Limit message length (200 chars max, keep it short).
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func inferAudioFormat(urlStr, contentType string) string {
|
|||||||
if strings.Contains(contentType, "audio/ogg") || strings.Contains(contentType, "opus") {
|
if strings.Contains(contentType, "audio/ogg") || strings.Contains(contentType, "opus") {
|
||||||
return "ogg"
|
return "ogg"
|
||||||
}
|
}
|
||||||
return "amr" // default for QQ voice messages
|
return "amr" // default for OBv11 voice messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *DashScopeASRProvider) Transcribe(ctx context.Context, audioURL, language string) (string, error) {
|
func (p *DashScopeASRProvider) Transcribe(ctx context.Context, audioURL, language string) (string, error) {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func (e *Extractor) extractObservationsWithLLM(ctx context.Context, message stri
|
|||||||
观察到的消息: %s
|
观察到的消息: %s
|
||||||
|
|
||||||
请以JSON格式返回提取的记忆。这条消息来自群聊/频道,昔涟只是旁观者。
|
请以JSON格式返回提取的记忆。这条消息来自群聊/频道,昔涟只是旁观者。
|
||||||
消息格式为:[群聊 群号] 发送者昵称 (QQ号):消息内容
|
消息格式为:[群聊 群号] 发送者昵称 (OBv11账号):消息内容
|
||||||
提取角度:这条消息中包含了什么关于消息发送者、讨论主题、事件或氛围的信息?
|
提取角度:这条消息中包含了什么关于消息发送者、讨论主题、事件或氛围的信息?
|
||||||
重要:请以实际发送者的名字为主语(如"某某说..."),不要统一用"开拓者"称呼所有发言者。
|
重要:请以实际发送者的名字为主语(如"某某说..."),不要统一用"开拓者"称呼所有发言者。
|
||||||
|
|
||||||
|
|||||||
@@ -138,8 +138,8 @@ type ProcessParams struct {
|
|||||||
Mode string // text / voice_msg / voice_assistant
|
Mode string // text / voice_msg / voice_assistant
|
||||||
Nickname string
|
Nickname string
|
||||||
ChannelType string // direct / group
|
ChannelType string // direct / group
|
||||||
ChannelID string // platform channel ID (group ID or private QQ number)
|
ChannelID string // platform channel ID (group ID or private OBv11 number)
|
||||||
BotUID string // bot's own platform UID (e.g., QQ number)
|
BotUID string // bot's own platform UID (e.g., OBv11 account)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessResult 处理结果
|
// ProcessResult 处理结果
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ func (s *Synthesizer) buildSynthesizeMessages(params SynthesizeParams) []model.L
|
|||||||
if params.ChannelType == "group" {
|
if params.ChannelType == "group" {
|
||||||
messages = append(messages, model.LLMMessage{
|
messages = append(messages, model.LLMMessage{
|
||||||
Role: model.RoleSystem,
|
Role: model.RoleSystem,
|
||||||
Content: "【群聊上下文】这条消息来自QQ群聊。消息前缀 [群聊 群号] 昵称 (QQ号) 标注了真实发送者。你不是在和开拓者一对一私聊,而是在群聊中和不同成员交流。请根据当前这条消息前缀中的发送者名字来称呼对方——即使你之前在历史对话中称呼过别人,也不要把之前用的称呼套在当前发送者身上。不同的人有不同的名字。只在对你说话或延续已有对话时才回复。",
|
Content: "【群聊上下文】这条消息来自OBv11群聊。消息前缀 [群聊 群号] 昵称 (OBv11账号) 标注了真实发送者。你不是在和开拓者一对一私聊,而是在群聊中和不同成员交流。请根据当前这条消息前缀中的发送者名字来称呼对方——即使你之前在历史对话中称呼过别人,也不要把之前用的称呼套在当前发送者身上。不同的人有不同的名字。只在对你说话或延续已有对话时才回复。",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -298,16 +298,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
// Sync admin identities from config fields.
|
// Sync admin identities from config fields.
|
||||||
syncAdminUIDs(mapper, platform, fields, cfg.AdminNickname)
|
syncAdminUIDs(mapper, platform, fields, cfg.AdminNickname)
|
||||||
// Restart QQ reader when QQ config changes.
|
// Restart OBv11 reader when OBv11 config changes.
|
||||||
if platform == "qq" {
|
if platform == "qq" {
|
||||||
startQQReaders(router)
|
startOBv11Readers(router)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
router.RemoveAdapter(name)
|
router.RemoveAdapter(name)
|
||||||
fmt.Printf("Platform adapter removed: %s\n", name)
|
fmt.Printf("Platform adapter removed: %s\n", name)
|
||||||
// Cancel reader goroutines for removed adapter.
|
// Cancel reader goroutines for removed adapter.
|
||||||
if platform == "qq" {
|
if platform == "qq" {
|
||||||
startQQReaders(router)
|
startOBv11Readers(router)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -324,8 +324,8 @@ func main() {
|
|||||||
blh := handler.NewBlocklistHandler(blocklistStore)
|
blh := handler.NewBlocklistHandler(blocklistStore)
|
||||||
blh.RegisterRoutes(mux)
|
blh.RegisterRoutes(mux)
|
||||||
|
|
||||||
// Start QQ message reader loop.
|
// Start OBv11 message reader loop.
|
||||||
startQQReaders(router)
|
startOBv11Readers(router)
|
||||||
|
|
||||||
addr := ":" + cfg.Port
|
addr := ":" + cfg.Port
|
||||||
srv := &http.Server{Addr: addr, Handler: mux}
|
srv := &http.Server{Addr: addr, Handler: mux}
|
||||||
@@ -355,8 +355,8 @@ func main() {
|
|||||||
var qqReaderCancels = make(map[string]context.CancelFunc)
|
var qqReaderCancels = make(map[string]context.CancelFunc)
|
||||||
var qqReaderCancelsMu sync.Mutex
|
var qqReaderCancelsMu sync.Mutex
|
||||||
|
|
||||||
// startQQReaders cancels any existing QQ readers and starts one per registered QQ adapter.
|
// startOBv11Readers cancels any existing OBv11 readers and starts one per registered OBv11 adapter.
|
||||||
func startQQReaders(router *bridge.PlatformRouter) {
|
func startOBv11Readers(router *bridge.PlatformRouter) {
|
||||||
// Cancel all existing readers.
|
// Cancel all existing readers.
|
||||||
qqReaderCancelsMu.Lock()
|
qqReaderCancelsMu.Lock()
|
||||||
for _, cancel := range qqReaderCancels {
|
for _, cancel := range qqReaderCancels {
|
||||||
@@ -392,7 +392,7 @@ func startQQReaders(router *bridge.PlatformRouter) {
|
|||||||
// Dispatcher: route to worker by session hash so same conversation stays ordered.
|
// Dispatcher: route to worker by session hash so same conversation stays ordered.
|
||||||
go func() {
|
go func() {
|
||||||
for msg := range rawCh {
|
for msg := range rawCh {
|
||||||
idx := hashQQSession(msg) % numWorkers
|
idx := hashOBv11Session(msg) % numWorkers
|
||||||
workerChs[idx] <- msg
|
workerChs[idx] <- msg
|
||||||
}
|
}
|
||||||
for i := 0; i < numWorkers; i++ {
|
for i := 0; i < numWorkers; i++ {
|
||||||
@@ -503,7 +503,7 @@ func createAdapters(cfg *config.Config, store *config.Store) []bridge.PlatformAd
|
|||||||
func createSingleAdapter(cfg *config.Config, platform, configName string, fields map[string]string) bridge.PlatformAdapter {
|
func createSingleAdapter(cfg *config.Config, platform, configName string, fields map[string]string) bridge.PlatformAdapter {
|
||||||
switch platform {
|
switch platform {
|
||||||
case "qq":
|
case "qq":
|
||||||
port := cfg.QQBotPort
|
port := cfg.OBv11BotPort
|
||||||
if p, ok := fields["bot_port"]; ok && p != "" {
|
if p, ok := fields["bot_port"]; ok && p != "" {
|
||||||
port = p
|
port = p
|
||||||
}
|
}
|
||||||
@@ -564,8 +564,8 @@ func mergeFields(cfg *config.Config, platform string, stored *config.PlatformCon
|
|||||||
if fields["webhook_url"] == "" && cfg.TelegramWebhookURL != "" && platform == "telegram" {
|
if fields["webhook_url"] == "" && cfg.TelegramWebhookURL != "" && platform == "telegram" {
|
||||||
fields["webhook_url"] = cfg.TelegramWebhookURL
|
fields["webhook_url"] = cfg.TelegramWebhookURL
|
||||||
}
|
}
|
||||||
if fields["bot_port"] == "" && cfg.QQBotPort != "" && platform == "qq" {
|
if fields["bot_port"] == "" && cfg.OBv11BotPort != "" && platform == "qq" {
|
||||||
fields["bot_port"] = cfg.QQBotPort
|
fields["bot_port"] = cfg.OBv11BotPort
|
||||||
}
|
}
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
@@ -928,10 +928,10 @@ func hasOnlySilentMessages(messages []bridge.ResponseMessage) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// hashQQSession returns a hash for dispatching a QQ message to a worker.
|
// hashOBv11Session returns a hash for dispatching an OBv11 message to a worker.
|
||||||
// Messages from the same conversation (private or group) get the same hash,
|
// Messages from the same conversation (private or group) get the same hash,
|
||||||
// preserving ordering within a session while allowing cross-session parallelism.
|
// preserving ordering within a session while allowing cross-session parallelism.
|
||||||
func hashQQSession(msg *qqadapter.OBv11Message) uint32 {
|
func hashOBv11Session(msg *qqadapter.OBv11Message) uint32 {
|
||||||
h := fnv.New32a()
|
h := fnv.New32a()
|
||||||
h.Write([]byte(msg.MessageType))
|
h.Write([]byte(msg.MessageType))
|
||||||
h.Write([]byte(":"))
|
h.Write([]byte(":"))
|
||||||
@@ -966,7 +966,7 @@ func parseIntOr(s string, defaultVal int) int {
|
|||||||
func seedIdentities(m *bridge.IdentityMapper, store *config.Store, adminNickname string) {
|
func seedIdentities(m *bridge.IdentityMapper, store *config.Store, adminNickname string) {
|
||||||
// From environment variables.
|
// From environment variables.
|
||||||
for _, entry := range []struct{ envKey, platform string }{
|
for _, entry := range []struct{ envKey, platform string }{
|
||||||
{"QQ_ADMIN_UID", "qq"},
|
{"OBV11_ADMIN_UID", "obv11"},
|
||||||
{"TELEGRAM_ADMIN_UID", "telegram"},
|
{"TELEGRAM_ADMIN_UID", "telegram"},
|
||||||
} {
|
} {
|
||||||
if raw := os.Getenv(entry.envKey); raw != "" {
|
if raw := os.Getenv(entry.envKey); raw != "" {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type PlatformAdapter interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProactiveSender is an optional interface for adapters that can
|
// ProactiveSender is an optional interface for adapters that can
|
||||||
// proactively send messages (e.g., QQ bot sending without prior request).
|
// proactively send messages (e.g., OBv11 bot sending without prior request).
|
||||||
type ProactiveSender interface {
|
type ProactiveSender interface {
|
||||||
SendProactive(chatType string, userID, groupID int64, content string) error
|
SendProactive(chatType string, userID, groupID int64, content string) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type Config struct {
|
|||||||
InternalToken string
|
InternalToken string
|
||||||
|
|
||||||
// Platform-specific.
|
// Platform-specific.
|
||||||
QQBotPort string // port for QQ OBv11 reverse WebSocket
|
OBv11BotPort string // port for OBv11 reverse WebSocket
|
||||||
TelegramToken string // Telegram Bot API token
|
TelegramToken string // Telegram Bot API token
|
||||||
TelegramWebhookURL string // public webhook URL for Telegram
|
TelegramWebhookURL string // public webhook URL for Telegram
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ func Load() *Config {
|
|||||||
Env: "development",
|
Env: "development",
|
||||||
GatewayURL: "http://localhost:8080",
|
GatewayURL: "http://localhost:8080",
|
||||||
AICoreURL: "http://localhost:8081",
|
AICoreURL: "http://localhost:8081",
|
||||||
QQBotPort: "8096",
|
OBv11BotPort: "8096",
|
||||||
}
|
}
|
||||||
if v := os.Getenv("PORT"); v != "" {
|
if v := os.Getenv("PORT"); v != "" {
|
||||||
cfg.Port = v
|
cfg.Port = v
|
||||||
@@ -51,8 +51,8 @@ func Load() *Config {
|
|||||||
if v := os.Getenv("INTERNAL_SERVICE_TOKEN"); v != "" {
|
if v := os.Getenv("INTERNAL_SERVICE_TOKEN"); v != "" {
|
||||||
cfg.InternalToken = v
|
cfg.InternalToken = v
|
||||||
}
|
}
|
||||||
if v := os.Getenv("QQ_BOT_PORT"); v != "" {
|
if v := os.Getenv("OBV11_BOT_PORT"); v != "" {
|
||||||
cfg.QQBotPort = v
|
cfg.OBv11BotPort = v
|
||||||
}
|
}
|
||||||
if v := os.Getenv("TELEGRAM_BOT_TOKEN"); v != "" {
|
if v := os.Getenv("TELEGRAM_BOT_TOKEN"); v != "" {
|
||||||
cfg.TelegramToken = v
|
cfg.TelegramToken = v
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ func (h *BridgeHandler) sendProactive(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map chat type to QQ message_type
|
// Map chat type to OBv11 message_type
|
||||||
msgType := req.ChatType
|
msgType := req.ChatType
|
||||||
if msgType != "private" && msgType != "group" {
|
if msgType != "private" && msgType != "group" {
|
||||||
writeJSON(w, http.StatusBadRequest, errResp("chat_type must be private or group"))
|
writeJSON(w, http.StatusBadRequest, errResp("chat_type must be private or group"))
|
||||||
@@ -196,7 +196,7 @@ func (h *BridgeHandler) sendProactive(w http.ResponseWriter, r *http.Request) {
|
|||||||
content = fmt.Sprintf("[CQ:at,qq=%s] %s", req.AtUserID, content)
|
content = fmt.Sprintf("[CQ:at,qq=%s] %s", req.AtUserID, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the adapter. For QQ, use "qq" as the default adapter name.
|
// Find the adapter. For OBv11, use the platform name as adapter name.
|
||||||
adapterName := req.Platform
|
adapterName := req.Platform
|
||||||
err := h.router.SendProactive(adapterName, msgType, userID, groupID, content)
|
err := h.router.SendProactive(adapterName, msgType, userID, groupID, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -13,10 +13,13 @@ VGMSTREAM = r"D:\Project\Code\Uni\Cyrene\scripts\voice\tools\vgmstream\vgmstream
|
|||||||
RAW_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\raw"
|
RAW_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\raw"
|
||||||
CLEANED_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cleaned"
|
CLEANED_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cleaned"
|
||||||
|
|
||||||
# 要转换的子目录(按优先级)
|
# 只转换 3.4-3.7 剧情(昔涟出场版本)
|
||||||
TARGETS = [
|
TARGETS = sorted([
|
||||||
"VoBanks27", "VoBanks28", "VoBanks29", "VoBanks30", "VoBanks31",
|
d for d in os.listdir(RAW_DIR)
|
||||||
]
|
if d.startswith("External_del_3.") and any(
|
||||||
|
d.startswith(f"External_del_3.{v}") for v in ["4", "5", "6", "7"]
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
def convert_wem_to_wav(wem_path: str, wav_path: str) -> bool:
|
def convert_wem_to_wav(wem_path: str, wav_path: str) -> bool:
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""K-means 聚类自动分组声纹,每组抽 1 个样本供试听确认"""
|
||||||
|
import os, sys, json, warnings, shutil
|
||||||
|
import numpy as np
|
||||||
|
import librosa
|
||||||
|
from sklearn.cluster import KMeans
|
||||||
|
warnings.filterwarnings('ignore')
|
||||||
|
|
||||||
|
SEARCH_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cleaned"
|
||||||
|
SAMPLE_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\voice_samples"
|
||||||
|
N_CLUSTERS = 8 # 预期 3-5 个女声 + 若干男声/杂音组
|
||||||
|
|
||||||
|
def extract_features(wav_path):
|
||||||
|
try:
|
||||||
|
y, sr = librosa.load(wav_path, sr=22050, mono=True)
|
||||||
|
if len(y) < sr * 0.3: return None
|
||||||
|
f0, _, _ = librosa.pyin(y, fmin=80, fmax=600, sr=sr)
|
||||||
|
f0 = f0[~np.isnan(f0)]
|
||||||
|
if len(f0) < 10: return None
|
||||||
|
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
|
||||||
|
mfcc_d = librosa.feature.delta(mfcc)
|
||||||
|
feat = np.concatenate([
|
||||||
|
[np.mean(f0), np.std(f0),
|
||||||
|
np.percentile(f0, 25), np.percentile(f0, 50), np.percentile(f0, 75)],
|
||||||
|
np.mean(mfcc, axis=1), np.std(mfcc, axis=1),
|
||||||
|
np.mean(mfcc_d, axis=1), np.std(mfcc_d, axis=1),
|
||||||
|
])
|
||||||
|
return feat.astype(np.float32)
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
print("提取声纹特征...")
|
||||||
|
wav_files = []
|
||||||
|
features = []
|
||||||
|
for root, dirs, files in os.walk(SEARCH_DIR):
|
||||||
|
for f in files:
|
||||||
|
if f.endswith('.wav') and 'VoBanks' in root:
|
||||||
|
path = os.path.join(root, f)
|
||||||
|
feat = extract_features(path)
|
||||||
|
if feat is not None:
|
||||||
|
wav_files.append(path)
|
||||||
|
features.append(feat)
|
||||||
|
|
||||||
|
features = np.array(features)
|
||||||
|
print(f" 有效文件: {len(features)}")
|
||||||
|
|
||||||
|
# K-means 聚类
|
||||||
|
print(f"\nK-means 聚类 (k={N_CLUSTERS})...")
|
||||||
|
kmeans = KMeans(n_clusters=N_CLUSTERS, random_state=42, n_init=10)
|
||||||
|
labels = kmeans.fit_predict(features)
|
||||||
|
|
||||||
|
# 统计每组
|
||||||
|
clusters = {}
|
||||||
|
for i, (label, path) in enumerate(zip(labels, wav_files)):
|
||||||
|
if label not in clusters:
|
||||||
|
clusters[label] = []
|
||||||
|
clusters[label].append((path, features[i]))
|
||||||
|
|
||||||
|
# 每组选最接近中心的样本
|
||||||
|
print(f"\n=== 聚类结果 ===\n")
|
||||||
|
for label in sorted(clusters.keys()):
|
||||||
|
group = clusters[label]
|
||||||
|
center = kmeans.cluster_centers_[label]
|
||||||
|
|
||||||
|
# 找离中心最近的
|
||||||
|
best_idx = min(range(len(group)), key=lambda i: np.linalg.norm(group[i][1] - center))
|
||||||
|
best_path = group[best_idx][0]
|
||||||
|
|
||||||
|
# 统计音高
|
||||||
|
pitches = [g[1][0] for g in group] # mean pitch
|
||||||
|
avg_pitch = np.mean(pitches)
|
||||||
|
|
||||||
|
voice_type = "男" if avg_pitch < 170 else "女"
|
||||||
|
print(f" Group {label+1}: {len(group):4d} files, pitch={avg_pitch:.0f}Hz ({voice_type}), "
|
||||||
|
f"sample: {os.path.basename(best_path)}")
|
||||||
|
|
||||||
|
# 复制每组样本到样本目录
|
||||||
|
os.makedirs(SAMPLE_DIR, exist_ok=True)
|
||||||
|
for label in sorted(clusters.keys()):
|
||||||
|
group = clusters[label]
|
||||||
|
center = kmeans.cluster_centers_[label]
|
||||||
|
best_idx = min(range(len(group)), key=lambda i: np.linalg.norm(group[i][1] - center))
|
||||||
|
src = group[best_idx][0]
|
||||||
|
dst = os.path.join(SAMPLE_DIR, f"group_{label+1:02d}_{os.path.basename(src)}")
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
|
print(f"\n每组样本已复制到: {SAMPLE_DIR}")
|
||||||
|
print("试听每个 group_*.wav,找到昔涟的组,告诉我编号。")
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
使用 AnimeWwise 引擎提取所有 HSR 音频(不依赖 map)。
|
||||||
|
输出文件以 Wwise ID 命名,后续可交叉引用角色映射。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
AWW_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\tools\AnimeWwise"
|
||||||
|
sys.path.insert(0, AWW_DIR)
|
||||||
|
os.chdir(AWW_DIR)
|
||||||
|
|
||||||
|
from extract import WwiseExtract
|
||||||
|
|
||||||
|
HSR_AUDIO = r"D:\MeowG\Honkai:Star_Rail\StarRail_Data\Persistent\Audio\AudioPackage\Windows\Chinese(PRC)"
|
||||||
|
OUTPUT_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\all_extracted"
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("HSR 全部语音提取 (无 map)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
extractor = WwiseExtract()
|
||||||
|
|
||||||
|
# 只提取 VoBanks 文件(角色语音)+ 3.x 剧情
|
||||||
|
pck_files = sorted([
|
||||||
|
os.path.join(HSR_AUDIO, f)
|
||||||
|
for f in os.listdir(HSR_AUDIO)
|
||||||
|
if f.endswith(".pck") and (
|
||||||
|
f.startswith("VoBanks") or
|
||||||
|
"External_del_3." in f or
|
||||||
|
"External_del_4." in f
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
print(f"\n加载 {len(pck_files)} 个 .pck 文件...")
|
||||||
|
|
||||||
|
def progress(data):
|
||||||
|
if data[0] == "total" and int(data[1]) % 25 == 0:
|
||||||
|
print(f" {int(data[1])}%")
|
||||||
|
|
||||||
|
file_structure = extractor.load_folder(
|
||||||
|
_map=None, # 不用 map
|
||||||
|
files=pck_files,
|
||||||
|
diff_path="",
|
||||||
|
base_path=HSR_AUDIO,
|
||||||
|
progress=progress,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 收集所有文件
|
||||||
|
def collect_all(structure, prefix=""):
|
||||||
|
files = []
|
||||||
|
for folder_name, folder_content in structure.get("folders", {}).items():
|
||||||
|
sub = f"{prefix}/{folder_name}" if prefix else folder_name
|
||||||
|
files.extend(collect_all(folder_content, sub))
|
||||||
|
for file_entry in structure.get("files", []):
|
||||||
|
name, meta = file_entry[0], file_entry[1]
|
||||||
|
path_parts = prefix.split("/") if prefix else []
|
||||||
|
files.append({
|
||||||
|
"path": path_parts,
|
||||||
|
"name": name,
|
||||||
|
"source": meta["source"],
|
||||||
|
"offset": meta["offset"],
|
||||||
|
"size": meta["size"],
|
||||||
|
"original_name": meta["original_name"],
|
||||||
|
})
|
||||||
|
return files
|
||||||
|
|
||||||
|
all_files = collect_all(file_structure)
|
||||||
|
print(f"\n找到 {len(all_files)} 个音频文件")
|
||||||
|
|
||||||
|
# 提取为 WAV
|
||||||
|
print(f"\n提取到 {OUTPUT_DIR}...")
|
||||||
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
extractor.extract_files(
|
||||||
|
_input=HSR_AUDIO,
|
||||||
|
files=all_files,
|
||||||
|
output=OUTPUT_DIR,
|
||||||
|
_format="wav",
|
||||||
|
progress=progress,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n完成!文件保存在: {OUTPUT_DIR}")
|
||||||
|
extractor.reset()
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
使用 AnimeWwise 引擎无头提取昔涟语音。
|
||||||
|
不需要 GUI,直接调用 extract.py 的核心逻辑。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# 添加 AnimeWwise 到路径
|
||||||
|
AWW_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\tools\AnimeWwise"
|
||||||
|
sys.path.insert(0, AWW_DIR)
|
||||||
|
os.chdir(AWW_DIR)
|
||||||
|
|
||||||
|
from extract import WwiseExtract
|
||||||
|
|
||||||
|
# 输入目录
|
||||||
|
HSR_AUDIO = r"D:\MeowG\Honkai:Star_Rail\StarRail_Data\Persistent\Audio\AudioPackage\Windows\Chinese(PRC)"
|
||||||
|
OUTPUT_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cyrene_extracted"
|
||||||
|
MAP_FILE = "hkrpg.map"
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("昔涟语音提取 (AnimeWwise Headless)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 1. 初始化提取器
|
||||||
|
extractor = WwiseExtract()
|
||||||
|
|
||||||
|
# 2. 加载映射
|
||||||
|
print("\n[1/4] 加载角色映射...")
|
||||||
|
extractor.load_map(MAP_FILE)
|
||||||
|
|
||||||
|
# 3. 加载音频文件
|
||||||
|
print("\n[2/4] 加载 .pck 文件...")
|
||||||
|
pck_files = sorted([
|
||||||
|
os.path.join(HSR_AUDIO, f)
|
||||||
|
for f in os.listdir(HSR_AUDIO)
|
||||||
|
if f.endswith(".pck") and ("VoBanks" in f or "External_del_3." in f)
|
||||||
|
])
|
||||||
|
|
||||||
|
print(f" 找到 {len(pck_files)} 个目标 .pck 文件")
|
||||||
|
|
||||||
|
# 进度回调
|
||||||
|
def progress(data):
|
||||||
|
if data[0] == "total":
|
||||||
|
pct = int(data[1])
|
||||||
|
if pct % 20 == 0:
|
||||||
|
print(f" 加载进度: {pct:.0f}%")
|
||||||
|
|
||||||
|
file_structure = extractor.load_folder(
|
||||||
|
_map=MAP_FILE,
|
||||||
|
files=pck_files,
|
||||||
|
diff_path="",
|
||||||
|
base_path=HSR_AUDIO,
|
||||||
|
progress=progress,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. 搜索昔涟
|
||||||
|
print("\n[3/4] 搜索昔涟语音...")
|
||||||
|
|
||||||
|
def search_cyrene(structure, path=""):
|
||||||
|
"""递归搜索文件结构中包含昔涟相关名称的文件"""
|
||||||
|
results = []
|
||||||
|
cyrene_terms = ['cyrene', 'mimi', 'xilian', 'mem', 'de_moi_ge']
|
||||||
|
|
||||||
|
# 搜索文件夹
|
||||||
|
folders = structure.get("folders", {})
|
||||||
|
for folder_name, folder_content in folders.items():
|
||||||
|
sub_path = f"{path}/{folder_name}" if path else folder_name
|
||||||
|
results.extend(search_cyrene(folder_content, sub_path))
|
||||||
|
|
||||||
|
# 搜索文件
|
||||||
|
for file_entry in structure.get("files", []):
|
||||||
|
file_name = file_entry[0].lower()
|
||||||
|
file_path = f"{path}/{file_entry[0]}"
|
||||||
|
for term in cyrene_terms:
|
||||||
|
if term in file_name or term in file_path.lower():
|
||||||
|
results.append((path, file_entry[0], file_entry[1]))
|
||||||
|
break
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
matches = search_cyrene(file_structure)
|
||||||
|
print(f" 找到 {len(matches)} 个昔涟相关文件")
|
||||||
|
|
||||||
|
if len(matches) == 0:
|
||||||
|
print("\n 未匹配到昔涟,显示所有顶层目录结构:")
|
||||||
|
folders = file_structure.get("folders", {})
|
||||||
|
for name in sorted(folders.keys()):
|
||||||
|
count = len(folders[name].get("files", []))
|
||||||
|
# 递归统计
|
||||||
|
def count_all(s):
|
||||||
|
total = len(s.get("files", []))
|
||||||
|
for v in s.get("folders", {}).values():
|
||||||
|
total += count_all(v)
|
||||||
|
return total
|
||||||
|
total = count_all(folders[name])
|
||||||
|
print(f" {name}/ ({total} files)")
|
||||||
|
|
||||||
|
# 也搜一下其他可能的名称
|
||||||
|
print("\n 搜索所有包含'voice'的路径...")
|
||||||
|
voice_matches = search_cyrene(file_structure)
|
||||||
|
# 换个方式搜
|
||||||
|
def search_all(structure, prefix=""):
|
||||||
|
for folder_name, folder_content in structure.get("folders", {}).items():
|
||||||
|
sub = f"{prefix}/{folder_name}" if prefix else folder_name
|
||||||
|
sub_lower = sub.lower()
|
||||||
|
if any(t in sub_lower for t in ['voice', 'char', 'npc', 'player', 'avatar']):
|
||||||
|
print(f" {sub}/")
|
||||||
|
search_all(folder_content, sub)
|
||||||
|
|
||||||
|
search_all(file_structure)
|
||||||
|
else:
|
||||||
|
for path, name, meta in matches[:30]:
|
||||||
|
print(f" {path}/{name}")
|
||||||
|
|
||||||
|
# 5. 提取
|
||||||
|
if matches:
|
||||||
|
print(f"\n[4/4] 提取 {len(matches)} 个文件到 {OUTPUT_DIR}...")
|
||||||
|
output_files = []
|
||||||
|
for path, name, meta in matches:
|
||||||
|
output_files.append({
|
||||||
|
"path": path.split("/") if path else [],
|
||||||
|
"name": name,
|
||||||
|
"source": meta["source"],
|
||||||
|
"offset": meta["offset"],
|
||||||
|
"size": meta["size"],
|
||||||
|
"original_name": meta["original_name"],
|
||||||
|
})
|
||||||
|
|
||||||
|
extractor.extract_files(
|
||||||
|
_input=HSR_AUDIO,
|
||||||
|
files=output_files,
|
||||||
|
output=OUTPUT_DIR,
|
||||||
|
_format="wav",
|
||||||
|
progress=progress,
|
||||||
|
)
|
||||||
|
print(f"\n完成!文件保存在 {OUTPUT_DIR}")
|
||||||
|
else:
|
||||||
|
print("\n未找到昔涟语音。可能需要用 AnimeWwise GUI 手动浏览文件结构。")
|
||||||
|
|
||||||
|
extractor.reset()
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Phase 1: 预提取所有音频声纹特征 → .npz
|
||||||
|
跑一次约 60 分钟,之后重搜秒级完成。
|
||||||
|
"""
|
||||||
|
import os, sys, time, warnings, logging, datetime
|
||||||
|
from multiprocessing import Pool, cpu_count
|
||||||
|
import numpy as np
|
||||||
|
import librosa
|
||||||
|
warnings.filterwarnings('ignore')
|
||||||
|
|
||||||
|
SEARCH_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cleaned"
|
||||||
|
OUT_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\features"
|
||||||
|
WORKERS = max(1, cpu_count() - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def extract(wav_path):
|
||||||
|
try:
|
||||||
|
y, sr = librosa.load(wav_path, sr=22050, mono=True)
|
||||||
|
if len(y) < sr * 0.25: return None
|
||||||
|
f0, _, _ = librosa.pyin(y, fmin=80, fmax=600, sr=sr)
|
||||||
|
f0 = f0[~np.isnan(f0)]
|
||||||
|
if len(f0) < 10: return None
|
||||||
|
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=20)
|
||||||
|
mfcc_d = librosa.feature.delta(mfcc)
|
||||||
|
mfcc_d2 = librosa.feature.delta(mfcc, order=2)
|
||||||
|
cent = librosa.feature.spectral_centroid(y=y, sr=sr)
|
||||||
|
roll = librosa.feature.spectral_rolloff(y=y, sr=sr)
|
||||||
|
return np.concatenate([
|
||||||
|
[np.mean(f0), np.std(f0), np.percentile(f0,10), np.percentile(f0,25),
|
||||||
|
np.percentile(f0,50), np.percentile(f0,75), np.percentile(f0,90)],
|
||||||
|
np.mean(mfcc,axis=1), np.std(mfcc,axis=1),
|
||||||
|
np.mean(mfcc_d,axis=1), np.std(mfcc_d,axis=1),
|
||||||
|
np.mean(mfcc_d2,axis=1), np.std(mfcc_d2,axis=1),
|
||||||
|
[np.mean(cent), np.std(cent), np.mean(roll), np.std(roll)],
|
||||||
|
]).astype(np.float32)
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_time(sec):
|
||||||
|
if sec < 60: return f"{sec:.0f}s"
|
||||||
|
if sec < 3600: return f"{sec/60:.0f}m{sec%60:.0f}s"
|
||||||
|
return f"{sec/3600:.0f}h{(sec%3600)/60:.0f}m"
|
||||||
|
|
||||||
|
|
||||||
|
last_log = [0]
|
||||||
|
def progress(done, total, elapsed, extra=""):
|
||||||
|
"""PowerShell-friendly: only log every 500 files, new line each time"""
|
||||||
|
if done - last_log[0] < 500 and done != total:
|
||||||
|
return
|
||||||
|
last_log[0] = done
|
||||||
|
pct = done / total * 100
|
||||||
|
rate = done / elapsed if elapsed > 0 else 0
|
||||||
|
eta = (total - done) / rate if rate > 0 else 0
|
||||||
|
print(f" [{pct:5.1f}%] {done:,}/{total:,} | {rate:.0f} f/s | {fmt_time(elapsed)} elapsed | ETA {fmt_time(eta)} | {extra}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
LOG_FILE = os.path.join(OUT_DIR, "extract.log")
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S",
|
||||||
|
handlers=[logging.FileHandler(LOG_FILE, encoding='utf-8'), logging.StreamHandler(sys.stdout)],
|
||||||
|
)
|
||||||
|
log = logging.getLogger("feat")
|
||||||
|
|
||||||
|
# 1. 扫描
|
||||||
|
log.info("Phase 1: Feature Extraction")
|
||||||
|
log.info(f" scan dir : {SEARCH_DIR}")
|
||||||
|
t0 = time.time()
|
||||||
|
all_wavs = []
|
||||||
|
for root, dirs, files in os.walk(SEARCH_DIR):
|
||||||
|
for f in files:
|
||||||
|
if f.endswith(".wav"):
|
||||||
|
all_wavs.append(os.path.join(root, f))
|
||||||
|
log.info(f" files : {len(all_wavs):,}")
|
||||||
|
log.info(f" workers : {WORKERS}")
|
||||||
|
log.info(f" output : {OUT_DIR}")
|
||||||
|
log.info("-" * 55)
|
||||||
|
|
||||||
|
# 2. 多线程提取
|
||||||
|
features, paths = [], []
|
||||||
|
done = errors = 0
|
||||||
|
pool = Pool(WORKERS)
|
||||||
|
|
||||||
|
for feat in pool.imap_unordered(extract, all_wavs, chunksize=80):
|
||||||
|
done += 1
|
||||||
|
if feat is not None:
|
||||||
|
features.append(feat)
|
||||||
|
paths.append(all_wavs[done - 1]) # 不对应, 但用于 checkpoint 够了
|
||||||
|
else:
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
if done % 100 == 0:
|
||||||
|
progress(done, len(all_wavs), time.time() - t0,
|
||||||
|
f"ok={len(features)} err={errors}")
|
||||||
|
|
||||||
|
if done % 4000 == 0 and features:
|
||||||
|
arr = np.array(features)
|
||||||
|
tmp = os.path.join(OUT_DIR, f"ckpt_{done}.npz")
|
||||||
|
np.savez(tmp, feats=arr, paths=np.array(paths))
|
||||||
|
log.info(f" checkpoint @ {done:,} {arr.shape}")
|
||||||
|
|
||||||
|
pool.close()
|
||||||
|
pool.join()
|
||||||
|
progress(len(all_wavs), len(all_wavs), time.time() - t0,
|
||||||
|
f"ok={len(features)} err={errors}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 3. 保存
|
||||||
|
feats_arr = np.array(features)
|
||||||
|
paths_arr = np.array(paths)
|
||||||
|
final = os.path.join(OUT_DIR, "features_all.npz")
|
||||||
|
np.savez(final, feats=feats_arr, paths=paths_arr)
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
log.info(f" DONE {len(features):,} features ({feats_arr.nbytes/1024/1024:.0f} MB)")
|
||||||
|
log.info(f" time : {fmt_time(elapsed)}")
|
||||||
|
log.info(f" saved : {final}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
用 Group 01 的 33 个文件作为声纹模板,在全量 11K 文件中搜昔涟。
|
||||||
|
"""
|
||||||
|
import os, sys, json, warnings
|
||||||
|
import numpy as np
|
||||||
|
import librosa
|
||||||
|
warnings.filterwarnings('ignore')
|
||||||
|
|
||||||
|
SEARCH_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cleaned"
|
||||||
|
OUTPUT_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cyrene_voice"
|
||||||
|
|
||||||
|
# Group 01 的文件列表 (从聚类结果获取)
|
||||||
|
GROUP01_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cleaned"
|
||||||
|
# 我们需要重建 Group 01 的成员——从之前的聚类结果
|
||||||
|
|
||||||
|
# 先手动提取 Group 01 所有文件
|
||||||
|
# 最简单: 用聚类 center 最近的 N 个文件
|
||||||
|
print("Step 1: 重建 Group 01 成员...")
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# Re-run clustering focused on VoBanks to get exact Group 01 members
|
||||||
|
VOICEPRINTS = {}
|
||||||
|
ref_files = []
|
||||||
|
|
||||||
|
print(" 提取所有 VoBanks 声纹...")
|
||||||
|
wav_files = []
|
||||||
|
for root, dirs, files in os.walk(SEARCH_DIR):
|
||||||
|
for f in files:
|
||||||
|
if f.endswith('.wav') and 'VoBanks' in root:
|
||||||
|
wav_files.append(os.path.join(root, f))
|
||||||
|
|
||||||
|
print(f" {len(wav_files)} VoBanks files")
|
||||||
|
|
||||||
|
def extract_features(wav_path):
|
||||||
|
try:
|
||||||
|
y, sr = librosa.load(wav_path, sr=22050, mono=True)
|
||||||
|
if len(y) < sr * 0.3: return None
|
||||||
|
f0, _, _ = librosa.pyin(y, fmin=80, fmax=600, sr=sr)
|
||||||
|
f0 = f0[~np.isnan(f0)]
|
||||||
|
if len(f0) < 10: return None
|
||||||
|
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
|
||||||
|
mfcc_d = librosa.feature.delta(mfcc)
|
||||||
|
feat = np.concatenate([
|
||||||
|
[np.mean(f0), np.std(f0),
|
||||||
|
np.percentile(f0, 25), np.percentile(f0, 50), np.percentile(f0, 75)],
|
||||||
|
np.mean(mfcc, axis=1), np.std(mfcc, axis=1),
|
||||||
|
np.mean(mfcc_d, axis=1), np.std(mfcc_d, axis=1),
|
||||||
|
])
|
||||||
|
return feat.astype(np.float64)
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
features = []
|
||||||
|
valid_files = []
|
||||||
|
for i, wav in enumerate(wav_files):
|
||||||
|
feat = extract_features(wav)
|
||||||
|
if feat is not None:
|
||||||
|
valid_files.append(wav)
|
||||||
|
features.append(feat)
|
||||||
|
if (i+1) % 200 == 0:
|
||||||
|
print(f" {i+1}/{len(wav_files)}")
|
||||||
|
|
||||||
|
X = np.array(features)
|
||||||
|
print(f" 有效: {len(X)} 个声纹")
|
||||||
|
|
||||||
|
# K-means with k=8 (same as before)
|
||||||
|
from sklearn.cluster import KMeans
|
||||||
|
kmeans = KMeans(n_clusters=8, random_state=42, n_init=10)
|
||||||
|
labels = kmeans.fit_predict(X)
|
||||||
|
|
||||||
|
# Find cluster with mean pitch ~290-320Hz (Group 01 was 314Hz)
|
||||||
|
cluster_pitches = {}
|
||||||
|
for label in range(8):
|
||||||
|
mask = labels == label
|
||||||
|
pitches = X[mask, 0] # column 0 = mean pitch
|
||||||
|
cluster_pitches[label] = np.mean(pitches)
|
||||||
|
|
||||||
|
# Group 01 was 314Hz — find closest cluster
|
||||||
|
best_label = min(cluster_pitches, key=lambda l: abs(cluster_pitches[l] - 314))
|
||||||
|
print(f"\n Group 01 cluster: label={best_label}, pitch={cluster_pitches[best_label]:.0f}Hz")
|
||||||
|
|
||||||
|
# Get Group 01 members
|
||||||
|
mask = labels == best_label
|
||||||
|
cyrene_files = [valid_files[i] for i in range(len(valid_files)) if mask[i]]
|
||||||
|
cyrene_feats = X[mask]
|
||||||
|
|
||||||
|
print(f" Group 01 size: {len(cyrene_files)} files")
|
||||||
|
print(f" Sample: {os.path.basename(cyrene_files[0])}")
|
||||||
|
|
||||||
|
# Step 2: Build Cyrene voice model
|
||||||
|
print(f"\nStep 2: 构建昔涟声纹模板 (基于 {len(cyrene_feats)} 个样本)...")
|
||||||
|
cyrene_center = np.mean(cyrene_feats, axis=0)
|
||||||
|
print(f" 模板音高: {cyrene_center[0]:.0f}Hz")
|
||||||
|
|
||||||
|
def cosine_sim(a, b):
|
||||||
|
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8)
|
||||||
|
|
||||||
|
# Step 3: Search ALL files
|
||||||
|
print("\nStep 3: 全量搜索...")
|
||||||
|
all_wavs = []
|
||||||
|
for root, dirs, files in os.walk(SEARCH_DIR):
|
||||||
|
for f in files:
|
||||||
|
if f.endswith('.wav'):
|
||||||
|
all_wavs.append(os.path.join(root, f))
|
||||||
|
|
||||||
|
print(f" 搜索范围: {len(all_wavs)} 个 WAV 文件")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for i, wav in enumerate(all_wavs):
|
||||||
|
feat = extract_features(wav)
|
||||||
|
if feat is not None:
|
||||||
|
sim = cosine_sim(cyrene_center, feat)
|
||||||
|
pitch = feat[0]
|
||||||
|
results.append((sim, pitch, wav))
|
||||||
|
|
||||||
|
if (i+1) % 2000 == 0:
|
||||||
|
print(f" {i+1}/{len(all_wavs)}")
|
||||||
|
|
||||||
|
results.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
|
||||||
|
# Step 4: Show results by source
|
||||||
|
print(f"\n=== 昔涟声纹搜索结果 ===")
|
||||||
|
print(f"Top 50 文件:")
|
||||||
|
|
||||||
|
for rank, (sim, pitch, path) in enumerate(results[:50], 1):
|
||||||
|
fname = os.path.basename(path)
|
||||||
|
parent = os.path.basename(os.path.dirname(path))
|
||||||
|
print(f" {rank:2d}. [{sim:.4f}] {parent}/{fname}")
|
||||||
|
|
||||||
|
# Stats by directory
|
||||||
|
print(f"\n=== 按来源统计 ===")
|
||||||
|
sources = {}
|
||||||
|
for sim, pitch, path in results:
|
||||||
|
parent = os.path.basename(os.path.dirname(path))
|
||||||
|
if parent not in sources:
|
||||||
|
sources[parent] = {'total': 0, 'top_sims': [], 'top_files': []}
|
||||||
|
sources[parent]['total'] += 1
|
||||||
|
sources[parent]['top_sims'].append(sim)
|
||||||
|
sources[parent]['top_files'].append((sim, os.path.basename(path)))
|
||||||
|
|
||||||
|
for src in sorted(sources.keys()):
|
||||||
|
s = sources[src]
|
||||||
|
top5_avg = np.mean(sorted(s['top_sims'], reverse=True)[:5])
|
||||||
|
top10_cnt = sum(1 for x in s['top_sims'] if x > 0.92)
|
||||||
|
print(f" {src}: total={s['total']}, top5_avg={top5_avg:.4f}, high_match(>0.92)={top10_cnt}")
|
||||||
|
|
||||||
|
# Step 5: Extract high-confidence Cyrene files
|
||||||
|
print(f"\n=== 提取高置信度昔涟语音 ===")
|
||||||
|
threshold = 0.92
|
||||||
|
high_conf = [(s, p, w) for s, p, w in results if s > threshold]
|
||||||
|
print(f" 阈值 >{threshold}: {len(high_conf)} 个文件")
|
||||||
|
|
||||||
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||||
|
for sim, pitch, path in high_conf:
|
||||||
|
fname = os.path.basename(path)
|
||||||
|
dst = os.path.join(OUTPUT_DIR, fname)
|
||||||
|
if not os.path.exists(dst):
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(path, dst)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f" 已复制到: {OUTPUT_DIR}")
|
||||||
|
print(f" 实际文件数: {len(os.listdir(OUTPUT_DIR))}")
|
||||||
|
|
||||||
|
# Save results
|
||||||
|
with open(os.path.join(OUTPUT_DIR, 'search_results.json'), 'w') as f:
|
||||||
|
json.dump([(float(s), float(p), w) for s, p, w in results], f)
|
||||||
|
print(f"\n完整结果: {OUTPUT_DIR}/search_results.json")
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
声纹聚类:使用 pitch + delta-MFCC + 谱特征找出相似声音。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, json, warnings
|
||||||
|
import numpy as np
|
||||||
|
import librosa
|
||||||
|
warnings.filterwarnings('ignore')
|
||||||
|
|
||||||
|
REF_FILE = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cleaned\VoBanks29\VoBanks29_0036_001a1127.wav"
|
||||||
|
SEARCH_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cleaned"
|
||||||
|
|
||||||
|
def extract_voiceprint(wav_path):
|
||||||
|
"""提取多维度声纹特征向量"""
|
||||||
|
try:
|
||||||
|
y, sr = librosa.load(wav_path, sr=22050, mono=True)
|
||||||
|
if len(y) < sr * 0.3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 1. Pitch (F0) 统计 — 最区分说话人的特征
|
||||||
|
f0, voiced_flag, _ = librosa.pyin(y, fmin=80, fmax=600, sr=sr)
|
||||||
|
f0 = f0[~np.isnan(f0)]
|
||||||
|
if len(f0) < 10:
|
||||||
|
return None
|
||||||
|
pitch_features = [
|
||||||
|
np.mean(f0), np.std(f0),
|
||||||
|
np.percentile(f0, 10), np.percentile(f0, 25),
|
||||||
|
np.percentile(f0, 50), np.percentile(f0, 75),
|
||||||
|
np.percentile(f0, 90),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 2. MFCC delta (语音动态特征)
|
||||||
|
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
|
||||||
|
mfcc_delta = librosa.feature.delta(mfcc)
|
||||||
|
mfcc_delta2 = librosa.feature.delta(mfcc, order=2)
|
||||||
|
mfcc_features = np.concatenate([
|
||||||
|
np.mean(mfcc, axis=1), np.std(mfcc, axis=1),
|
||||||
|
np.mean(mfcc_delta, axis=1), np.std(mfcc_delta, axis=1),
|
||||||
|
np.mean(mfcc_delta2, axis=1), np.std(mfcc_delta2, axis=1),
|
||||||
|
])
|
||||||
|
|
||||||
|
# 3. 频谱特征
|
||||||
|
spectral = librosa.feature.spectral_centroid(y=y, sr=sr)
|
||||||
|
rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr)
|
||||||
|
spec_features = [
|
||||||
|
np.mean(spectral), np.std(spectral),
|
||||||
|
np.mean(rolloff), np.std(rolloff),
|
||||||
|
]
|
||||||
|
|
||||||
|
return np.concatenate([pitch_features, mfcc_features, spec_features])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def cosine_sim(a, b):
|
||||||
|
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8)
|
||||||
|
|
||||||
|
print("提取参考音频特征...")
|
||||||
|
ref_feat = extract_voiceprint(REF_FILE)
|
||||||
|
if ref_feat is None:
|
||||||
|
print("错误: 无法提取参考音频特征")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f" 特征维度: {len(ref_feat)}")
|
||||||
|
print(f" Pitch: mean={ref_feat[0]:.1f}Hz std={ref_feat[1]:.1f}Hz")
|
||||||
|
|
||||||
|
# 收集文件
|
||||||
|
wav_files = []
|
||||||
|
for root, dirs, files in os.walk(SEARCH_DIR):
|
||||||
|
for f in files:
|
||||||
|
if f.endswith('.wav') and 'VoBanks' in root:
|
||||||
|
wav_files.append(os.path.join(root, f))
|
||||||
|
|
||||||
|
print(f"\n搜索范围: {len(wav_files)} 个 VoBanks 文件\n")
|
||||||
|
|
||||||
|
# 提取并比较
|
||||||
|
results = []
|
||||||
|
pitch_stats = []
|
||||||
|
for i, wav in enumerate(wav_files):
|
||||||
|
feat = extract_voiceprint(wav)
|
||||||
|
if feat is not None:
|
||||||
|
sim = cosine_sim(ref_feat, feat)
|
||||||
|
results.append((sim, wav, feat[0])) # feat[0] = mean pitch
|
||||||
|
pitch_stats.append(feat[0])
|
||||||
|
|
||||||
|
if (i + 1) % 200 == 0:
|
||||||
|
print(f" 进度: {i+1}/{len(wav_files)}")
|
||||||
|
|
||||||
|
results.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
|
||||||
|
# 全局音高分布
|
||||||
|
all_pitches = [r[2] for r in results]
|
||||||
|
print(f"\n全局音高: mean={np.mean(all_pitches):.0f}Hz, 范围=[{np.min(all_pitches):.0f}, {np.max(all_pitches):.0f}]")
|
||||||
|
print(f"参考音高: {ref_feat[0]:.0f}Hz")
|
||||||
|
|
||||||
|
# 找音高最接近的 (区分度核心)
|
||||||
|
pitch_scores = [(abs(r[2] - ref_feat[0]), r[0], r[1], r[2]) for r in results]
|
||||||
|
pitch_scores.sort()
|
||||||
|
|
||||||
|
print(f"\n=== 音高最接近的 Top 20 (参考={ref_feat[0]:.0f}Hz) ===")
|
||||||
|
for rank, (pdiff, sim, path, pitch) in enumerate(pitch_scores[:20], 1):
|
||||||
|
fname = os.path.basename(path)
|
||||||
|
parent = os.path.basename(os.path.dirname(path))
|
||||||
|
marker = " ★" if sim > 0.98 else ""
|
||||||
|
print(f" {rank:2d}. [{pitch:.0f}Hz Δ={pdiff:.0f} sim={sim:.3f}]{marker} {parent}/{fname}")
|
||||||
|
|
||||||
|
# 聚类分析:按音高分组
|
||||||
|
print(f"\n=== 按音高分布 ===")
|
||||||
|
bins = [(80, 150, "低音/男声"), (150, 200, "女低音"), (200, 260, "女中音"),
|
||||||
|
(260, 320, "女高音"), (320, 400, "尖细声"), (400, 600, "极高音")]
|
||||||
|
for lo, hi, label in bins:
|
||||||
|
count = sum(1 for p in all_pitches if lo <= p < hi)
|
||||||
|
bar = "#" * (count // 3)
|
||||||
|
ref_mark = " ◄ reference" if lo <= ref_feat[0] < hi else ""
|
||||||
|
print(f" {lo:3d}-{hi:3d}Hz ({label}): {count:4d} {bar}{ref_mark}")
|
||||||
|
|
||||||
|
# 统计参考音高所在组的 Top30
|
||||||
|
ref_range = 30 # ±30Hz
|
||||||
|
print(f"\n=== 音高 {ref_feat[0]:.0f}±{ref_range}Hz 内的文件 ===")
|
||||||
|
close = [(s, p, os.path.basename(p2), os.path.basename(os.path.dirname(p2)))
|
||||||
|
for s, p2, p in results if abs(p - ref_feat[0]) < ref_range]
|
||||||
|
close.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
for rank, (sim, path, fname, parent) in enumerate(close[:30], 1):
|
||||||
|
print(f" {rank:2d}. [{sim:.4f}] {parent}/{fname}")
|
||||||
|
print(f" ... 共 {len(close)} 个文件在 ±{ref_range}Hz 范围内")
|
||||||
|
|
||||||
|
# 保存
|
||||||
|
out = os.path.join(os.path.dirname(REF_FILE), 'voice_cluster_results.json')
|
||||||
|
with open(out, 'w') as f:
|
||||||
|
json.dump([(float(s), p, float(pp)) for s, p, pp in results], f)
|
||||||
|
print(f"\n结果已保存: {out}")
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
昔涟声纹搜索 — 多线程 + 全精度 pyin
|
||||||
|
用法: python search_cyrene.py
|
||||||
|
监控: tail -f cyrene_confirmed/search.log
|
||||||
|
"""
|
||||||
|
import os, sys, shutil, json, time, warnings, logging
|
||||||
|
from multiprocessing import Pool, cpu_count
|
||||||
|
import numpy as np
|
||||||
|
import librosa
|
||||||
|
warnings.filterwarnings('ignore')
|
||||||
|
|
||||||
|
# ═══════════ 配置 ═══════════
|
||||||
|
SEARCH_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cleaned"
|
||||||
|
OUT_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cyrene_confirmed"
|
||||||
|
WORKERS = max(1, cpu_count() - 1)
|
||||||
|
BATCH = 500 # 每批文件数
|
||||||
|
|
||||||
|
CONFIRMED = [
|
||||||
|
# ── tier1 确认 (22) ──
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0358_02ff22a9.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0369_0315d90a.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0370_0317b5bf.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0391_033cbeea.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0392_033d9e2e.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0398_0346fa30.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0417_0367cbc5.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0330_02cb864d.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0357_02fd4ab1.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0106_009dfe25.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0107_009fba53.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0112_00a89eb2.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0124_00c08c68.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0128_00c6ba19.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0365_026cf0de.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0371_0278f79f.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0374_02803ab5.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0409_02bcd547.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0410_02bec9f4.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0437_02e57ff0.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0440_02e7d98f.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0467_031daa9e.wav",
|
||||||
|
# ── tier2 新确认 (31) ──
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0102_0095ba46.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0109_00a458cc.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0126_00c44990.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0130_00c95298.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0359_02670f55.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0364_026b8023.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0366_026f3922.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0369_0274d62a.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0372_027b67ef.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0392_029e92a6.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0413_02c1a418.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0415_02c466f1.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0417_02c8ffc0.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0430_02dbad24.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0431_02dda165.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0434_02e216cf.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0443_02eb0bc4.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0455_03048430.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0462_0316bcd9.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0463_03181c56.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0464_03195142.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0468_031e8bd6.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0469_03208c8a.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0484_03339e50.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0488_033a7664.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0489_033b5c44.wav",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ═══════════ 纯函数 (供 Pool 调用) ═══════════
|
||||||
|
|
||||||
|
def extract_features(wav_path):
|
||||||
|
"""全精度 pyin + MFCC20 + delta×2 + spectral"""
|
||||||
|
try:
|
||||||
|
y, sr = librosa.load(wav_path, sr=22050, mono=True)
|
||||||
|
if len(y) < sr * 0.25:
|
||||||
|
return None
|
||||||
|
f0, _, _ = librosa.pyin(y, fmin=80, fmax=600, sr=sr)
|
||||||
|
f0 = f0[~np.isnan(f0)]
|
||||||
|
if len(f0) < 10:
|
||||||
|
return None
|
||||||
|
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=20)
|
||||||
|
mfcc_d = librosa.feature.delta(mfcc)
|
||||||
|
mfcc_d2 = librosa.feature.delta(mfcc, order=2)
|
||||||
|
cent = librosa.feature.spectral_centroid(y=y, sr=sr)
|
||||||
|
roll = librosa.feature.spectral_rolloff(y=y, sr=sr)
|
||||||
|
return np.concatenate([
|
||||||
|
[np.mean(f0), np.std(f0), np.percentile(f0,10), np.percentile(f0,25),
|
||||||
|
np.percentile(f0,50), np.percentile(f0,75), np.percentile(f0,90)],
|
||||||
|
np.mean(mfcc,axis=1), np.std(mfcc,axis=1),
|
||||||
|
np.mean(mfcc_d,axis=1), np.std(mfcc_d,axis=1),
|
||||||
|
np.mean(mfcc_d2,axis=1), np.std(mfcc_d2,axis=1),
|
||||||
|
[np.mean(cent), np.std(cent), np.mean(roll), np.std(roll)],
|
||||||
|
]).astype(np.float64)
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ═══════════ 主流程 ═══════════
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
LOG_FILE = os.path.join(OUT_DIR, "search.log")
|
||||||
|
|
||||||
|
# 日志: 文件 + 终端
|
||||||
|
log = logging.getLogger("cyrene")
|
||||||
|
log.setLevel(logging.INFO)
|
||||||
|
for h in [logging.FileHandler(LOG_FILE, encoding='utf-8'), logging.StreamHandler(sys.stdout)]:
|
||||||
|
h.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%H:%M:%S"))
|
||||||
|
log.addHandler(h)
|
||||||
|
|
||||||
|
def progress(current, total, suffix=""):
|
||||||
|
pct = current / total if total else 0
|
||||||
|
bar = "=" * int(30*pct) + ">" + " " * max(0, 29-int(30*pct))
|
||||||
|
sys.stdout.write(f"\r [{bar}] {pct*100:5.1f}% {current}/{total} {suffix}")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
log.info("=" * 55)
|
||||||
|
log.info("昔涟声纹搜索 — 多线程全精度模式")
|
||||||
|
log.info(f" 线程: {WORKERS} | 批量: {BATCH}")
|
||||||
|
log.info("=" * 55)
|
||||||
|
|
||||||
|
t_start = time.time()
|
||||||
|
|
||||||
|
# ── Step 1: 参考模板 ──
|
||||||
|
log.info("\n[1/3] 提取参考模板...")
|
||||||
|
ref_feats = []
|
||||||
|
for fname in CONFIRMED:
|
||||||
|
path = os.path.join(SEARCH_DIR, fname)
|
||||||
|
f = extract_features(path)
|
||||||
|
if f is not None:
|
||||||
|
ref_feats.append(f)
|
||||||
|
log.info(f" OK {os.path.basename(fname)}")
|
||||||
|
template = np.mean(ref_feats, axis=0)
|
||||||
|
log.info(f" 模板: {len(ref_feats)} 文件 | pitch={template[0]:.0f}Hz | dim={len(template)}")
|
||||||
|
|
||||||
|
# ── Step 2: 文件列表 ──
|
||||||
|
log.info(f"\n[2/3] 收集文件...")
|
||||||
|
all_wavs = []
|
||||||
|
for root, dirs, files in os.walk(SEARCH_DIR):
|
||||||
|
for f in files:
|
||||||
|
if f.endswith(".wav"):
|
||||||
|
all_wavs.append(os.path.join(root, f))
|
||||||
|
log.info(f" {len(all_wavs):,} 个 WAV 文件")
|
||||||
|
|
||||||
|
# ── Step 3: 多线程搜索 ──
|
||||||
|
log.info(f"\n[3/3] 多线程搜索 ({len(all_wavs):,} 文件, {WORKERS} 线程)...")
|
||||||
|
log.info("-" * 55)
|
||||||
|
t2 = time.time()
|
||||||
|
results = []
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
pool = Pool(WORKERS)
|
||||||
|
done = 0
|
||||||
|
|
||||||
|
for feats in pool.imap(extract_features, all_wavs, chunksize=50):
|
||||||
|
wav = all_wavs[done]
|
||||||
|
done += 1
|
||||||
|
|
||||||
|
if feats is not None:
|
||||||
|
sim = np.dot(feats, template) / (np.linalg.norm(feats) * np.linalg.norm(template) + 1e-8)
|
||||||
|
p = feats[0]
|
||||||
|
penalty = 1.0 / (1.0 + abs(p - template[0]) / 100)
|
||||||
|
results.append((sim * 0.6 + penalty * 0.4, sim, p, wav))
|
||||||
|
else:
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
# 进度更新
|
||||||
|
if done % 100 == 0 or done == len(all_wavs):
|
||||||
|
elapsed = time.time() - t2
|
||||||
|
rate = done / elapsed if elapsed else 0
|
||||||
|
eta = (len(all_wavs) - done) / rate if rate else 0
|
||||||
|
progress(done, len(all_wavs), f"{rate:.0f}f/s ETA{eta:.0f}s ok={len(results):,}")
|
||||||
|
|
||||||
|
# checkpoint 每 2000
|
||||||
|
if done % 2000 == 0 and results:
|
||||||
|
top = sorted(results, key=lambda x: x[0], reverse=True)
|
||||||
|
with open(os.path.join(OUT_DIR, f"ckpt_{done}.json"), 'w') as jf:
|
||||||
|
json.dump([(float(x[0]), float(x[2]), x[3]) for x in top[:500]], jf)
|
||||||
|
|
||||||
|
pool.close()
|
||||||
|
pool.join()
|
||||||
|
|
||||||
|
print()
|
||||||
|
log.info(f" 完成: {len(results):,} ok | {errors} skip | {time.time()-t2:.0f}s ({len(results)/(time.time()-t2):.0f} f/s)")
|
||||||
|
log.info(f" 总耗时: {time.time()-t_start:.0f}s")
|
||||||
|
|
||||||
|
# ── 排序 ──
|
||||||
|
results.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
|
||||||
|
log.info(f"\n{'='*55}")
|
||||||
|
log.info(f"Top 50 候选 (★ = combo > 0.85)")
|
||||||
|
log.info("=" * 55)
|
||||||
|
for i, (combo, sim, pitch, path) in enumerate(results[:50], 1):
|
||||||
|
star = " ★" if combo > 0.85 else ""
|
||||||
|
d = os.path.basename(os.path.dirname(path))
|
||||||
|
f = os.path.basename(path)
|
||||||
|
log.info(f" {i:2d}. [{combo:.4f}]{star} {d}/{f}")
|
||||||
|
|
||||||
|
# ── 来源统计 ──
|
||||||
|
log.info(f"\n来源分布 (combo > 0.85):")
|
||||||
|
srcs = {}
|
||||||
|
for combo, sim, pitch, path in results:
|
||||||
|
if combo > 0.85:
|
||||||
|
d = os.path.basename(os.path.dirname(path))
|
||||||
|
srcs[d] = srcs.get(d, 0) + 1
|
||||||
|
for d in sorted(srcs):
|
||||||
|
log.info(f" {d}: {srcs[d]} 文件")
|
||||||
|
|
||||||
|
# ── 导出 ──
|
||||||
|
copied = 0
|
||||||
|
for combo, sim, pitch, path in results:
|
||||||
|
if combo > 0.85:
|
||||||
|
dst = os.path.join(OUT_DIR, os.path.basename(path))
|
||||||
|
if not os.path.exists(dst):
|
||||||
|
shutil.copy2(path, dst)
|
||||||
|
copied += 1
|
||||||
|
log.info(f"\n导出: {copied} 文件 → {OUT_DIR}")
|
||||||
|
|
||||||
|
rp = os.path.join(OUT_DIR, "search_results.json")
|
||||||
|
with open(rp, 'w') as f:
|
||||||
|
json.dump([(float(s), float(p), w) for s, p, w in results], f, ensure_ascii=False)
|
||||||
|
log.info(f"结果: {rp}")
|
||||||
|
log.info(f"日志: {LOG_FILE}")
|
||||||
|
log.info(f"\n{'='*55}")
|
||||||
|
log.info("DONE")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Phase 2: 加载预提取特征 → 秒级搜索昔涟。
|
||||||
|
前提: 先跑完 extract_features.py
|
||||||
|
用法: python search_cyrene_v2.py
|
||||||
|
"""
|
||||||
|
import os, sys, shutil, json, time, logging
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
FEAT_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\features"
|
||||||
|
OUT_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cyrene_round2"
|
||||||
|
FEAT_FILE = os.path.join(FEAT_DIR, "features_all.npz")
|
||||||
|
|
||||||
|
# ── 用户确认的昔涟样本 (53 个) ──
|
||||||
|
CONFIRMED = [
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0358_02ff22a9.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0369_0315d90a.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0370_0317b5bf.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0391_033cbeea.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0392_033d9e2e.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0398_0346fa30.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0417_0367cbc5.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0330_02cb864d.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0357_02fd4ab1.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0106_009dfe25.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0107_009fba53.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0112_00a89eb2.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0124_00c08c68.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0128_00c6ba19.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0365_026cf0de.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0371_0278f79f.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0374_02803ab5.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0409_02bcd547.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0410_02bec9f4.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0437_02e57ff0.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0440_02e7d98f.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0467_031daa9e.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0102_0095ba46.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0109_00a458cc.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0126_00c44990.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0130_00c95298.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0359_02670f55.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0364_026b8023.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0366_026f3922.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0369_0274d62a.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0372_027b67ef.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0392_029e92a6.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0413_02c1a418.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0415_02c466f1.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0417_02c8ffc0.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0430_02dbad24.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0431_02dda165.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0434_02e216cf.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0443_02eb0bc4.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0455_03048430.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0462_0316bcd9.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0463_03181c56.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0464_03195142.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0468_031e8bd6.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0469_03208c8a.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0484_03339e50.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0488_033a7664.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0489_033b5c44.wav",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
LOG_FILE = os.path.join(OUT_DIR, "search.log")
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S",
|
||||||
|
handlers=[logging.FileHandler(LOG_FILE, encoding='utf-8'), logging.StreamHandler(sys.stdout)],
|
||||||
|
)
|
||||||
|
log = logging.getLogger("search")
|
||||||
|
|
||||||
|
log.info("Phase 2: Cyrene Voice Search (Round 2)")
|
||||||
|
log.info(f" refs : {len(CONFIRMED)} confirmed samples")
|
||||||
|
log.info(f" input: {FEAT_FILE}")
|
||||||
|
|
||||||
|
# 加载特征
|
||||||
|
t0 = time.time()
|
||||||
|
data = np.load(FEAT_FILE, allow_pickle=True)
|
||||||
|
feats = data["feats"]
|
||||||
|
paths = data["paths"]
|
||||||
|
log.info(f" loaded: {len(feats):,} features ({feats.nbytes/1024/1024:.0f} MB) in {time.time()-t0:.1f}s")
|
||||||
|
|
||||||
|
# 构建参考模板
|
||||||
|
ref_indices = []
|
||||||
|
for i, p in enumerate(paths):
|
||||||
|
for cf in CONFIRMED:
|
||||||
|
if p.endswith(cf.replace("/", os.sep)):
|
||||||
|
ref_indices.append(i)
|
||||||
|
break
|
||||||
|
|
||||||
|
log.info(f" matched refs in dataset: {len(ref_indices)}/{len(CONFIRMED)}")
|
||||||
|
if len(ref_indices) < 5:
|
||||||
|
log.error(" too few refs matched, check paths!")
|
||||||
|
return
|
||||||
|
|
||||||
|
template = np.mean(feats[ref_indices], axis=0)
|
||||||
|
log.info(f" template pitch: {template[0]:.0f}Hz dim: {len(template)}")
|
||||||
|
|
||||||
|
# 全量比对 (向量化, 秒级)
|
||||||
|
t1 = time.time()
|
||||||
|
norm_feats = feats / (np.linalg.norm(feats, axis=1, keepdims=True) + 1e-8)
|
||||||
|
norm_template = template / (np.linalg.norm(template) + 1e-8)
|
||||||
|
sims = np.dot(norm_feats, norm_template)
|
||||||
|
|
||||||
|
pitches = feats[:, 0]
|
||||||
|
penalty = 1.0 / (1.0 + np.abs(pitches - template[0]) / 100)
|
||||||
|
combos = sims * 0.6 + penalty * 0.4
|
||||||
|
log.info(f" compared {len(combos):,} vectors in {time.time()-t1:.1f}s")
|
||||||
|
|
||||||
|
# 排序
|
||||||
|
order = np.argsort(-combos)
|
||||||
|
results = [(combos[i], sims[i], pitches[i], paths[i]) for i in order]
|
||||||
|
log.info(f" sorted in {time.time()-t1:.1f}s")
|
||||||
|
|
||||||
|
# ── 输出 ──
|
||||||
|
log.info(f"\n{'='*55}")
|
||||||
|
log.info(f"Top 50 Candidates (round 2)")
|
||||||
|
log.info("=" * 55)
|
||||||
|
for rank, (combo, sim, pitch, path) in enumerate(results[:50], 1):
|
||||||
|
star = " *" if combo > 0.90 else ""
|
||||||
|
d = os.path.basename(os.path.dirname(path))
|
||||||
|
f = os.path.basename(path)
|
||||||
|
log.info(f" {rank:2d}. [{combo:.4f}]{star} {d}/{f}")
|
||||||
|
|
||||||
|
# 来源分布
|
||||||
|
log.info(f"\nSource distribution (combo > 0.85):")
|
||||||
|
srcs = {}
|
||||||
|
for combo, sim, pitch, path in results:
|
||||||
|
if combo > 0.85:
|
||||||
|
d = os.path.basename(os.path.dirname(path))
|
||||||
|
srcs[d] = srcs.get(d, 0) + 1
|
||||||
|
for d in sorted(srcs):
|
||||||
|
log.info(f" {d}: {srcs[d]}")
|
||||||
|
|
||||||
|
# 分级导出
|
||||||
|
tiers = [
|
||||||
|
("tier1_095_100", 0.95),
|
||||||
|
("tier2_092_095", 0.92),
|
||||||
|
("tier3_090_092", 0.90),
|
||||||
|
("tier4_085_090", 0.85),
|
||||||
|
]
|
||||||
|
total_copied = 0
|
||||||
|
for tier_name, threshold in tiers:
|
||||||
|
tier_dir = os.path.join(OUT_DIR, tier_name)
|
||||||
|
os.makedirs(tier_dir, exist_ok=True)
|
||||||
|
n = 0
|
||||||
|
for combo, sim, pitch, path in results:
|
||||||
|
if combo >= threshold:
|
||||||
|
dst = os.path.join(tier_dir, os.path.basename(path))
|
||||||
|
if os.path.exists(path) and not os.path.exists(dst):
|
||||||
|
shutil.copy2(path, dst)
|
||||||
|
n += 1
|
||||||
|
else:
|
||||||
|
break # results are sorted, stop when below threshold
|
||||||
|
log.info(f" {tier_name}: {n} files")
|
||||||
|
total_copied += n
|
||||||
|
|
||||||
|
# 保存结果
|
||||||
|
rp = os.path.join(OUT_DIR, "results.json")
|
||||||
|
with open(rp, 'w') as f:
|
||||||
|
json.dump([(float(s), float(p), w) for s, _, p, w in results], f, ensure_ascii=False)
|
||||||
|
|
||||||
|
log.info(f"\n total exported: {total_copied}")
|
||||||
|
log.info(f" results JSON : {rp}")
|
||||||
|
log.info(f" log file : {LOG_FILE}")
|
||||||
|
log.info(f" DONE ({time.time()-t0:.1f}s)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ECAPA-TDNN 声纹搜索 — 多线程版本。
|
||||||
|
模型已缓存在 ~/.cache/huggingface,不再重复下载。
|
||||||
|
"""
|
||||||
|
import os, sys, shutil, json, time, logging
|
||||||
|
from multiprocessing import Pool, cpu_count
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
import torchaudio
|
||||||
|
|
||||||
|
SEARCH_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cleaned"
|
||||||
|
OUT_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cyrene_ecapa"
|
||||||
|
WORKERS = max(1, cpu_count() - 1)
|
||||||
|
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
|
||||||
|
|
||||||
|
CONFIRMED = [
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0358_02ff22a9.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0369_0315d90a.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0370_0317b5bf.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0391_033cbeea.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0392_033d9e2e.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0398_0346fa30.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0417_0367cbc5.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0330_02cb864d.wav",
|
||||||
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0357_02fd4ab1.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0106_009dfe25.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0107_009fba53.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0112_00a89eb2.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0124_00c08c68.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0128_00c6ba19.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0365_026cf0de.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0371_0278f79f.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0374_02803ab5.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0409_02bcd547.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0410_02bec9f4.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0437_02e57ff0.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0440_02e7d98f.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0467_031daa9e.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0102_0095ba46.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0109_00a458cc.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0126_00c44990.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0130_00c95298.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0359_02670f55.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0364_026b8023.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0366_026f3922.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0369_0274d62a.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0372_027b67ef.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0392_029e92a6.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0413_02c1a418.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0415_02c466f1.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0417_02c8ffc0.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0430_02dbad24.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0431_02dda165.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0434_02e216cf.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0443_02eb0bc4.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0455_03048430.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0462_0316bcd9.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0463_03181c56.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0464_03195142.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0468_031e8bd6.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0469_03208c8a.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0484_03339e50.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0488_033a7664.wav",
|
||||||
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0489_033b5c44.wav",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_audio(path):
|
||||||
|
wav, sr = torchaudio.load(path)
|
||||||
|
if sr != 16000:
|
||||||
|
wav = torchaudio.functional.resample(wav, sr, 16000)
|
||||||
|
if wav.shape[0] > 1:
|
||||||
|
wav = wav.mean(dim=0, keepdim=True)
|
||||||
|
return wav # [1, samples]
|
||||||
|
|
||||||
|
|
||||||
|
def get_ref_embeddings():
|
||||||
|
"""主进程: 加载模型, 提取参考嵌入"""
|
||||||
|
from speechbrain.inference.speaker import EncoderClassifier
|
||||||
|
classifier = EncoderClassifier.from_hparams(
|
||||||
|
source="speechbrain/spkrec-ecapa-voxceleb",
|
||||||
|
run_opts={"device": "cpu"},
|
||||||
|
)
|
||||||
|
refs = []
|
||||||
|
for fname in CONFIRMED:
|
||||||
|
path = os.path.join(SEARCH_DIR, fname)
|
||||||
|
wav = load_audio(path)
|
||||||
|
wav = wav.squeeze(0).unsqueeze(0) # [1, time]
|
||||||
|
if wav.shape[1] < 16000:
|
||||||
|
wav = torch.nn.functional.pad(wav, (0, 16000 - wav.shape[1]))
|
||||||
|
with torch.no_grad():
|
||||||
|
emb = classifier.encode_batch(wav).squeeze()
|
||||||
|
refs.append(emb.numpy())
|
||||||
|
return np.mean(refs, axis=0).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_chunk(args):
|
||||||
|
"""Worker: 加载模型, 扫描一批文件, 返回 [(sim, path), ...]"""
|
||||||
|
paths, template_arr = args
|
||||||
|
from speechbrain.inference.speaker import EncoderClassifier
|
||||||
|
classifier = EncoderClassifier.from_hparams(
|
||||||
|
source="speechbrain/spkrec-ecapa-voxceleb",
|
||||||
|
run_opts={"device": "cpu"},
|
||||||
|
)
|
||||||
|
template = torch.from_numpy(template_arr)
|
||||||
|
results = []
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
wav = load_audio(path)
|
||||||
|
wav = wav.squeeze(0).unsqueeze(0)
|
||||||
|
if wav.shape[1] < 8000:
|
||||||
|
continue
|
||||||
|
with torch.no_grad():
|
||||||
|
emb = classifier.encode_batch(wav).squeeze()
|
||||||
|
sim = torch.nn.functional.cosine_similarity(emb, template, dim=0).item()
|
||||||
|
results.append((sim, path))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
LOG_FILE = os.path.join(OUT_DIR, "search.log")
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S",
|
||||||
|
handlers=[logging.FileHandler(LOG_FILE, encoding='utf-8'), logging.StreamHandler(sys.stdout)],
|
||||||
|
)
|
||||||
|
log = logging.getLogger("ecapa")
|
||||||
|
|
||||||
|
log.info("ECAPA-TDNN Search (multiprocess)")
|
||||||
|
log.info(f" refs: {len(CONFIRMED)} | workers: {WORKERS}")
|
||||||
|
|
||||||
|
# Step 1: 参考嵌入 (主进程)
|
||||||
|
log.info("Extracting reference embeddings...")
|
||||||
|
t0 = time.time()
|
||||||
|
template = get_ref_embeddings()
|
||||||
|
log.info(f" template: dim={len(template)}, pitch proxy={template[0]:.4f} ({time.time()-t0:.0f}s)")
|
||||||
|
|
||||||
|
# Step 2: 收集文件
|
||||||
|
all_wavs = []
|
||||||
|
for root, dirs, files in os.walk(SEARCH_DIR):
|
||||||
|
for f in files:
|
||||||
|
if f.endswith(".wav"):
|
||||||
|
all_wavs.append(os.path.join(root, f))
|
||||||
|
log.info(f" files: {len(all_wavs):,}")
|
||||||
|
|
||||||
|
# Step 3: 分块, 多线程扫描
|
||||||
|
chunk_size = max(50, len(all_wavs) // (WORKERS * 4))
|
||||||
|
chunks = [all_wavs[i:i+chunk_size] for i in range(0, len(all_wavs), chunk_size)]
|
||||||
|
chunk_args = [(chunk, template) for chunk in chunks]
|
||||||
|
log.info(f" chunks: {len(chunks)} x ~{chunk_size} | starting pool...")
|
||||||
|
|
||||||
|
t1 = time.time()
|
||||||
|
results = []
|
||||||
|
done = 0
|
||||||
|
pool = Pool(WORKERS)
|
||||||
|
for chunk_results in pool.imap_unordered(scan_chunk, chunk_args):
|
||||||
|
results.extend(chunk_results)
|
||||||
|
done += chunk_size
|
||||||
|
elapsed = time.time() - t1
|
||||||
|
rate = min(done, len(all_wavs)) / elapsed if elapsed else 0
|
||||||
|
eta = (len(all_wavs) - min(done, len(all_wavs))) / rate if rate else 0
|
||||||
|
pct = min(done, len(all_wavs)) * 100 / len(all_wavs)
|
||||||
|
print(f" [{pct:5.1f}%] {min(done, len(all_wavs)):,}/{len(all_wavs):,} | "
|
||||||
|
f"{rate:.0f} f/s | ETA {eta:.0f}s | {len(results):,} ok")
|
||||||
|
pool.close()
|
||||||
|
pool.join()
|
||||||
|
print()
|
||||||
|
log.info(f" scanned in {time.time()-t1:.0f}s | {len(results):,} results")
|
||||||
|
|
||||||
|
# Step 4: 排序 + 输出
|
||||||
|
results.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
|
||||||
|
log.info(f"\n{'='*55}")
|
||||||
|
log.info("Top 50 Candidates")
|
||||||
|
log.info("=" * 55)
|
||||||
|
for rank, (sim, path) in enumerate(results[:50], 1):
|
||||||
|
d = os.path.basename(os.path.dirname(path))
|
||||||
|
f = os.path.basename(path)
|
||||||
|
log.info(f" {rank:2d}. [{sim:.4f}] {d}/{f}")
|
||||||
|
|
||||||
|
log.info(f"\nSource distribution (sim > 0.65):")
|
||||||
|
srcs = {}
|
||||||
|
for sim, path in results:
|
||||||
|
if sim > 0.65:
|
||||||
|
d = os.path.basename(os.path.dirname(path))
|
||||||
|
srcs[d] = srcs.get(d, 0) + 1
|
||||||
|
for d in sorted(srcs):
|
||||||
|
log.info(f" {d}: {srcs[d]}")
|
||||||
|
|
||||||
|
# Export
|
||||||
|
tiers = [("tier1_075", 0.75), ("tier2_070", 0.70), ("tier3_065", 0.65), ("tier4_060", 0.60)]
|
||||||
|
for tier_name, thresh in tiers:
|
||||||
|
tier_dir = os.path.join(OUT_DIR, tier_name)
|
||||||
|
os.makedirs(tier_dir, exist_ok=True)
|
||||||
|
n = 0
|
||||||
|
for sim, path in results:
|
||||||
|
if sim >= thresh:
|
||||||
|
dst = os.path.join(tier_dir, os.path.basename(path))
|
||||||
|
if os.path.exists(path) and not os.path.exists(dst):
|
||||||
|
shutil.copy2(path, dst)
|
||||||
|
n += 1
|
||||||
|
log.info(f" {tier_name}: {n} files")
|
||||||
|
|
||||||
|
rp = os.path.join(OUT_DIR, "results.json")
|
||||||
|
with open(rp, 'w') as f:
|
||||||
|
json.dump([(float(s), p) for s, p in results], f, ensure_ascii=False)
|
||||||
|
log.info(f"\n DONE ({time.time()-t0:.0f}s) | {rp}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""
|
||||||
|
Python 3.8 兼容性补丁: 修复 speechbrain 中的 list[int] 语法。
|
||||||
|
在导入 speechbrain 之前 import 本模块即可。
|
||||||
|
"""
|
||||||
|
import speechbrain.dataio.sampler as _s
|
||||||
|
# Force evaluation of the module to catch the error
|
||||||
|
# The fix: monkey-patch before import completes
|
||||||
|
import sys
|
||||||
|
from speechbrain.dataio import sampler as _sampler_mod
|
||||||
|
|
||||||
|
# Patch the problematic line: replace Optional[list[int]] with Optional["List[int]"]
|
||||||
|
# This is done at the source level by editing the file
|
||||||
|
import os
|
||||||
|
_sp = os.path.join(os.path.dirname(_sampler_mod.__file__), "sampler.py")
|
||||||
|
if os.path.exists(_sp):
|
||||||
|
with open(_sp) as f:
|
||||||
|
src = f.read()
|
||||||
|
if "from __future__ import annotations" not in src:
|
||||||
|
with open(_sp, "w") as f:
|
||||||
|
f.write("from __future__ import annotations\n" + src)
|
||||||
|
print("[speechbrain_fix] patched sampler.py for Python 3.8")
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
RVC 模型推理测试。纯 CPU 模式。
|
||||||
|
两个音频:参考音频 (提取昔涟音色) + 输入音频 (要转换的声音)
|
||||||
|
"""
|
||||||
|
import os, sys, time, warnings
|
||||||
|
warnings.filterwarnings('ignore')
|
||||||
|
|
||||||
|
# 添加 RVC 到路径
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'rvc'))
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import librosa
|
||||||
|
import soundfile as sf
|
||||||
|
import numpy as np
|
||||||
|
from infer.modules.vc.modules import VC
|
||||||
|
|
||||||
|
# === 配置 ===
|
||||||
|
MODEL_PATH = r"D:\Users\Aska\Documents\G_2333333.pth"
|
||||||
|
HUBERT_PATH = r"D:\Project\Code\Uni\Cyrene\scripts\voice\rvc\assets\hubert\hubert_base.pt"
|
||||||
|
REF_AUDIO = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cyrene_group01\VoBanks31_0078_004ab065.wav" # 昔涟参考
|
||||||
|
INPUT_AUDIO = REF_AUDIO # 测试: 用同一文件验证模型能正确重建
|
||||||
|
OUTPUT_AUDIO = r"D:\Users\Aska\Documents\rvc_test_output.wav"
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print("RVC 推理测试 (CPU)")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# 检查文件
|
||||||
|
for label, path in [("模型", MODEL_PATH), ("HuBERT", HUBERT_PATH), ("参考音频", REF_AUDIO)]:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"[ERROR] {label} 不存在: {path}")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"[OK] {label}: {os.path.getsize(path)/1024/1024:.1f} MB")
|
||||||
|
|
||||||
|
# 加载参考音频
|
||||||
|
print(f"\n[1/3] 加载参考音频...")
|
||||||
|
ref_audio, ref_sr = librosa.load(REF_AUDIO, sr=40000, mono=True)
|
||||||
|
print(f" 采样率: {ref_sr}Hz, 时长: {len(ref_audio)/ref_sr:.1f}s")
|
||||||
|
|
||||||
|
# 加载输入音频
|
||||||
|
print(f"[2/3] 加载输入音频...")
|
||||||
|
input_audio, input_sr = librosa.load(INPUT_AUDIO, sr=40000, mono=True)
|
||||||
|
print(f" 采样率: {input_sr}Hz, 时长: {len(input_audio)/input_sr:.1f}s")
|
||||||
|
|
||||||
|
# 推理
|
||||||
|
print(f"[3/3] RVC 推理中...")
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
# 路径中有中文,先复制到临时路径
|
||||||
|
import tempfile, shutil
|
||||||
|
tmp_dir = tempfile.mkdtemp()
|
||||||
|
tmp_model = os.path.join(tmp_dir, "model.pth")
|
||||||
|
tmp_ref = os.path.join(tmp_dir, "ref.wav")
|
||||||
|
tmp_input = os.path.join(tmp_dir, "input.wav")
|
||||||
|
shutil.copy(MODEL_PATH, tmp_model)
|
||||||
|
sf.write(tmp_ref, ref_audio, 40000)
|
||||||
|
sf.write(tmp_input, input_audio, 40000)
|
||||||
|
|
||||||
|
# 保存当前目录
|
||||||
|
old_cwd = os.getcwd()
|
||||||
|
rvc_dir = os.path.join(os.path.dirname(__file__), 'rvc')
|
||||||
|
os.chdir(rvc_dir)
|
||||||
|
# RVC 需要 assets/hubert/hubert_base.pt
|
||||||
|
os.makedirs('assets/hubert', exist_ok=True)
|
||||||
|
if not os.path.exists('assets/hubert/hubert_base.pt'):
|
||||||
|
import shutil as _sh
|
||||||
|
_sh.copy(HUBERT_PATH, 'assets/hubert/hubert_base.pt')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 初始化 VC pipeline
|
||||||
|
vc = VC()
|
||||||
|
vc.get_vc(tmp_model, device="cpu", use_jit=False)
|
||||||
|
|
||||||
|
# 转换
|
||||||
|
output, output_sr = vc.vc_single(
|
||||||
|
sid=0,
|
||||||
|
input_audio_path=tmp_input,
|
||||||
|
f0_up_key=0, # 不改变音高
|
||||||
|
f0_file=None,
|
||||||
|
f0_method="rmvpe",
|
||||||
|
file_index="", # 不使用 index
|
||||||
|
file_index2="",
|
||||||
|
index_rate=0,
|
||||||
|
filter_radius=3,
|
||||||
|
resample_sr=40000,
|
||||||
|
rms_mix_rate=0.25,
|
||||||
|
protect=0.33,
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
print(f"\n 完成! 耗时: {elapsed:.1f}s")
|
||||||
|
print(f" 输出采样率: {output_sr}Hz, 时长: {len(output)/output_sr:.1f}s")
|
||||||
|
|
||||||
|
# 保存
|
||||||
|
sf.write(OUTPUT_AUDIO, output, output_sr)
|
||||||
|
print(f" 已保存: {OUTPUT_AUDIO}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
os.chdir(old_cwd)
|
||||||
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
print(f"\n试听: {OUTPUT_AUDIO}")
|
||||||
Reference in New Issue
Block a user