|
| 1 | +using Edge_tts_sharp.Model; |
| 2 | +using System; |
| 3 | +using System.Resources; |
| 4 | +using System.Collections.Generic; |
| 5 | +using System.IO; |
| 6 | +using System.Linq; |
| 7 | +using System.Net.WebSockets; |
| 8 | +using System.Reflection; |
| 9 | +using System.Text; |
| 10 | +using System.Text.RegularExpressions; |
| 11 | +using System.Threading.Tasks; |
| 12 | +using Edge_tts_sharp.Utils; |
| 13 | +using System.Threading; |
| 14 | +using System.Security.Cryptography; |
| 15 | + |
| 16 | + |
| 17 | +namespace Edge_tts_sharp |
| 18 | +{ |
| 19 | + public class Edge_tts |
| 20 | + { |
| 21 | + /// <summary> |
| 22 | + /// 调试模式 |
| 23 | + /// </summary> |
| 24 | + public static bool Debug = false; |
| 25 | + /// <summary> |
| 26 | + /// 同步模式 |
| 27 | + /// </summary> |
| 28 | + public static bool Await = false; |
| 29 | + |
| 30 | + private const string ChromiumVersion = "143.0.3650.75"; |
| 31 | + private const string ChromiumMajorVersion = "143"; |
| 32 | + |
| 33 | + private static Dictionary<string, string> Headers { get; } = new Dictionary<string, string>() |
| 34 | + { |
| 35 | + { "User-Agent", $"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{ChromiumMajorVersion}.0.0.0 Safari/537.36 Edg/{ChromiumMajorVersion}.0.0.0" }, |
| 36 | + // { "Accept-Encoding", "gzip, deflate, br, zstd" }, |
| 37 | + { "Accept-Language", "en-US,en;q=0.9" }, |
| 38 | + { "Pragma", "no-cache" }, |
| 39 | + { "Cache-Control", "no-cache" }, |
| 40 | + { "Origin", "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold"}, |
| 41 | + { "Accept", "*/*" }, |
| 42 | + }; |
| 43 | + |
| 44 | + static string GenerateSecMsGecToken() |
| 45 | + { |
| 46 | + // 吵了下作业 |
| 47 | + // 来自 https://github.com/STBBRD/EdgeTTS_dotNET_Framework/ |
| 48 | + // 来自 https://github.com/rany2/edge-tts/issues/290#issuecomment-2464956570 |
| 49 | + var ticks = DateTime.Now.ToFileTimeUtc(); |
| 50 | + ticks -= ticks % 3_000_000_000; |
| 51 | + var str = ticks + "6A5AA1D4EAFF4E9FB37E23D68491D6F4"; |
| 52 | + return ToHexString(HashData(Encoding.ASCII.GetBytes(str))); |
| 53 | + } |
| 54 | + static string ToHexString(byte[] byteArray) |
| 55 | + { |
| 56 | + return BitConverter.ToString(byteArray).Replace("-", "").ToUpper(); |
| 57 | + } |
| 58 | + static byte[] HashData(byte[] data) |
| 59 | + { |
| 60 | + using (SHA256 sha256 = SHA256.Create()) |
| 61 | + { |
| 62 | + byte[] hashBytes = sha256.ComputeHash(data); |
| 63 | + return hashBytes; |
| 64 | + } |
| 65 | + } |
| 66 | + static string GetGUID() |
| 67 | + { |
| 68 | + return Guid.NewGuid().ToString().Replace("-", ""); |
| 69 | + } |
| 70 | + /// <summary> |
| 71 | + /// 讲一个浮点型数值转换为百分比数值 |
| 72 | + /// </summary> |
| 73 | + /// <param name="input"></param> |
| 74 | + /// <returns></returns> |
| 75 | + static string FromatPercentage(double input) |
| 76 | + { |
| 77 | + string output; |
| 78 | + |
| 79 | + if (input < 0) |
| 80 | + { |
| 81 | + output = input.ToString("+#;-#;0") + "%"; |
| 82 | + } |
| 83 | + else |
| 84 | + { |
| 85 | + output = input.ToString("+#;-#;0") + "%"; |
| 86 | + } |
| 87 | + return output; |
| 88 | + } |
| 89 | + static string ConvertToAudioFormatWebSocketString(string outputformat) |
| 90 | + { |
| 91 | + return "Content-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n{\"context\":{\"synthesis\":{\"audio\":{\"metadataoptions\":{\"sentenceBoundaryEnabled\":\"false\",\"wordBoundaryEnabled\":\"false\"},\"outputFormat\":\"" + outputformat + "\"}}}}"; |
| 92 | + } |
| 93 | + /// <summary> |
| 94 | + /// |
| 95 | + /// </summary> |
| 96 | + /// <param name="lang">输出语言</param> |
| 97 | + /// <param name="voice">音源名</param> |
| 98 | + /// <param name="rate">语速,-100% - 100% 之间的值,无需传递百分号</param> |
| 99 | + /// <param name="text"></param> |
| 100 | + /// <returns></returns> |
| 101 | + static string ConvertToSsmlText(string lang, string voice, int rate, int volume, string text) |
| 102 | + { |
| 103 | + return $"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='{lang}'><voice name='{voice}'><prosody pitch='+0Hz' rate ='{FromatPercentage(rate)}' volume='{volume}'>{text}</prosody></voice></speak>"; |
| 104 | + } |
| 105 | + static string ConvertToSsmlWebSocketString(string requestId, string lang, string voice, int rate, int volume, string msg) |
| 106 | + { |
| 107 | + return $"X-RequestId:{requestId}\r\nContent-Type:application/ssml+xml\r\nPath:ssml\r\n\r\n{ConvertToSsmlText(lang, voice, rate, volume, msg)}"; |
| 108 | + } |
| 109 | + /// <summary> |
| 110 | + /// 语言转文本,将结果返回到回调函数中 |
| 111 | + /// </summary> |
| 112 | + /// <param name="option">播放参数</param> |
| 113 | + /// <param name="voice">音源参数</param> |
| 114 | + public static void Invoke(PlayOption option, eVoice voice, Action<List<byte>> callback, IProgress<List<byte>> progress = null) |
| 115 | + { |
| 116 | + var binary_delim = "Path:audio\r\n"; |
| 117 | + var sendRequestId = GetGUID(); |
| 118 | + var binary = new List<byte>(); |
| 119 | + bool IsTurnEnd = false; |
| 120 | + |
| 121 | + var wss = new Wss($"wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4&Sec-MS-GEC={GenerateSecMsGecToken()}&Sec-MS-GEC-Version={ChromiumVersion}"); |
| 122 | + |
| 123 | + wss.AddCookie("muid", GetGUID(), "/", "speech.platform.bing.com"); |
| 124 | + foreach (var header in Headers) |
| 125 | + { |
| 126 | + wss.AddHeader(header.Key, header.Value); |
| 127 | + } |
| 128 | + |
| 129 | + wss.OnMessage += (sender, e) => |
| 130 | + { |
| 131 | + if (e.IsText) |
| 132 | + { |
| 133 | + var data = e.Data; |
| 134 | + var requestId = Regex.Match(data, @"X-RequestId:(?<requestId>.*?)\r\n").Groups["requestId"].Value; |
| 135 | + if (data.Contains("Path:turn.start")) |
| 136 | + { |
| 137 | + // start of turn, ignore. 开始信号,不用处理 |
| 138 | + } |
| 139 | + else if (data.Contains("Path:turn.end")) |
| 140 | + { |
| 141 | + // 返回内容 |
| 142 | + if (binary.Count > 0) |
| 143 | + { |
| 144 | + callback?.Invoke(binary); |
| 145 | + } |
| 146 | + else |
| 147 | + { |
| 148 | + throw new Exception("返回值为空!"); |
| 149 | + } |
| 150 | + // end of turn, close stream. 结束信号,可主动关闭socket |
| 151 | + // 音频发送完毕后,最后还会收到一个表示音频结束的文本信息 |
| 152 | + //wss.Close(); |
| 153 | + } |
| 154 | + else if (data.Contains("Path:response")) |
| 155 | + { |
| 156 | + // context response, ignore. 响应信号,无需处理 |
| 157 | + } |
| 158 | + else |
| 159 | + { |
| 160 | + // 未知错误,通常不会发生 |
| 161 | + } |
| 162 | + if (Debug) Console.WriteLine(e.Data); |
| 163 | + IsTurnEnd = true; |
| 164 | + } |
| 165 | + else if (e.IsBinary) |
| 166 | + { |
| 167 | + var data = e.RawData; |
| 168 | + var requestId = Regex.Match(e.Data, @"X-RequestId:(?<requestId>.*?)\r\n").Groups["requestId"].Value; |
| 169 | + if (data[0] == 0x00 && data[1] == 0x67 && data[2] == 0x58) |
| 170 | + { |
| 171 | + // Last (empty) audio fragment. 空音频片段,代表音频发送结束 |
| 172 | + } |
| 173 | + else |
| 174 | + { |
| 175 | + var index = Encoding.UTF8.GetString(data).IndexOf(binary_delim) + binary_delim.Length; |
| 176 | + var curVal = data.Skip(index); |
| 177 | + binary.AddRange(curVal); |
| 178 | + // 传出 |
| 179 | + progress?.Report(curVal.ToList()); |
| 180 | + } |
| 181 | + } |
| 182 | + }; |
| 183 | + wss.OnColse += (sender, e) => |
| 184 | + { |
| 185 | + if (!string.IsNullOrEmpty(option.SavePath)) |
| 186 | + { |
| 187 | + File.WriteAllBytes(option.SavePath, binary.ToArray()); |
| 188 | + } |
| 189 | + }; |
| 190 | + wss.OnLog += (onmsg) => |
| 191 | + { |
| 192 | + if (Debug) Console.WriteLine($"[{onmsg.level.ToString()}] {onmsg.msg}"); |
| 193 | + }; |
| 194 | + if (wss.Run()) |
| 195 | + { |
| 196 | + wss.Send(ConvertToAudioFormatWebSocketString(voice.SuggestedCodec)); |
| 197 | + wss.Send(ConvertToSsmlWebSocketString(sendRequestId, voice.Locale, voice.Name, option.Rate, ((int)option.Volume * 100), option.Text)); |
| 198 | + } |
| 199 | + while (Await && !IsTurnEnd) |
| 200 | + { |
| 201 | + Thread.Sleep(10); |
| 202 | + } |
| 203 | + } |
| 204 | + /// <summary> |
| 205 | + /// 另存为mp3文件 |
| 206 | + /// </summary> |
| 207 | + /// <param name="option">播放参数</param> |
| 208 | + /// <param name="voice">音源参数</param> |
| 209 | + public static void SaveAudio(PlayOption option, eVoice voice) |
| 210 | + { |
| 211 | + if (string.IsNullOrEmpty(option.SavePath)) |
| 212 | + { |
| 213 | + throw new Exception("保存路径为空,请核对参数后重试."); |
| 214 | + } |
| 215 | + Invoke(option, voice, null); |
| 216 | + } |
| 217 | + /// <summary> |
| 218 | + /// 调用微软Edge接口,文字转语音 |
| 219 | + /// </summary> |
| 220 | + /// <param name="option">播放参数</param> |
| 221 | + /// <param name="voice">音源参数</param> |
| 222 | + public static void PlayText(PlayOption option, eVoice voice) |
| 223 | + { |
| 224 | + Invoke(option, voice, (_binary) => |
| 225 | + { |
| 226 | + Audio.PlayToByteAsync(_binary.ToArray(), option.Volume); |
| 227 | + }); |
| 228 | + } |
| 229 | + /// <summary> |
| 230 | + /// 获取一个`AudioPlayer`的对象 |
| 231 | + /// </summary> |
| 232 | + /// <param name="option">播放参数</param> |
| 233 | + /// <param name="voice">音源参数</param> |
| 234 | + /// <returns></returns> |
| 235 | + public static AudioPlayer GetPlayer(PlayOption option, eVoice voice) |
| 236 | + { |
| 237 | + AudioPlayer player = null; |
| 238 | + Invoke(option, voice, (_binary) => |
| 239 | + { |
| 240 | + player = new AudioPlayer(_binary.ToArray(), option.Volume); |
| 241 | + }); |
| 242 | + while (player == null) |
| 243 | + { |
| 244 | + Thread.Sleep(10); |
| 245 | + } |
| 246 | + return player; |
| 247 | + } |
| 248 | + /// <summary> |
| 249 | + /// 同步等待播放音频结束 |
| 250 | + /// </summary> |
| 251 | + /// <param name="option">播放参数</param> |
| 252 | + /// <param name="voice">音源参数</param> |
| 253 | + //public static void PlayTextAsync(PlayOption option, eVoice voice) |
| 254 | + //{ |
| 255 | + // List<byte> buffer = new List<byte>(); |
| 256 | + // var audioStreamer = new Mp3AudioStreamer(); |
| 257 | + // var report = new Progress<List<byte>>((binary) => |
| 258 | + // { |
| 259 | + // audioStreamer.OnAudioReceived(binary.ToArray()); |
| 260 | + // }); |
| 261 | + // Invoke(option, voice, null, report); |
| 262 | + |
| 263 | + // audioStreamer.Stop(); |
| 264 | + //} |
| 265 | + |
| 266 | + /// <summary> |
| 267 | + /// 获取支持的音频列表 |
| 268 | + /// </summary> |
| 269 | + /// <returns></returns> |
| 270 | + public static List<eVoice> GetVoice() |
| 271 | + { |
| 272 | + var voiceList = Tools.GetEmbedText("Edge_tts_sharp.Source.VoiceList.json"); |
| 273 | + return Tools.StringToJson<List<eVoice>>(voiceList); |
| 274 | + } |
| 275 | + } |
| 276 | +} |
0 commit comments