diff --git a/CloudBuilder.Topshelf.csproj b/CloudBuilder.Topshelf.csproj index 2615ab4..56bd339 100644 --- a/CloudBuilder.Topshelf.csproj +++ b/CloudBuilder.Topshelf.csproj @@ -59,6 +59,8 @@ + + diff --git a/Lib/CloudBuilder.AI.dll b/Lib/CloudBuilder.AI.dll index 4b8118e..7930740 100644 Binary files a/Lib/CloudBuilder.AI.dll and b/Lib/CloudBuilder.AI.dll differ diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json index ea2989a..efaa058 100644 --- a/Properties/launchSettings.json +++ b/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "CloudBuilder.Topshelf": { "commandName": "Project", - "commandLineArgs": "task:ChineseNameExtractorTask book_id:B000008" + "commandLineArgs": "task:TtsTask book_id:B000008 voice:Sherpa2 actor:澈蕾 actor_voice:Sherpa1 save_path:D:\\\\Net8\\\\FileServer\\\\Backup\\\\DmsFile\\\\voice" } } } \ No newline at end of file diff --git a/Task/AI/ChineseNameExtractorTask.cs b/Task/AI/ChineseNameExtractorTask.cs index acc3861..e90f57a 100644 --- a/Task/AI/ChineseNameExtractorTask.cs +++ b/Task/AI/ChineseNameExtractorTask.cs @@ -42,7 +42,7 @@ namespace CloudBuilder.Topshelf.Task List cons = null; - SpeakerAnalysisHelper speakerFeatureLearner = new SpeakerAnalysisHelper(); + SpeakerAnalysisHelper speakerAnalysisHelper = new SpeakerAnalysisHelper(); AiSentenceViewEntity[] ents = repository.DetachedEntities.Where(x => x.BookId == guid).OrderBy(x => x.ChapterId).ThenBy(x => x.ParagraphId).ThenBy(x => x.SentenceIndex).ToArray(); if (ents == null || ents.Length == 0) return; @@ -54,11 +54,12 @@ namespace CloudBuilder.Topshelf.Task int totalLines = ents.Length; int processedLines = 0; // 已处理行数计数器 AiChapterPersonEntity aiChapterPerson; + List cps = new List(); foreach (var ent in ents) { processedLines++; - person = speakerFeatureLearner.GetPerson(hanlp, ent.Content); + person = speakerAnalysisHelper.GetPerson(hanlp, ent.Content); paragraphIndex = ent.ParagraphId; if (!string.IsNullOrEmpty(person)) @@ -72,8 +73,9 @@ namespace CloudBuilder.Topshelf.Task aiChapterPerson.PersonName = p; aiChapterPerson.ChapterId = ent.ChapterId; aiChapterPerson.ParagraphIndex = ent.ParagraphId; + aiChapterPerson.SentenceIndex= ent.SentenceIndex; aiChapterPerson.OrderIndex = orderIndex++; - + cps.Add(aiChapterPerson); repositoryAiChapterPersonEntity.InsertNow(aiChapterPerson); } } @@ -81,6 +83,9 @@ namespace CloudBuilder.Topshelf.Task UpdateProgress(processedLines, totalLines); } + speakerAnalysisHelper.AiChapterPersons = cps.ToArray(); + + speakerAnalysisHelper.MatchPerson(ents, repositoryAiSentenceEntity); } catch (Exception ex) { diff --git a/Task/TTS/TtsTask.cs b/Task/TTS/TtsTask.cs index 4bfe594..eff26db 100644 --- a/Task/TTS/TtsTask.cs +++ b/Task/TTS/TtsTask.cs @@ -1,9 +1,14 @@ -using CloudBuilder.Core.DatabaseAccessor.Entity; +using CloudBuilder.AI.Entity; +using CloudBuilder.Core.DatabaseAccessor.Entity; using CloudBuilder.Core.DependencyInjection.Task; using CloudBuilder.Topshelf.Utility; +using Microsoft.IdentityModel.Tokens; +using NAudio.Lame; +using NAudio.Wave; using System; using System.Collections.Generic; using System.Linq; +using System.Numerics; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Threading.Tasks; @@ -12,6 +17,7 @@ using static System.Runtime.InteropServices.JavaScript.JSType; namespace CloudBuilder.Topshelf.Task.TTS { + //task:TtsTask book_id:B000008 voice:Sherpa2 actor:韩立 actor_voice:Sherpa1 save_path:D:\\Net8\\FileServer\\Backup\\DmsFile\\voice //task:TtsTask voice:Sherpa2 save_path:D:\\Net8\\FileServer\\Backup\\DmsFile\\voice\\20260528112739.mp3 text:二愣子睁大着双眼,直直望着茅草和烂泥糊成的黑屋顶,身上盖着的旧棉被,已呈深黄色,看不出原来的本来面目,还若有若无的散发着淡淡的霉味。 public class TtsTask : IScheduleTask { @@ -24,19 +30,173 @@ namespace CloudBuilder.Topshelf.Task.TTS public void Run(Dictionary bodyDict) { - string save_path = string.Empty, text = string.Empty, voice = ChineseTtsVoice.Sherpa2.ToString(); + IRepository repository = service.GetRepository>(); + + string bookId = string.Empty; + if (bodyDict.ContainsKey("book_id")) + bookId = bodyDict["book_id"]; + + AiSentenceViewEntity[] ents = repository.DetachedEntities.Where(x => x.BookId == bookId).OrderBy(x => x.ChapterId).ThenBy(x => x.ParagraphId).ThenBy(x => x.SentenceIndex).ToArray(); + + if (ents == null || ents.Length == 0) return; + + string save_path = string.Empty, text = string.Empty, voice = ChineseTtsVoice.Sherpa2.ToString(), actor = string.Empty, actor_voice = ChineseTtsVoice.Sherpa1.ToString(); + if (bodyDict.ContainsKey("actor")) + actor = bodyDict["actor"]; + if (bodyDict.ContainsKey("voice")) voice = bodyDict["voice"]; + if (bodyDict.ContainsKey("actor_voice")) + actor_voice = bodyDict["actor_voice"]; + if (bodyDict.ContainsKey("save_path")) save_path = bodyDict["save_path"]; - if (bodyDict.ContainsKey("text")) - text = bodyDict["text"]; + GenerateAndMergeChapterAudio(ents, actor, actor_voice, voice, save_path); + } - if (string.IsNullOrEmpty(voice) || string.IsNullOrEmpty(save_path) || string.IsNullOrEmpty(text)) return; + public void GenerateAndMergeChapterAudio(AiSentenceViewEntity[] ents, string actor, string actor_voice, string mainVoice, string save_path) + { + // 1. 按 BookId + ChapterId 分组(关键!) + var chapterGroups = ents + .Where(ent => ent.Content.Any(c => c >= 0x4E00 && c <= 0x9FFF)) // 只保留含中文的句子 + .GroupBy(ent => new { ent.BookId, ent.ChapterId }) + .ToList(); - SaveAudioToFileHelper.AudioToFile(EnumHelper.ToEnum(voice), text, save_path); + int totalLines = chapterGroups.Count(); + int processedLines = 0; // 已处理行数计数器 + + foreach (var chapter in chapterGroups) + { + processedLines++; + + string bookId = chapter.Key.BookId; + int chapterId = chapter.Key.ChapterId; + List tempMp3List = new List(); // 本章所有碎片音频 + + // 2. 生成本章所有句子音频 + foreach (AiSentenceViewEntity ent in chapter) + { + string voice = ChineseTtsVoice.Sherpa2.ToString(); + if (ent.DialogueIndc == YesNoPolicy.YES) + { + if (ent.PersonName == actor) + voice = actor_voice; + else + voice = AI.Policy.ChineseTtsVoiceRandom.GetRandom().ToString(); + } + else + { + voice = mainVoice; + } + + if (string.IsNullOrEmpty(voice) || string.IsNullOrEmpty(ent.Content)) + continue; + + // 碎片文件名:BookId_ChapterId_ParagraphId_SentenceId.mp3 + string fileName = string.Format("{0}_{1}_{2}_{3}.mp3", ent.BookId, ent.ChapterId, ent.ParagraphId, ent.SentenceId); + string filePath = Path.Combine(save_path, fileName); + + // 生成音频 + SaveAudioToFileHelper.AudioToFile(EnumHelper.ToEnum(voice), ent.Content, filePath); + + tempMp3List.Add(filePath); // 加入合并列表 + } + + Thread.Sleep(100); + + // 3. 合并为:BookId_ChapterId.mp3 + string outputFileName = string.Format("{0}_{1}.mp3", bookId, chapterId); + string outputFilePath = Path.Combine(save_path, outputFileName); + + MergeChapterMp3Files(tempMp3List, outputFilePath); + + // 4. 可选:合并后删除碎片文件(节省空间) + foreach (var chunk in tempMp3List) + { + try { File.Delete(chunk); } catch { } + } + + Console.WriteLine($"章节合并完成:{outputFileName}"); + + UpdateProgress(processedLines, totalLines); + } + } + /// + /// 最终修复:完全解决 No fmt chunk / 采样率不兼容 / 只有一截声音 + /// + public void MergeChapterMp3Files(List chunkFiles, string outputFilePath) + { + var validFiles = chunkFiles.Where(File.Exists).ToList(); + if (validFiles.Count == 0) return; + + try + { + // 目标格式:32000Hz 单声道 16bit + WaveFormat targetFormat = new WaveFormat(32000, 16, 1); + + // 直接生成 MP3,不经过 WAV 文件,彻底避免 fmt 错误 + using (var mp3Writer = new LameMP3FileWriter(outputFilePath, targetFormat, 128)) + { + foreach (var file in validFiles) + { + try + { + // 释放文件占用 + GC.Collect(); + GC.WaitForPendingFinalizers(); + + // 用Windows系统解码器读取任何MP3 + using (var reader = new MediaFoundationReader(file)) + using (var resampler = new MediaFoundationResampler(reader, targetFormat)) + { + byte[] buffer = new byte[8192]; + int read; + while ((read = resampler.Read(buffer, 0, buffer.Length)) > 0) + { + mp3Writer.Write(buffer, 0, read); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"跳过文件:{file},错误:{ex.Message}"); + } + } + } + + Console.WriteLine($"✅ 合并成功:{outputFilePath},大小:{new FileInfo(outputFilePath).Length} 字节"); + } + catch (Exception ex) + { + Console.WriteLine($"❌ 合并失败:{ex.Message}"); + } + } + + // 辅助方法:WAV 转 MP3 + private void WaveFileToMp3(byte[] wavData, string mp3Path) + { + using (var ms = new MemoryStream(wavData)) + using (var waveReader = new WaveFileReader(ms)) + using (var mp3Writer = new LameMP3FileWriter(mp3Path, waveReader.WaveFormat, 128)) + { + waveReader.CopyTo(mp3Writer); + } + } + + private static void UpdateProgress(int processed, int total) + { + double progressPercent = (double)processed / total * 100; + int progressBarLength = 50; // 进度条总长度 + int filledLength = (int)(progressPercent / 100 * progressBarLength); + + // 构建进度条(如:[██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░]) + string progressBar = "[" + new string('█', filledLength) + new string('░', progressBarLength - filledLength) + "]"; + + string progressInfo = $"{progressBar} {progressPercent:F2}% | 已处理:{processed}/{total} "; + Console.Write($"\r{progressInfo}"); + Console.Out.Flush(); } } } diff --git a/Utility/SpeakerAnalysisHelper.cs b/Utility/SpeakerAnalysisHelper.cs index e84b445..18b3b1e 100644 --- a/Utility/SpeakerAnalysisHelper.cs +++ b/Utility/SpeakerAnalysisHelper.cs @@ -1,4 +1,5 @@ using CloudBuilder.AI.Entity; +using CloudBuilder.Core.DatabaseAccessor.Entity; using CloudBuilder.Topshelf.Python; using System; using System.Collections.Generic; @@ -184,7 +185,35 @@ namespace CloudBuilder.Topshelf.Utility return arg0; } - //public AiSentenceEntity[] AiParagraphs { get; set; } + public void MatchPerson(AiSentenceViewEntity[] aiSentences, IRepository repositoryAiSentenceEntity) + { + if (AiChapterPersons == null || AiChapterPersons.Length == 0) return; + + AiSentenceEntity aiSentence; + string dialogueName = string.Empty; + foreach (AiSentenceViewEntity aiSentenceView in aiSentences) + { + dialogueName= string.Empty; + if (aiSentenceView.DialogueIndc != YesNoPolicy.YES) continue; + + if (aiSentences.Where(x => x.ChapterId == aiSentenceView.ChapterId && x.ParagraphId == aiSentenceView.ParagraphId).Count() > 1) + { + if (AiChapterPersons.Where(x => x.ChapterId == aiSentenceView.ChapterId && x.ParagraphIndex == aiSentenceView.ParagraphId && aiSentenceView.SentenceIndex == x.SentenceIndex).Any()) + { + dialogueName = AiChapterPersons.Where(x => x.ChapterId == aiSentenceView.ChapterId && x.ParagraphIndex == aiSentenceView.ParagraphId && aiSentenceView.SentenceIndex == x.SentenceIndex).FirstOrDefault().PersonName; + } + + if (AiChapterPersons.Where(x => x.ChapterId == aiSentenceView.ChapterId && x.ParagraphIndex == aiSentenceView.ParagraphId && aiSentenceView.SentenceIndex != x.SentenceIndex && x.PersonName != dialogueName).Any()) + { + aiSentenceView.PersonName = AiChapterPersons.Where(x =>x.ChapterId==aiSentenceView.ChapterId && x.ParagraphIndex == aiSentenceView.ParagraphId && aiSentenceView.SentenceIndex != x.SentenceIndex && x.PersonName != dialogueName).OrderBy(x => x.OrderIndex).FirstOrDefault().PersonName; + } + } + + aiSentence = new AiSentenceEntity(); + ObjectCopyHelper.Copy(aiSentenceView, aiSentence); + repositoryAiSentenceEntity.UpdateNow(aiSentence); + } + } public AiSentenceEntity[] AiSentences { get; set; } @@ -203,37 +232,4 @@ D -->|匹配失败| E{规则3:段落内最近人物}; E -->|匹配成功| C; E -->|匹配失败| F[赋值默认值+标记异常]; -规则 1:文本特征精准匹配(最高优先级) -基于对话内容中的 “专属特征” 直接匹配说话者,避免上下文干扰: -示例规则: -对话内容特征 匹配的说话者 -包含 “第二元婴” 韩立 -包含 “银月所化” 银月 -包含 “本座 / 本尊” 反派角色 - -落地:维护一个 “特征 - 人物” 映射字典,匹配时优先使用: -规则 2:紧邻旁白匹配(次高优先级)只找对话行 “紧邻的前 1-2 行旁白”,而非整个段落的旁白,避免跨越多个人物导致匹配错误: -规则:仅检查对话行的前 1 行、前 2 行旁白(最多前 3 行),超过则停止; -落地:修改向前查找的逻辑,限制查找范围 - -规则 3:排除无效人物(避免干扰)从旁白中提取人物时,过滤掉 “非核心人物”(如 “店小二”“路人”),只保留主要人物: -落地:维护 “核心人物白名单”,提取人物时只保留白名单内的名称 - -3. 增加异常处理和人工校验(兜底保障) -即使逻辑再完善,也会有边缘场景匹配错误,需通过 “异常标记 + 人工复核” 兜底: - -步骤 1:标记可疑匹配结果对以下情况标记为 “可疑”,存入日志或单独字段: -匹配到的人物在旁白中出现次数≥2(比如同一段落有韩立和银月,无法确定); -对话行前后 3 行都没有找到人物,最终赋值为 “未知人物”; -对话内容无任何特征,且段落内人物数量≥3; -落地:为实体增加扩展字段(如IsSuspected),或生成异常日志 - -步骤 2:人工复核可疑数据 -规则:所有标记为 “可疑” 的匹配结果,必须经过人工复核后再更新到数据库; -落地:生成 “可疑数据清单”(包含Guid、Content、PersonName),供运营 / 编辑人员校验,修正错误后重新填充。 - - -收集错误案例:记录每次匹配错误的场景(如 “银月的对话匹配给韩立”),分析错误原因; -迭代特征字典:将错误案例转化为新的特征规则(如新增 “乌云”→银月,“元婴”→韩立); -A/B 测试:对优化后的规则做小范围测试,对比匹配准确率,确认有效后全量上线。 */ \ No newline at end of file