CloudBuilder.Topshelf/Task/TTS/SoVITSTtsTask.cs
owenchen 2c6475b2d5 ow
2026-06-22 17:02:26 +08:00

278 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using CloudBuilder.AI.Entity;
using CloudBuilder.AI.Policy;
using CloudBuilder.AI.Utility;
using CloudBuilder.Core.DatabaseAccessor.Entity;
using CloudBuilder.Core.DependencyInjection.Task;
using CloudBuilder.Topshelf.Python;
using CloudBuilder.Topshelf.Utility;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
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:SoVITSTtsTask book_id:B000011 chapter_id:3 voice:MIDDLE_AGED_FEMALE5 actor:韩立 actor_voice:YOUNG_MALE2 save_path:D:\\Net8\\FileServer\\Backup\\DmsFile\\voice
public class SoVITSTtsTask : IScheduleTask
{
private readonly IApplicationService service;
public SoVITSTtsTask(IApplicationService service)
{
this.service = service;
}
public void Run(Dictionary<string, string> bodyDict)
{
ConsoleOutput.HideStdError();
IRepository<AiSentenceViewEntity> repository = service.GetRepository<IRepository<AiSentenceViewEntity>>();
string bookId = string.Empty;
if (bodyDict.ContainsKey("book_id"))
bookId = bodyDict["book_id"];
string chapterId = string.Empty;
if (bodyDict.ContainsKey("chapter_id"))
chapterId = bodyDict["chapter_id"];
string from_chapter_id = string.Empty;
if (bodyDict.ContainsKey("from_chapter_id"))
from_chapter_id = bodyDict["from_chapter_id"];
string to_chapter_id = string.Empty;
if (bodyDict.ContainsKey("to_chapter_id"))
to_chapter_id = bodyDict["to_chapter_id"];
AiSentenceViewEntity[] ents = repository.DetachedEntities.Where(x => x.BookId == bookId
&& (string.IsNullOrEmpty(chapterId) || x.ChapterId == Convert.ToInt32(chapterId))
&& (string.IsNullOrEmpty(from_chapter_id) || x.ChapterId >= Convert.ToInt32(from_chapter_id))
&& (string.IsNullOrEmpty(to_chapter_id) || x.ChapterId <= Convert.ToInt32(to_chapter_id)
)).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 = SoVITSTtsVoiceRandom.GetRandomYoungFemale().ToString(), actor = string.Empty, actor_voice = SoVITSTtsVoiceRandom.GetRandomYoungMale().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"];
float speed = 1.0f;
if (bodyDict.ContainsKey("speed"))
speed = Convert.ToSingle(bodyDict["speed"]);
GenerateAndMergeChapterAudio(ents, speed, actor, actor_voice, voice, save_path);
}
public void GenerateAndMergeChapterAudio(AiSentenceViewEntity[] ents, float speed, string actor, string actor_voice, string mainVoice, string save_path)
{
IRepository<AiBookPersonEntity> repositoryAiBookPersonEntity = service.GetRepository<IRepository<AiBookPersonEntity>>();
AiBookPersonEntity[] bps = repositoryAiBookPersonEntity.DetachedEntities.Where(x => x.BookId == ents[0].BookId).ToArray();
Python39Command python39Command = new Python39Command(service);
SoVITS soVITS = new SoVITS(python39Command);
// 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);
Dictionary<string, string> personMapping = new Dictionary<string, string>();
foreach (var chapter in chapterGroups)
{
string bookId = chapter.Key.BookId;
int chapterId = chapter.Key.ChapterId;
List<string> tempMp3List = new List<string>(); // 本章所有碎片音频
// 2. 生成本章所有句子音频
foreach (AiSentenceViewEntity ent in chapter)
{
try
{
processedLines++;
string voice = SoVITSTtsVoiceRandom.GetRandomYoungFemale().ToString();
if (ent.DialogueIndc == YesNoPolicy.YES)
{
string key = string.IsNullOrEmpty(ent.PersonName) ? "NULL" : ent.PersonName;
if (key == actor)
{
if (personMapping.ContainsKey(key))
voice = personMapping[key];
else
{
voice = actor_voice;
personMapping.Add(key, voice);
}
}
else
{
if (!string.IsNullOrEmpty(key) && personMapping.ContainsKey(key))
{
voice = personMapping[key];
}
else if (!string.IsNullOrEmpty(key) && bps != null && bps.Length > 0 && bps.Where(x => x.PersonName == key).Any())
{
voice = bps.Where(x => x.PersonName == key).FirstOrDefault().ActorName;
personMapping.Add(key, voice);
}
else
{
if (ent.Gender == YesNoPolicy.YES)
{
voice = SoVITSTtsVoiceRandom.GetRandomFemaleExclude(EnumHelper.ToEnum<SoVITSTtsVoice>(actor_voice)).ToString();
personMapping.Add(key, voice);
}
else if (ent.Gender == YesNoPolicy.NO)
{
voice = SoVITSTtsVoiceRandom.GetRandomFemale().ToString();
personMapping.Add(key, voice);
}
else
{
voice = SoVITSTtsVoiceRandom.GetRandom().ToString();
personMapping.Add(key, voice);
}
}
}
}
else
{
voice = mainVoice;
if (personMapping.ContainsKey("mainVoice"))
voice = personMapping["mainVoice"];
else if (bps != null && bps.Length > 0 && bps.Where(x => string.IsNullOrEmpty(x.PersonName)).Any())
{
voice = bps.Where(x => string.IsNullOrEmpty(x.PersonName)).FirstOrDefault().ActorName;
personMapping.Add("mainVoice", voice);
}
}
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);
// 生成音频
soVITS.Generate(ent.Content, soVITS.Voices[EnumHelper.ToEnum<SoVITSTtsVoice>(voice)], filePath);
tempMp3List.Add(filePath); // 加入合并列表
}
catch (Exception ex)
{
Console.WriteLine($"❌ 失败:{ex.Message}");
}
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);
}
}
/// <summary>
/// 最终修复:完全解决 No fmt chunk / 采样率不兼容 / 只有一截声音
/// </summary>
public void MergeChapterMp3Files(List<string> 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();
}
}
}