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; using static System.Net.Mime.MediaTypeNames; 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 { private readonly IApplicationService service; public TtsTask(IApplicationService service) { this.service = service; } public void Run(Dictionary bodyDict) { ConsoleOutput.HideStdError(); 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"]; GenerateAndMergeChapterAudio(ents, actor, actor_voice, voice, save_path); } 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(); int totalLines = ents.Count(); int processedLines = 0; // 已处理行数计数器 UpdateProgress(processedLines, totalLines); foreach (var chapter in chapterGroups) { string bookId = chapter.Key.BookId; int chapterId = chapter.Key.ChapterId; List tempMp3List = new List(); // 本章所有碎片音频 // 2. 生成本章所有句子音频 foreach (AiSentenceViewEntity ent in chapter) { processedLines++; 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); // 加入合并列表 UpdateProgress(processedLines, totalLines); } 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 { } } 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}"); } } 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(); } } }