diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/backend/ai-core/cmd/main.go b/backend/ai-core/cmd/main.go index 579f95a..42c8c56 100644 --- a/backend/ai-core/cmd/main.go +++ b/backend/ai-core/cmd/main.go @@ -932,7 +932,7 @@ func handleChat( } // 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 != "" { req.SessionID = adminSessionID } diff --git a/backend/ai-core/internal/background/thinker.go b/backend/ai-core/internal/background/thinker.go index caea76f..af41fa9 100644 --- a/backend/ai-core/internal/background/thinker.go +++ b/backend/ai-core/internal/background/thinker.go @@ -30,24 +30,59 @@ type PendingThought struct { 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. type ProactiveTarget struct { Platform string // "qq" ChatType string // "private" or "group" - UserID string // QQ user number - GroupID string // QQ group number (group chat) - AtUserID string // QQ number to @mention (optional) + UserID string // OBv11 user number + GroupID string // OBv11 group number (group chat) + AtUserID string // OBv11 number to @mention (optional) } // PlatformChannel represents a platform channel to observe for background thinking. type PlatformChannel struct { Platform string // qq, telegram, etc. 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) } +// 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. // Format: "qq:group:123456,telegram:group:789012" // 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") botUIDs map[string]string + + // 平台格式注册表 (platform -> format) + platformFormats map[string]*PlatformFormat } // AutonomousToolPolicy 自主思考工具调用安全策略 @@ -237,14 +275,14 @@ func (t *Thinker) SetMessagePusher(pusher func(string, string, string)) { 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)) { t.mu.Lock() defer t.mu.Unlock() 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) { t.mu.Lock() defer t.mu.Unlock() @@ -412,6 +450,7 @@ func NewThinker( proactiveGuard: DefaultProactiveGuard(), platformChannels: cfg.PlatformChannels, platformThinkInterval: cfg.PlatformSilentThinkInterval, + platformFormats: defaultPlatformFormats(), } } @@ -1481,7 +1520,7 @@ func (t *Thinker) buildThinkingUserPrompt( sb.WriteString("\n") } - // QQ platform identity and available channels for proactive messaging. + // OBv11 platform identity and available channels for proactive messaging. t.mu.Lock() qqChannels := t.platformChannels botUIDs := t.botUIDs @@ -1489,11 +1528,11 @@ func (t *Thinker) buildThinkingUserPrompt( lastMsgTime := t.lastUserMessage t.mu.Unlock() if len(qqChannels) > 0 { - sb.WriteString("\n\n【你的QQ平台身份与可用频道】\n") + sb.WriteString("\n\n【你的平台身份与可用频道】\n") // Bot's own identity. 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. @@ -1518,23 +1557,23 @@ func (t *Thinker) buildThinkingUserPrompt( if ch.ChannelType == "group" { if triggerReason == "post_chat" { 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 { - sb.WriteString(fmt.Sprintf("【刚刚】你当前正在QQ群聊 %s 中。群里有人说了话——如果你想回应,用【主动消息】【QQ群聊:%s】格式输出。\n", chLabel, activeChID)) + sb.WriteString(fmt.Sprintf("【刚刚】你当前正在OBv11群聊 %s 中。群里有人说了话——如果你想回应,用【主动消息】【OBv11群聊:%s】格式输出。\n", chLabel, activeChID)) } } else { // Autonomous thinking (periodic, silence, startup). 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 { - sb.WriteString(fmt.Sprintf("你当前在QQ群聊 %s 中。现在是自主思考时间——如果你想主动对群里说些什么,用【主动消息】【QQ群聊:%s】格式。\n", chLabel, activeChID)) + sb.WriteString(fmt.Sprintf("你当前在OBv11群聊 %s 中。现在是自主思考时间——如果你想主动对群里说些什么,用【主动消息】【OBv11群聊:%s】格式。\n", chLabel, activeChID)) } } } else { 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 { - sb.WriteString(fmt.Sprintf("你当前在与QQ用户 %s 私聊。现在是自主思考时间——想主动发消息用【主动消息】【QQ私聊:%s】格式。\n", chLabel, activeChID)) + sb.WriteString(fmt.Sprintf("你当前在与OBv11用户 %s 私聊。现在是自主思考时间——想主动发消息用【主动消息】【OBv11私聊:%s】格式。\n", chLabel, activeChID)) } } break @@ -1543,10 +1582,10 @@ func (t *Thinker) buildThinkingUserPrompt( } sb.WriteString("\n发送主动消息的格式:\n") - sb.WriteString("- QQ私聊: 【主动消息】【QQ私聊:QQ号】消息内容\n") - sb.WriteString("- QQ群聊: 【主动消息】【QQ群聊:群号】消息内容\n") - sb.WriteString("- QQ群聊@某人: 【主动消息】【QQ群聊:群号@QQ号】消息内容\n") - sb.WriteString("\n可用的QQ频道列表:\n") + sb.WriteString("- OBv11私聊: 【主动消息】【OBv11私聊:账号】消息内容\n") + sb.WriteString("- OBv11群聊: 【主动消息】【OBv11群聊:群号】消息内容\n") + sb.WriteString("- OBv11群聊@某人: 【主动消息】【OBv11群聊:群号@账号】消息内容\n") + sb.WriteString("\n可用的平台频道列表:\n") for _, ch := range qqChannels { if ch.Platform != "qq" { continue @@ -1567,7 +1606,7 @@ func (t *Thinker) buildThinkingUserPrompt( sb.WriteString("- 群聊消息要有公共价值,不要当成私聊\n") sb.WriteString("- @某人时:确认这个人在群里有发言过,你的@内容是对他之前说的话的回应\n") 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:] } - // Extract proactive message and optional QQ target. - proactiveMsg, proactiveTarget := extractProactiveMessage(content) + // Extract proactive message and optional platform target. + proactiveMsg, proactiveTarget := t.extractProactiveMessage(content) // Prefer active session, fall back to admin main session. pushSessionID := t.activeSessionID 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】 -var qqTargetRe = regexp.MustCompile(`【QQ(私聊|群聊):(\d+)(?:@(\d+))?】`) - -// extractProactiveMessage extracts the 【主动消息】 marker and optional QQ target from thinking content. +// extractProactiveMessage extracts the 【主动消息】 marker and optional platform target from thinking content. // Returns the message content and an optional ProactiveTarget for platform delivery. // 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 := "【主动消息】" // 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 } - // Parse optional QQ target marker right after 【主动消息】. + // Parse optional platform target marker right after 【主动消息】. msg := raw var target *ProactiveTarget - if loc := qqTargetRe.FindStringSubmatchIndex(raw); loc != nil && loc[0] == 0 { - m := qqTargetRe.FindStringSubmatch(raw) - if len(m) == 4 { - 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 == "" { + // Try each registered platform format. + for platform, pf := range t.platformFormats { + if pf.compiled == nil { 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). diff --git a/backend/ai-core/internal/llm/asr.go b/backend/ai-core/internal/llm/asr.go index 0b66b50..fe723f0 100644 --- a/backend/ai-core/internal/llm/asr.go +++ b/backend/ai-core/internal/llm/asr.go @@ -96,7 +96,7 @@ func inferAudioFormat(urlStr, contentType string) string { if strings.Contains(contentType, "audio/ogg") || strings.Contains(contentType, "opus") { 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) { diff --git a/backend/ai-core/internal/memory/extractor.go b/backend/ai-core/internal/memory/extractor.go index 65153d6..8047137 100644 --- a/backend/ai-core/internal/memory/extractor.go +++ b/backend/ai-core/internal/memory/extractor.go @@ -83,7 +83,7 @@ func (e *Extractor) extractObservationsWithLLM(ctx context.Context, message stri 观察到的消息: %s 请以JSON格式返回提取的记忆。这条消息来自群聊/频道,昔涟只是旁观者。 -消息格式为:[群聊 群号] 发送者昵称 (QQ号):消息内容 +消息格式为:[群聊 群号] 发送者昵称 (OBv11账号):消息内容 提取角度:这条消息中包含了什么关于消息发送者、讨论主题、事件或氛围的信息? 重要:请以实际发送者的名字为主语(如"某某说..."),不要统一用"开拓者"称呼所有发言者。 diff --git a/backend/ai-core/internal/orchestrator/orchestrator.go b/backend/ai-core/internal/orchestrator/orchestrator.go index 9bb5676..613eefc 100644 --- a/backend/ai-core/internal/orchestrator/orchestrator.go +++ b/backend/ai-core/internal/orchestrator/orchestrator.go @@ -138,8 +138,8 @@ type ProcessParams struct { Mode string // text / voice_msg / voice_assistant Nickname string ChannelType string // direct / group - ChannelID string // platform channel ID (group ID or private QQ number) - BotUID string // bot's own platform UID (e.g., QQ number) + ChannelID string // platform channel ID (group ID or private OBv11 number) + BotUID string // bot's own platform UID (e.g., OBv11 account) } // ProcessResult 处理结果 diff --git a/backend/ai-core/internal/orchestrator/synthesizer.go b/backend/ai-core/internal/orchestrator/synthesizer.go index a777e37..1d5d438 100644 --- a/backend/ai-core/internal/orchestrator/synthesizer.go +++ b/backend/ai-core/internal/orchestrator/synthesizer.go @@ -216,7 +216,7 @@ func (s *Synthesizer) buildSynthesizeMessages(params SynthesizeParams) []model.L if params.ChannelType == "group" { messages = append(messages, model.LLMMessage{ Role: model.RoleSystem, - Content: "【群聊上下文】这条消息来自QQ群聊。消息前缀 [群聊 群号] 昵称 (QQ号) 标注了真实发送者。你不是在和开拓者一对一私聊,而是在群聊中和不同成员交流。请根据当前这条消息前缀中的发送者名字来称呼对方——即使你之前在历史对话中称呼过别人,也不要把之前用的称呼套在当前发送者身上。不同的人有不同的名字。只在对你说话或延续已有对话时才回复。", + Content: "【群聊上下文】这条消息来自OBv11群聊。消息前缀 [群聊 群号] 昵称 (OBv11账号) 标注了真实发送者。你不是在和开拓者一对一私聊,而是在群聊中和不同成员交流。请根据当前这条消息前缀中的发送者名字来称呼对方——即使你之前在历史对话中称呼过别人,也不要把之前用的称呼套在当前发送者身上。不同的人有不同的名字。只在对你说话或延续已有对话时才回复。", }) } diff --git a/backend/platform-bridge/cmd/main.go b/backend/platform-bridge/cmd/main.go index c3febeb..07e9365 100644 --- a/backend/platform-bridge/cmd/main.go +++ b/backend/platform-bridge/cmd/main.go @@ -298,16 +298,16 @@ func main() { } // Sync admin identities from config fields. syncAdminUIDs(mapper, platform, fields, cfg.AdminNickname) - // Restart QQ reader when QQ config changes. + // Restart OBv11 reader when OBv11 config changes. if platform == "qq" { - startQQReaders(router) + startOBv11Readers(router) } } else { router.RemoveAdapter(name) fmt.Printf("Platform adapter removed: %s\n", name) // Cancel reader goroutines for removed adapter. if platform == "qq" { - startQQReaders(router) + startOBv11Readers(router) } } }) @@ -324,8 +324,8 @@ func main() { blh := handler.NewBlocklistHandler(blocklistStore) blh.RegisterRoutes(mux) - // Start QQ message reader loop. - startQQReaders(router) + // Start OBv11 message reader loop. + startOBv11Readers(router) addr := ":" + cfg.Port srv := &http.Server{Addr: addr, Handler: mux} @@ -355,8 +355,8 @@ func main() { var qqReaderCancels = make(map[string]context.CancelFunc) var qqReaderCancelsMu sync.Mutex -// startQQReaders cancels any existing QQ readers and starts one per registered QQ adapter. -func startQQReaders(router *bridge.PlatformRouter) { +// startOBv11Readers cancels any existing OBv11 readers and starts one per registered OBv11 adapter. +func startOBv11Readers(router *bridge.PlatformRouter) { // Cancel all existing readers. qqReaderCancelsMu.Lock() 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. go func() { for msg := range rawCh { - idx := hashQQSession(msg) % numWorkers + idx := hashOBv11Session(msg) % numWorkers workerChs[idx] <- msg } 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 { switch platform { case "qq": - port := cfg.QQBotPort + port := cfg.OBv11BotPort if p, ok := fields["bot_port"]; ok && 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" { fields["webhook_url"] = cfg.TelegramWebhookURL } - if fields["bot_port"] == "" && cfg.QQBotPort != "" && platform == "qq" { - fields["bot_port"] = cfg.QQBotPort + if fields["bot_port"] == "" && cfg.OBv11BotPort != "" && platform == "qq" { + fields["bot_port"] = cfg.OBv11BotPort } return fields } @@ -928,10 +928,10 @@ func hasOnlySilentMessages(messages []bridge.ResponseMessage) bool { 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, // 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.Write([]byte(msg.MessageType)) h.Write([]byte(":")) @@ -966,7 +966,7 @@ func parseIntOr(s string, defaultVal int) int { func seedIdentities(m *bridge.IdentityMapper, store *config.Store, adminNickname string) { // From environment variables. for _, entry := range []struct{ envKey, platform string }{ - {"QQ_ADMIN_UID", "qq"}, + {"OBV11_ADMIN_UID", "obv11"}, {"TELEGRAM_ADMIN_UID", "telegram"}, } { if raw := os.Getenv(entry.envKey); raw != "" { diff --git a/backend/platform-bridge/internal/bridge/adapter.go b/backend/platform-bridge/internal/bridge/adapter.go index 4fbc803..62de528 100644 --- a/backend/platform-bridge/internal/bridge/adapter.go +++ b/backend/platform-bridge/internal/bridge/adapter.go @@ -21,7 +21,7 @@ type PlatformAdapter interface { } // 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 { SendProactive(chatType string, userID, groupID int64, content string) error } diff --git a/backend/platform-bridge/internal/config/config.go b/backend/platform-bridge/internal/config/config.go index 22e3635..1ee16e9 100644 --- a/backend/platform-bridge/internal/config/config.go +++ b/backend/platform-bridge/internal/config/config.go @@ -14,7 +14,7 @@ type Config struct { InternalToken string // Platform-specific. - QQBotPort string // port for QQ OBv11 reverse WebSocket + OBv11BotPort string // port for OBv11 reverse WebSocket TelegramToken string // Telegram Bot API token TelegramWebhookURL string // public webhook URL for Telegram @@ -34,7 +34,7 @@ func Load() *Config { Env: "development", GatewayURL: "http://localhost:8080", AICoreURL: "http://localhost:8081", - QQBotPort: "8096", + OBv11BotPort: "8096", } if v := os.Getenv("PORT"); v != "" { cfg.Port = v @@ -51,8 +51,8 @@ func Load() *Config { if v := os.Getenv("INTERNAL_SERVICE_TOKEN"); v != "" { cfg.InternalToken = v } - if v := os.Getenv("QQ_BOT_PORT"); v != "" { - cfg.QQBotPort = v + if v := os.Getenv("OBV11_BOT_PORT"); v != "" { + cfg.OBv11BotPort = v } if v := os.Getenv("TELEGRAM_BOT_TOKEN"); v != "" { cfg.TelegramToken = v diff --git a/backend/platform-bridge/internal/handler/bridge_handler.go b/backend/platform-bridge/internal/handler/bridge_handler.go index 85c7212..8b6056c 100644 --- a/backend/platform-bridge/internal/handler/bridge_handler.go +++ b/backend/platform-bridge/internal/handler/bridge_handler.go @@ -180,7 +180,7 @@ func (h *BridgeHandler) sendProactive(w http.ResponseWriter, r *http.Request) { return } - // Map chat type to QQ message_type + // Map chat type to OBv11 message_type msgType := req.ChatType if msgType != "private" && msgType != "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) } - // 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 err := h.router.SendProactive(adapterName, msgType, userID, groupID, content) if err != nil { diff --git a/scripts/voice/__pycache__/speechbrain_fix.cpython-314.pyc b/scripts/voice/__pycache__/speechbrain_fix.cpython-314.pyc new file mode 100644 index 0000000..9415465 Binary files /dev/null and b/scripts/voice/__pycache__/speechbrain_fix.cpython-314.pyc differ diff --git a/scripts/voice/__pycache__/speechbrain_fix.cpython-38.pyc b/scripts/voice/__pycache__/speechbrain_fix.cpython-38.pyc new file mode 100644 index 0000000..8f0187a Binary files /dev/null and b/scripts/voice/__pycache__/speechbrain_fix.cpython-38.pyc differ diff --git a/scripts/voice/batch_convert.py b/scripts/voice/batch_convert.py index b5b37b8..02a0e8c 100644 --- a/scripts/voice/batch_convert.py +++ b/scripts/voice/batch_convert.py @@ -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" CLEANED_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cleaned" -# 要转换的子目录(按优先级) -TARGETS = [ - "VoBanks27", "VoBanks28", "VoBanks29", "VoBanks30", "VoBanks31", -] +# 只转换 3.4-3.7 剧情(昔涟出场版本) +TARGETS = sorted([ + 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: diff --git a/scripts/voice/cluster_voices.py b/scripts/voice/cluster_voices.py new file mode 100644 index 0000000..4c374fb --- /dev/null +++ b/scripts/voice/cluster_voices.py @@ -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,找到昔涟的组,告诉我编号。") diff --git a/scripts/voice/extract_all_headless.py b/scripts/voice/extract_all_headless.py new file mode 100644 index 0000000..847037e --- /dev/null +++ b/scripts/voice/extract_all_headless.py @@ -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() diff --git a/scripts/voice/extract_cyrene_animew.py b/scripts/voice/extract_cyrene_animew.py new file mode 100644 index 0000000..887d95e --- /dev/null +++ b/scripts/voice/extract_cyrene_animew.py @@ -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() diff --git a/scripts/voice/extract_features.py b/scripts/voice/extract_features.py new file mode 100644 index 0000000..46ca773 --- /dev/null +++ b/scripts/voice/extract_features.py @@ -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() diff --git a/scripts/voice/find_cyrene_all.py b/scripts/voice/find_cyrene_all.py new file mode 100644 index 0000000..60a967f --- /dev/null +++ b/scripts/voice/find_cyrene_all.py @@ -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") diff --git a/scripts/voice/find_similar_voice.py b/scripts/voice/find_similar_voice.py new file mode 100644 index 0000000..e373d74 --- /dev/null +++ b/scripts/voice/find_similar_voice.py @@ -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}") diff --git a/scripts/voice/search_cyrene.py b/scripts/voice/search_cyrene.py new file mode 100644 index 0000000..78c1dfa --- /dev/null +++ b/scripts/voice/search_cyrene.py @@ -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() diff --git a/scripts/voice/search_cyrene_v2.py b/scripts/voice/search_cyrene_v2.py new file mode 100644 index 0000000..5b11dc5 --- /dev/null +++ b/scripts/voice/search_cyrene_v2.py @@ -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() diff --git a/scripts/voice/search_ecapa.py b/scripts/voice/search_ecapa.py new file mode 100644 index 0000000..967c3f8 --- /dev/null +++ b/scripts/voice/search_ecapa.py @@ -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() diff --git a/scripts/voice/speechbrain_fix.py b/scripts/voice/speechbrain_fix.py new file mode 100644 index 0000000..f3763cd --- /dev/null +++ b/scripts/voice/speechbrain_fix.py @@ -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") diff --git a/scripts/voice/test_rvc.py b/scripts/voice/test_rvc.py new file mode 100644 index 0000000..d8c342f --- /dev/null +++ b/scripts/voice/test_rvc.py @@ -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}")