From 37a62a3fb066152a8d5db37af0654135f7faa0df Mon Sep 17 00:00:00 2001 From: dengxibo <22745709@qq.com> Date: Wed, 14 Oct 2026 20:04:58 +0800 Subject: [PATCH] Fix Edge online speech with Sec-MS-GEC v2 --- NaturalVoiceSAPIAdapter/EdgeGec.h | 145 +++++ NaturalVoiceSAPIAdapter/EdgeGecTables.h | 27 + .../NaturalVoiceSAPIAdapter.vcxproj | 17 +- .../SpeechServiceConstants.h | 10 +- NaturalVoiceSAPIAdapter/WSConnectionPool.cpp | 134 ++-- tests/.gitignore | 6 + tests/README.md | 75 +++ tests/gec_cli.cpp | 9 + tests/port_gec.py | 27 + tests/reference/LICENSE.edge-tts | 195 ++++++ tests/reference/README.md | 14 + tests/reference/constants.py | 45 ++ tests/reference/drm.py | 610 ++++++++++++++++++ tests/sapi_smoke.cpp | 250 +++++++ tests/test_gec.py | 26 + 15 files changed, 1508 insertions(+), 82 deletions(-) create mode 100644 NaturalVoiceSAPIAdapter/EdgeGec.h create mode 100644 NaturalVoiceSAPIAdapter/EdgeGecTables.h create mode 100644 tests/.gitignore create mode 100644 tests/README.md create mode 100644 tests/gec_cli.cpp create mode 100644 tests/port_gec.py create mode 100644 tests/reference/LICENSE.edge-tts create mode 100644 tests/reference/README.md create mode 100644 tests/reference/constants.py create mode 100644 tests/reference/drm.py create mode 100644 tests/sapi_smoke.cpp create mode 100644 tests/test_gec.py diff --git a/NaturalVoiceSAPIAdapter/EdgeGec.h b/NaturalVoiceSAPIAdapter/EdgeGec.h new file mode 100644 index 0000000..dba22b9 --- /dev/null +++ b/NaturalVoiceSAPIAdapter/EdgeGec.h @@ -0,0 +1,145 @@ +#pragma once +#include +#include +#include + +// Sec-MS-GEC v2, ported from edge-tts 7.3.0 drm.py (LGPL-3.0). +// Keep this independent of Windows/SAPI so the production algorithm can be tested. +namespace EdgeGec { +#include "EdgeGecTables.h" +inline uint32_t Ror(uint32_t x, unsigned n) { + return (x >> n) | (x << (32 - n)); +} +inline uint32_t Swap(uint32_t x) { + return Ror(x, 16); +} +inline uint32_t S0(uint32_t x) { + return Ror(x, 7) ^ Ror(x, 18) ^ (x >> 3); +} +inline uint32_t S1(uint32_t x) { + return Ror(x, 17) ^ Ror(x, 19) ^ (x >> 10); +} +inline uint32_t ReadBE(const unsigned char* p) { + return uint32_t(p[0]) << 24 | uint32_t(p[1]) << 16 | uint32_t(p[2]) << 8 | p[3]; +} +inline uint32_t ReadLE(const unsigned char* p) { + return uint32_t(p[3]) << 24 | uint32_t(p[2]) << 16 | uint32_t(p[1]) << 8 | p[0]; +} +inline void Expand(uint32_t* w, unsigned first) { + for (unsigned i = first; i < 64; i++) w[i] = S1(w[i - 2]) + w[i - 7] + S0(w[i - 15]) + w[i - 16]; +} +inline void Compress(uint32_t* state, const uint32_t* w, unsigned first) { + uint32_t a = state[0], b = state[1], c = state[2], d = state[3], e = state[4], f = state[5], g = state[6], + h = state[7]; + for (unsigned i = first; i < 64; i++) { + uint32_t k = first == 14 && i == 14 ? 0xC9DC06A7 : Rounds[i]; + uint32_t t1 = h + (Ror(e, 6) ^ Ror(e, 11) ^ Ror(e, 25)) + (g ^ (e & (f ^ g))) + k + w[i]; + uint32_t t2 = (Ror(a, 2) ^ Ror(a, 13) ^ Ror(a, 22)) + ((a & b) | (c & (a | b))); + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + } + uint32_t work[] = {a, b, c, d, e, f, g, h}; + for (unsigned i = 0; i < 8; i++) state[i] = (first == 14 ? Initial[i] : state[i]) + work[i]; +} +// fileTime includes the correction derived from the server's Date header. +// result must have room for 81 ASCII characters plus the terminating NUL. +inline void Generate(uint64_t fileTime, char* result) { + fileTime -= fileTime % 3000000000ULL; + char data[64]; + snprintf(data, sizeof(data), "%llu6A5AA1D4EAFF4E9FB37E23D68491D6F4", (unsigned long long)fileTime); + size_t len = strlen(data); + const auto* bytes = reinterpret_cast(data); + // V2 resumes a modified SHA-256 block after a fixed 57-byte prefix. + // These schedule/state adjustments are intentional; ordinary SHA256(data) + // produces the old v1 token and cannot replace this step. + uint32_t w[64] = {}; + w[14] = uint32_t(bytes[0]) << 16 | uint32_t(bytes[1]) << 8 | bytes[2]; + w[15] = ReadBE(bytes + 3); + for (unsigned i = 16; i < 30; i++) { + uint32_t v = Schedule[i - 16]; + if (i - 2 >= 14) v += S1(w[i - 2]) ^ (i == 16 ? 0x000B92C0 : 0); + if (i - 7 >= 14) v += w[i - 7]; + if (i - 15 >= 14) v += S0(w[i - 15]) ^ (i == 29 ? 0x059C0B80 : 0); + w[i] = v; + } + w[30] = S1(w[28]) + w[23] + S0(w[15]) + w[14] + 0xC9DC06A7 - Rounds[14]; + Expand(w, 31); + uint32_t state[8]; + memcpy(state, ResumeState, sizeof(state)); + Compress(state, w, 14); + unsigned char padded[128] = {}; + memcpy(padded, bytes + 7, len - 7); + padded[len - 7] = 0x80; + size_t total = ((len + 57 + 9 + 63) / 64) * 64 - 64; + uint64_t bits = (len + 57) * 8; + for (unsigned i = 0; i < 8; i++) padded[total - 1 - i] = (unsigned char)(bits >> (i * 8)); + for (size_t off = 0; off < total; off += 64) { + for (unsigned i = 0; i < 16; i++) w[i] = ReadBE(padded + off + i * 4); + Expand(w, 16); + Compress(state, w, 0); + } + unsigned char digest[32]; + for (unsigned i = 0; i < 32; i++) digest[i] = (unsigned char)(state[i / 4] >> (24 - 8 * (i % 4))); + // CS64 uses little-endian words; SHA-256 emits big-endian words. + uint32_t ka = ReadLE(digest) | 1, kb = ReadLE(digest + 4) | 1; + uint32_t factors[] = {ka * 0x82893933U, kb * 0x82893933U}; + uint32_t terms[32] = {}, offsets[32] = {}; + unsigned count = 14; + for (unsigned i = 0; i < 14; i++) { + terms[i] = Coefficients[i][0] * factors[i % 2]; + offsets[i] = Coefficients[i][1]; + } + unsigned char input[72] = {}; + memcpy(input + 1, bytes, len); + unsigned words = (unsigned)((len + 1 + 7) / 8) * 2; + for (unsigned i = 0; i < words; i++) { + uint32_t word = ReadLE(input + i * 4); + terms[count] = word * (i % 2 ? kb : ka); + offsets[count] = Swap(word) * (i % 2 ? 0xC31713DBU : 0xEF0569FBU); + if (i == 0) { + terms[count] += 0xF2F90B1AU * factors[0]; + offsets[count] += 0x0B1A0000; + } + count++; + } + uint32_t swapAcc = 0, swapLast = 0, s = 0, acc = 0; + for (unsigned i = 0; i < count; i += 2) { + uint32_t x = terms[i], y = terms[i + 1]; + uint32_t q = Swap(offsets[i] + x) * 0x76F50F34U; + swapAcc += Swap(q) * 0xC3EFEA97U + q; + q = Swap(offsets[i + 1] + y) * 0x1881CE3CU; + swapLast = Swap(q) * 0x35BD1EC9U + q; + swapAcc += swapLast; + s = s * ka + x; + s = Swap(s) * 0xCF98B111U; + s = Swap(s) * 0x87085B9FU; + s = Swap(s) * 0x12CEB96DU; + s = Swap(s) * 0x257E1D83U; + acc += s; + uint32_t t = s * kb + y; + t = Swap(t) * 0xA27416F5U; + t = Swap(t) * 0xD38396FFU; + s = Swap(Swap(t) * 0x7C932B89U) * 0xBFA49F69U; + acc += s; + } + uint32_t cs[] = {swapLast ^ s, swapAcc ^ acc}; + constexpr char hex[] = "0123456789ABCDEF"; + for (unsigned i = 0; i < 8; i++) { + unsigned char v = (unsigned char)(cs[i / 4] >> (8 * (i % 4))); + *result++ = hex[v >> 4]; + *result++ = hex[v & 15]; + } + *result++ = '-'; + for (auto v : digest) { + *result++ = hex[v >> 4]; + *result++ = hex[v & 15]; + } + *result = 0; +} +} // namespace EdgeGec diff --git a/NaturalVoiceSAPIAdapter/EdgeGecTables.h b/NaturalVoiceSAPIAdapter/EdgeGecTables.h new file mode 100644 index 0000000..84df120 --- /dev/null +++ b/NaturalVoiceSAPIAdapter/EdgeGecTables.h @@ -0,0 +1,27 @@ +// Constants from edge-tts 7.3.0 drm.py (LGPL-3.0). See tests/reference. +static constexpr uint32_t Initial[] = { + 0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19, +}; +static constexpr uint32_t ResumeState[] = { + 0xFD9FBEA2, 0x91243CE7, 0xBE499094, 0xCD2E1E0A, 0x6C006F49, 0x04BD35AB, 0xFEB0A7E4, 0x03EFFBD2, +}; +static constexpr uint32_t Schedule[] = { + 0xEB24BF9E, 0x1558691F, 0xA0C09EE2, 0x0CFA32BA, 0xA622EE8C, 0xD1DD9491, 0xC0DD25A0, + 0xF9E70AB6, 0xF0C31573, 0x7058CE4E, 0x7188E606, 0x00E5AB33, 0xD258B060, 0x72766564, +}; +static constexpr uint32_t Rounds[] = { + 0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5, 0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5, + 0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3, 0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174, + 0xE49B69C1, 0xEFBE4786, 0x0FC19DC6, 0x240CA1CC, 0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA, + 0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7, 0xC6E00BF3, 0xD5A79147, 0x06CA6351, 0x14292967, + 0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13, 0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85, + 0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3, 0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070, + 0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5, 0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3, + 0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208, 0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2, +}; +static constexpr uint32_t Coefficients[][2] = { + {0xE82E91B1, 0xB32700D0}, {0x44DE24C6, 0x88DFF51D}, {0x3BEB65BC, 0x29514038}, {0xC89C5933, 0xB328CBDF}, + {0x8D1EC211, 0xD405B4D5}, {0x314EF5D5, 0x26E55F3C}, {0xE92491B1, 0x607801C6}, {0xF0A5B9D5, 0x82B2C1FB}, + {0x06064CF3, 0xFA51A3DA}, {0xDA79FA60, 0x207CC464}, {0xBED20560, 0x3C3C74F3}, {0x89F9C9F8, 0xD2D14B61}, + {0x35FA38C6, 0xBB04A2C1}, {0xCB49E3C6, 0xC5626167}, +}; diff --git a/NaturalVoiceSAPIAdapter/NaturalVoiceSAPIAdapter.vcxproj b/NaturalVoiceSAPIAdapter/NaturalVoiceSAPIAdapter.vcxproj index 1c44094..844e810 100644 --- a/NaturalVoiceSAPIAdapter/NaturalVoiceSAPIAdapter.vcxproj +++ b/NaturalVoiceSAPIAdapter/NaturalVoiceSAPIAdapter.vcxproj @@ -102,6 +102,9 @@ + + true + true true @@ -174,7 +177,7 @@ Windows .\NaturalVoiceSAPIAdapter.def - true + $(NaturalVoiceRegisterOutput) false Microsoft.CognitiveServices.Speech.core.dll libcrypto.lib;libssl.lib;crypt32.lib;RuntimeObject.lib;%(AdditionalDependencies) @@ -214,7 +217,7 @@ Windows .\NaturalVoiceSAPIAdapter.def - true + $(NaturalVoiceRegisterOutput) false Microsoft.CognitiveServices.Speech.core.dll libcrypto.lib;libssl.lib;crypt32.lib;RuntimeObject.lib;%(AdditionalDependencies) @@ -255,7 +258,7 @@ Windows .\NaturalVoiceSAPIAdapter.def - true + $(NaturalVoiceRegisterOutput) false Microsoft.CognitiveServices.Speech.core.dll libcrypto.lib;libssl.lib;crypt32.lib;RuntimeObject.lib;%(AdditionalDependencies) @@ -298,7 +301,7 @@ .\NaturalVoiceSAPIAdapter.def true true - true + $(NaturalVoiceRegisterOutput) false Microsoft.CognitiveServices.Speech.core.dll /PDBALTPATH:%_PDB% %(AdditionalOptions) @@ -340,7 +343,7 @@ .\NaturalVoiceSAPIAdapter.def true true - true + $(NaturalVoiceRegisterOutput) false Microsoft.CognitiveServices.Speech.core.dll /PDBALTPATH:%_PDB% %(AdditionalOptions) @@ -383,7 +386,7 @@ .\NaturalVoiceSAPIAdapter.def true true - true + $(NaturalVoiceRegisterOutput) false Microsoft.CognitiveServices.Speech.core.dll /PDBALTPATH:%_PDB% %(AdditionalOptions) @@ -417,6 +420,8 @@ + + diff --git a/NaturalVoiceSAPIAdapter/SpeechServiceConstants.h b/NaturalVoiceSAPIAdapter/SpeechServiceConstants.h index 00d34d0..2f67a7d 100644 --- a/NaturalVoiceSAPIAdapter/SpeechServiceConstants.h +++ b/NaturalVoiceSAPIAdapter/SpeechServiceConstants.h @@ -16,9 +16,9 @@ constexpr inline const char* EDGE_TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23 constexpr inline const char* EDGE_VOICE_LIST_URL = "https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list?trustedclienttoken=6A5AA1D4EAFF4E9FB37E23D68491D6F4"; -constexpr inline const wchar_t* EDGE_WEBSOCKET_URL - = L"wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1" - L"?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4&Sec-MS-GEC={Sec-MS-GEC}&Sec-MS-GEC-Version=1-142.0.3595.94"; +constexpr inline const wchar_t* EDGE_WEBSOCKET_URL = + L"wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1" + L"?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4&Sec-MS-GEC={Sec-MS-GEC}&Sec-MS-GEC-Version=2-143.0.3650.75"; constexpr inline const char* AZURE_TTS_HOST_AFTER_REGION = ".tts.speech.microsoft.com"; @@ -26,4 +26,6 @@ constexpr inline const char* AZURE_VOICE_LIST_PATH = "/cognitiveservices/voices/ constexpr inline const char* AZURE_WEBSOCKET_PATH = "/cognitiveservices/websocket/v1"; -constexpr inline const char* EDGE_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0"; \ No newline at end of file +constexpr inline const char* EDGE_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 " + "Edg/143.0.0.0"; \ No newline at end of file diff --git a/NaturalVoiceSAPIAdapter/WSConnectionPool.cpp b/NaturalVoiceSAPIAdapter/WSConnectionPool.cpp index d1bb7fa..43acc41 100644 --- a/NaturalVoiceSAPIAdapter/WSConnectionPool.cpp +++ b/NaturalVoiceSAPIAdapter/WSConnectionPool.cpp @@ -2,7 +2,7 @@ #include "WSConnectionPool.h" #include "RegKey.h" #include "SpeechServiceConstants.h" -#include +#include "EdgeGec.h" extern TaskScheduler g_taskScheduler; @@ -19,36 +19,18 @@ static void ReplaceString(std::string& str, std::string_view from, const std::st } } -static std::string GetGECToken() -{ - // algorithm for generating the Sec-MS-GEC token - // see: https://github.com/rany2/edge-tts/issues/290#issuecomment-2464956570 - - FILETIME ft; - GetSystemTimeAsFileTime(&ft); - ULARGE_INTEGER ul = { ft.dwLowDateTime, ft.dwHighDateTime }; - // add the time delta to the local time - ul.QuadPart += s_responseTimeDelta.load(std::memory_order_relaxed); - ul.QuadPart -= ul.QuadPart % 3'000'000'000; // round down to the nearest 5 minute - - // concatenate ticks & trusted client token into a string - // then calculate SHA-256 hash - std::string str = std::to_string(ul.QuadPart) + EDGE_TRUSTED_CLIENT_TOKEN; - BYTE hash[SHA256_DIGEST_LENGTH]; - if (!SHA256(reinterpret_cast(str.data()), str.size(), hash)) - throw std::runtime_error("GEC token generation failed"); - - // convert the hash to a hexadecimal string - std::string result(SHA256_DIGEST_LENGTH * 2, '0'); - char* pCh = result.data(); - constexpr const char* HexDigits = "0123456789ABCDEF"; - for (BYTE byte : hash) - { - *pCh++ = HexDigits[(byte & 0xF0) >> 4]; - *pCh++ = HexDigits[byte & 0x0F]; - } +static std::string GetGECToken() { + // Sec-MS-GEC v2; see edge-tts 7.3.0 src/edge_tts/drm.py. - return result; + FILETIME ft; + GetSystemTimeAsFileTime(&ft); + ULARGE_INTEGER ul = {ft.dwLowDateTime, ft.dwHighDateTime}; + // add the time delta to the local time + ul.QuadPart += s_responseTimeDelta.load(std::memory_order_relaxed); + char result[82]; + EdgeGec::Generate(ul.QuadPart, result); + + return result; } WSConnectionPool::WSConnectionPool() @@ -265,48 +247,56 @@ WSConnectionPtr WSConnectionPool::MakeConnectionPtr(HostInfo& info, WSConnection return WSConnectionPtr(ptr, ptr->get()); } -void WSConnectionPool::CreateConnection( - std::string url, - const std::string& key, - HostInfo& info) -{ - ReplaceString(url, "{Sec-MS-GEC}", GetGECToken()); - - std::error_code ec; - auto conn = m_client.get_connection(url, ec); - if (ec) - throw std::system_error(ec); - - WSConnectionUPtr wrapper(new WSConnection(conn)); - - SetConnectionHandlers(info, wrapper.get()); - - conn->append_header("User-Agent", EDGE_USER_AGENT); - - if (!key.empty()) - conn->append_header("Ocp-Apim-Subscription-Key", key); - - std::string proxy = GetProxyForUrl(url); - if (!proxy.empty()) - { - size_t schemeDelimPos = proxy.find("://"); - if (schemeDelimPos == proxy.npos) - { - conn->set_proxy("http://" + std::move(proxy)); - } - else - { - std::string_view scheme(proxy.data(), schemeDelimPos); - if (EqualsIgnoreCase(scheme, "http")) - conn->set_proxy(proxy); - } - } - - m_client.connect(conn); - info.connections.push_back(std::move(wrapper)); - info.count++; - LogDebug("Connection pool: New connection {} created and put into pool ({}/{})", - conn, info.connections.size(), info.count); +void WSConnectionPool::CreateConnection(std::string url, const std::string& key, HostInfo& info) { + const bool isEdge = url.starts_with("wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?"); + if (url.find("{Sec-MS-GEC}") != std::string::npos) ReplaceString(url, "{Sec-MS-GEC}", GetGECToken()); + + std::error_code ec; + auto conn = m_client.get_connection(url, ec); + if (ec) throw std::system_error(ec); + + WSConnectionUPtr wrapper(new WSConnection(conn)); + + SetConnectionHandlers(info, wrapper.get()); + + conn->append_header("User-Agent", EDGE_USER_AGENT); + if (isEdge) { + conn->append_header("Origin", "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold"); + conn->append_header("Pragma", "no-cache"); + conn->append_header("Cache-Control", "no-cache"); + conn->append_header("Accept-Language", "en-US,en;q=0.9"); + GUID muid; + HRESULT hr = CoCreateGuid(&muid); + if (FAILED(hr)) throw std::runtime_error("Cannot generate Edge MUID"); + const auto* bytes = reinterpret_cast(&muid); + char cookie[40] = "muid="; + constexpr char hex[] = "0123456789ABCDEF"; + for (unsigned i = 0; i < sizeof(muid); i++) { + cookie[5 + i * 2] = hex[bytes[i] >> 4]; + cookie[6 + i * 2] = hex[bytes[i] & 15]; + } + cookie[37] = ';'; + conn->append_header("Cookie", cookie); + } + + if (!key.empty()) conn->append_header("Ocp-Apim-Subscription-Key", key); + + std::string proxy = GetProxyForUrl(url); + if (!proxy.empty()) { + size_t schemeDelimPos = proxy.find("://"); + if (schemeDelimPos == proxy.npos) { + conn->set_proxy("http://" + std::move(proxy)); + } else { + std::string_view scheme(proxy.data(), schemeDelimPos); + if (EqualsIgnoreCase(scheme, "http")) conn->set_proxy(proxy); + } + } + + m_client.connect(conn); + info.connections.push_back(std::move(wrapper)); + info.count++; + LogDebug("Connection pool: New connection {} created and put into pool ({}/{})", conn, info.connections.size(), + info.count); } void WSConnectionPool::SetConnectionHandlers(HostInfo& info, WSConnection* wrapper) diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..6a3a775 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,6 @@ +*.exe +*.wav +*.obj +*.pdb +*.log +__pycache__/ diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..9cdb217 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,75 @@ +# Edge Sec-MS-GEC v2 regression tests + +Based on v0.2.9 (`7194eca5b78d8e477793c159d731d62b6357ffbf`). +The pinned edge-tts 7.3.0 reference and its license are in `reference/`. + +The v2 implementation ports the modified SHA-256 and CS64 digest. It retains +server Date clock correction and rounds integer FILETIME values down to +five-minute windows. The protocol version and User-Agent match edge-tts 7.3.0. +Additional Edge handshake headers are restricted to the official Edge endpoint. +Local SDK initialization, Azure subscription authentication, SSML, decoding +and SAPI event conversion are unchanged. + +## Build without registration + +Run from the repository root in a Visual Studio Developer PowerShell: + +```powershell +msbuild NaturalVoiceSAPIAdapter.sln /t:NaturalVoiceSAPIAdapter /m /p:Configuration=Release /p:Platform=x64 /p:NaturalVoiceRegisterOutput=false +msbuild NaturalVoiceSAPIAdapter.sln /t:NaturalVoiceSAPIAdapter /m /p:Configuration=Release /p:Platform=x86 /p:NaturalVoiceRegisterOutput=false +``` + +The registration option preserves the existing default (`true`). Always pass +`NaturalVoiceRegisterOutput=false` when testing without installation. +The test machine used `/p:PlatformToolset=v145 /p:PreferredToolArchitecture=x64` +because its v143 installation lacked ATL. + +## Offline algorithm comparison + +```powershell +cl /nologo /EHsc /W4 /std:c++17 tests\gec_cli.cpp /Fe:tests\gec_cli.exe /Fo:tests\gec_cli.obj +python tests\test_gec.py +``` + +Python 3.10 or later is required; no Python dependencies need to be installed. +This compares compiled production code with the pinned reference, including +random timestamps, five-minute boundaries and uint64 extremes. + +## Direct SAPI engine smoke test + +```powershell +cl /nologo /EHsc /W4 /std:c++17 /utf-8 tests\sapi_smoke.cpp /Fe:tests\sapi_smoke.exe /Fo:tests\sapi_smoke.obj ole32.lib sapi.lib uuid.lib + tests\sapi_smoke.exe "$PWD\x64\Release\NaturalVoiceSAPIAdapter.dll" en-US-AriaNeural 60000 tests\english.wav + tests\sapi_smoke.exe "$PWD\x64\Release\NaturalVoiceSAPIAdapter.dll" zh-CN-XiaoxiaoNeural 60000 tests\chinese.wav + tests\sapi_smoke.exe "$PWD\x64\Release\NaturalVoiceSAPIAdapter.dll" local 60000 tests\local.wav + tests\sapi_smoke.exe "$PWD\x64\Release\NaturalVoiceSAPIAdapter.dll" en-US-AriaNeural 100 +``` + +This executable uses LoadLibraryEx / DllGetClassObject and online tokens in +memory. It does not register the engine or run the installer. The local mode +reads existing voices and reports SKIP when none are available; it never +downloads voice packs or changes settings. + +For x86 testing, compile in an x64_x86 Developer environment using output name +sapi_smoke_x86.exe, then load Release\NaturalVoiceSAPIAdapter.dll. Process and +DLL architectures must match. Audio is 24 kHz, 16-bit mono WAV. + +## Recorded validation (2026-10-14) + +| Check | Result | +|---|---| +| Release x64 DLL | Zero errors; two existing warnings | +| Release x86 DLL | Zero errors; one existing warning | +| Algorithm comparison | All 318 vectors matched edge-tts 7.3.0 | +| x64 Edge en-US-AriaNeural | 225792 PCM bytes; 8 word, 2 sentence, 1 bookmark events | +| x86 Edge en-US-AriaNeural | 225792 PCM bytes; 8 word, 2 sentence, 1 bookmark events | +| x64 Edge zh-CN-XiaoxiaoNeural | 202752 PCM bytes; 6 word, 2 sentence, 1 bookmark, 47 viseme events | +| x64 local Aria | 155400 PCM bytes; 8 word, 1 bookmark, 31 viseme events | +| x64 cancellation after 100 ms | S_OK; returned after approximately 125 ms | +| AzureSpeechSDKShim x64, including Patcher | Build succeeded; Patcher not run | +| TtsApplication x64 | Build succeeded; GUI not launched | + +The local SDK path does not emit sentence events; this test preserves that +behavior. Stream-start/end events generated by the SAPI host are outside +this direct ISpTTSEngine test. Azure subscription endpoints, XP runtime and +ARM64 have not been tested. No installation or registration was performed. diff --git a/tests/gec_cli.cpp b/tests/gec_cli.cpp new file mode 100644 index 0000000..a6ce8ee --- /dev/null +++ b/tests/gec_cli.cpp @@ -0,0 +1,9 @@ +#include "../NaturalVoiceSAPIAdapter/EdgeGec.h" +#include +int main(int argc, char** argv) { + for (int i = 1; i < argc; i++) { + char token[82]; + EdgeGec::Generate(strtoull(argv[i], nullptr, 10), token); + puts(token); + } +} diff --git a/tests/port_gec.py b/tests/port_gec.py new file mode 100644 index 0000000..2127b99 --- /dev/null +++ b/tests/port_gec.py @@ -0,0 +1,27 @@ +"""Generate constant tables from the pinned edge-tts 7.3.0 reference.""" +import ast +from pathlib import Path + +root = Path(__file__).resolve().parent.parent +tree = ast.parse((root / 'tests/reference/drm.py').read_text()) +tables = {} +for node in tree.body: + if isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name): + try: + tables[node.targets[0].id] = ast.literal_eval(node.value) + except (ValueError, TypeError): + pass +names = {'_SHA256_INITIAL': 'Initial', '_SHA256_STATE': 'ResumeState', + '_SHA256_SCHEDULE': 'Schedule', '_SHA256_ROUND_CONSTANTS': 'Rounds', + '_CS64_COEFFICIENTS': 'Coefficients'} +out = '// Constants from edge-tts 7.3.0 drm.py (LGPL-3.0). See tests/reference.\n' +for key, name in names.items(): + values = tables[key] + if isinstance(values[0], tuple): + out += f'static constexpr uint32_t {name}[][2] = {{\n' + out += ''.join(f' {{0x{a:08X}, 0x{b:08X}}},\n' for a, b in values) + else: + out += f'static constexpr uint32_t {name}[] = {{\n' + out += ''.join(f' 0x{v:08X},\n' for v in values) + out += '};\n' +(root / 'NaturalVoiceSAPIAdapter/EdgeGecTables.h').write_text(out) diff --git a/tests/reference/LICENSE.edge-tts b/tests/reference/LICENSE.edge-tts new file mode 100644 index 0000000..5d9bc34 --- /dev/null +++ b/tests/reference/LICENSE.edge-tts @@ -0,0 +1,195 @@ +The MIT license is used for 'src/edge_tts/srt_composer.py' only. All +remaining files are licensed under the LGPLv3. + +----------------------------------------------------------------------- + +The MIT License + +Copyright (c) 2014-2023 Christopher Down +Copyright (c) 2025- rany + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +----------------------------------------------------------------------- + + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/tests/reference/README.md b/tests/reference/README.md new file mode 100644 index 0000000..18b09cc --- /dev/null +++ b/tests/reference/README.md @@ -0,0 +1,14 @@ +Pinned reference: rany2/edge-tts tag `7.3.0`. + +- https://github.com/rany2/edge-tts/blob/7.3.0/src/edge_tts/drm.py +- https://github.com/rany2/edge-tts/blob/7.3.0/src/edge_tts/constants.py +- https://github.com/rany2/edge-tts/blob/7.3.0/LICENSE + +`EdgeGec.h` and `EdgeGecTables.h` port the v2 digest from `drm.py`. +These portions retain edge-tts's LGPLv3 license; see `LICENSE.edge-tts`. +The repository's original MIT license is unchanged for its original code. + +`test_gec.py` runs the pinned Python digest without installing edge-tts or +requiring aiohttp. It compares the compiled production algorithm with this +independent reference, including exact FILETIME window boundaries. +`port_gec.py` regenerates only the constant table; run clang-format afterward. diff --git a/tests/reference/constants.py b/tests/reference/constants.py new file mode 100644 index 0000000..16bc616 --- /dev/null +++ b/tests/reference/constants.py @@ -0,0 +1,45 @@ +"""Constants for the edge_tts package.""" + +BASE_URL = "speech.platform.bing.com/consumer/speech/synthesize/readaloud" +TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4" + +WSS_URL = f"wss://{BASE_URL}/edge/v1?TrustedClientToken={TRUSTED_CLIENT_TOKEN}" +VOICE_LIST = f"https://{BASE_URL}/voices/list?trustedclienttoken={TRUSTED_CLIENT_TOKEN}" + +DEFAULT_VOICE = "en-US-EmmaMultilingualNeural" + +CHROMIUM_FULL_VERSION = "143.0.3650.75" +CHROMIUM_MAJOR_VERSION = CHROMIUM_FULL_VERSION.split(".", maxsplit=1)[0] +SEC_MS_GEC_VERSION = f"2-{CHROMIUM_FULL_VERSION}" +BASE_HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + f" (KHTML, like Gecko) Chrome/{CHROMIUM_MAJOR_VERSION}.0.0.0 Safari/537.36" + f" Edg/{CHROMIUM_MAJOR_VERSION}.0.0.0", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept-Language": "en-US,en;q=0.9", +} +WSS_HEADERS = { + "Pragma": "no-cache", + "Cache-Control": "no-cache", + "Origin": "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold", + "Sec-WebSocket-Version": "13", +} +WSS_HEADERS.update(BASE_HEADERS) +VOICE_HEADERS = { + "Authority": "speech.platform.bing.com", + "Sec-CH-UA": f'" Not;A Brand";v="99", "Microsoft Edge";v="{CHROMIUM_MAJOR_VERSION}",' + f' "Chromium";v="{CHROMIUM_MAJOR_VERSION}"', + "Sec-CH-UA-Mobile": "?0", + "Accept": "*/*", + "Sec-Fetch-Site": "none", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", +} +VOICE_HEADERS.update(BASE_HEADERS) + +# Audio timing constants for CBR-based offset compensation. +# The output format "audio-24khz-48kbitrate-mono-mp3" is a 48 kbps constant +# bitrate stream. Microsoft's offset/duration metadata uses 100-nanosecond +# ticks, so 1 second = 10,000,000 ticks. +TICKS_PER_SECOND = 10_000_000 +MP3_BITRATE_BPS = 48_000 diff --git a/tests/reference/drm.py b/tests/reference/drm.py new file mode 100644 index 0000000..9411804 --- /dev/null +++ b/tests/reference/drm.py @@ -0,0 +1,610 @@ +"""DRM module is used to handle DRM operations with clock skew correction. +Currently the only DRM operation is generating the Sec-MS-GEC token value +used in all API requests to Microsoft Edge's online text-to-speech service.""" + +import secrets +import struct +from datetime import datetime as dt +from datetime import timezone as tz +from functools import lru_cache +from typing import Dict, List, Optional, Tuple + +import aiohttp + +from .constants import TRUSTED_CLIENT_TOKEN +from .exceptions import SkewAdjustmentError + +WIN_EPOCH = 11644473600 +S_TO_NS = 1e9 + +# FIPS 180-4: +# https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf +_SHA256_INITIAL = ( + 0x6A09E667, + 0xBB67AE85, + 0x3C6EF372, + 0xA54FF53A, + 0x510E527F, + 0x9B05688C, + 0x1F83D9AB, + 0x5BE0CD19, +) +_SHA256_STATE = ( + 0xFD9FBEA2, + 0x91243CE7, + 0xBE499094, + 0xCD2E1E0A, + 0x6C006F49, + 0x04BD35AB, + 0xFEB0A7E4, + 0x03EFFBD2, +) +_SHA256_SCHEDULE = ( + 0xEB24BF9E, + 0x1558691F, + 0xA0C09EE2, + 0x0CFA32BA, + 0xA622EE8C, + 0xD1DD9491, + 0xC0DD25A0, + 0xF9E70AB6, + 0xF0C31573, + 0x7058CE4E, + 0x7188E606, + 0x00E5AB33, + 0xD258B060, + 0x72766564, +) +_SHA256_ROUND_CONSTANTS = ( + 0x428A2F98, + 0x71374491, + 0xB5C0FBCF, + 0xE9B5DBA5, + 0x3956C25B, + 0x59F111F1, + 0x923F82A4, + 0xAB1C5ED5, + 0xD807AA98, + 0x12835B01, + 0x243185BE, + 0x550C7DC3, + 0x72BE5D74, + 0x80DEB1FE, + 0x9BDC06A7, + 0xC19BF174, + 0xE49B69C1, + 0xEFBE4786, + 0x0FC19DC6, + 0x240CA1CC, + 0x2DE92C6F, + 0x4A7484AA, + 0x5CB0A9DC, + 0x76F988DA, + 0x983E5152, + 0xA831C66D, + 0xB00327C8, + 0xBF597FC7, + 0xC6E00BF3, + 0xD5A79147, + 0x06CA6351, + 0x14292967, + 0x27B70A85, + 0x2E1B2138, + 0x4D2C6DFC, + 0x53380D13, + 0x650A7354, + 0x766A0ABB, + 0x81C2C92E, + 0x92722C85, + 0xA2BFE8A1, + 0xA81A664B, + 0xC24B8B70, + 0xC76C51A3, + 0xD192E819, + 0xD6990624, + 0xF40E3585, + 0x106AA070, + 0x19A4C116, + 0x1E376C08, + 0x2748774C, + 0x34B0BCB5, + 0x391C0CB3, + 0x4ED8AA4A, + 0x5B9CCA4F, + 0x682E6FF3, + 0x748F82EE, + 0x78A5636F, + 0x84C87814, + 0x8CC70208, + 0x90BEFFFA, + 0xA4506CEB, + 0xBEF9A3F7, + 0xC67178F2, +) +_SHA256_ADJUSTMENT = 0xC9DC06A7 +_SHA256_BLOCK_CONSTANTS = ( + _SHA256_ROUND_CONSTANTS[:14] + (_SHA256_ADJUSTMENT,) + _SHA256_ROUND_CONSTANTS[15:] +) +_SHA256_SIGMA0_ADJUSTMENT = 0x059C0B80 +_SHA256_SIGMA1_ADJUSTMENT = 0x000B92C0 + +_CS64_COEFFICIENTS = ( + (0xE82E91B1, 0xB32700D0), + (0x44DE24C6, 0x88DFF51D), + (0x3BEB65BC, 0x29514038), + (0xC89C5933, 0xB328CBDF), + (0x8D1EC211, 0xD405B4D5), + (0x314EF5D5, 0x26E55F3C), + (0xE92491B1, 0x607801C6), + (0xF0A5B9D5, 0x82B2C1FB), + (0x06064CF3, 0xFA51A3DA), + (0xDA79FA60, 0x207CC464), + (0xBED20560, 0x3C3C74F3), + (0x89F9C9F8, 0xD2D14B61), + (0x35FA38C6, 0xBB04A2C1), + (0xCB49E3C6, 0xC5626167), +) +_CS64_FACTOR = 0x82893933 +_CS64_ADJUSTMENT0 = 0xF2F90B1A +_CS64_ADJUSTMENT1 = 0x0B1A0000 + +_U32_MASK = 0xFFFFFFFF + + +def _rol16(value: int) -> int: + """Rotate a 32-bit value left by 16 bits.""" + value &= _U32_MASK + return ((value << 16) | (value >> 16)) & _U32_MASK + + +def _words_le4_even(data: bytes) -> List[int]: + """Split data into little-endian 32-bit words, zero-padded to an even count.""" + padded = data + b"\x00" * ((-len(data)) % 4) + if not padded: + return [] + words = list(struct.unpack(f"<{len(padded) // 4}I", padded)) + if len(words) % 2: + words.append(0) + return words + + +def _sigma0(value: int) -> int: + """SHA-256 small sigma zero, truncating the combined rotations once.""" + return ( + (value >> 7) ^ (value << 25) ^ (value >> 18) ^ (value << 14) ^ (value >> 3) + ) & _U32_MASK + + +def _sigma1(value: int) -> int: + """SHA-256 small sigma one, truncating the combined rotations once.""" + return ( + (value >> 17) ^ (value << 15) ^ (value >> 19) ^ (value << 13) ^ (value >> 10) + ) & _U32_MASK + + +def _sha256_rounds( + state: Tuple[int, ...], words: List[int], first_round: int = 0 +) -> Tuple[int, ...]: + """Run SHA-256 rounds, optionally resuming inside the first block.""" + a, b, c, d, e, f, g, h = state + mask = _U32_MASK + constants = ( + _SHA256_BLOCK_CONSTANTS if first_round == 14 else _SHA256_ROUND_CONSTANTS + ) + # Multiplying a 32-bit word by 2**32 + 1 repeats it in 64 bits. + # A right shift then has ROTR's low 32 bits, with no separate left shift. + # The final additions mask off the unused high bits. + aligned = min(64, ((first_round + 7) // 8) * 8) + for index in range(first_round, aligned): + rot_e = e * 0x100000001 + rot_a = a * 0x100000001 + t1 = ( + h + + ((rot_e >> 6) ^ (rot_e >> 11) ^ (rot_e >> 25)) + + (g ^ (e & (f ^ g))) + + constants[index] + + words[index] + ) + t2 = ((rot_a >> 2) ^ (rot_a >> 13) ^ (rot_a >> 22)) + ((a & b) | (c & (a | b))) + a, b, c, d, e, f, g, h = (t1 + t2) & mask, a, b, c, (d + t1) & mask, e, f, g + # Eight rounds return the working-register names to their original order. + # Rotate their roles in the expressions instead of moving eight values + # after every round; only the two updated registers need assignments. + for index in range(aligned, 64, 8): + rot_e = e * 0x100000001 + rot_a = a * 0x100000001 + t1 = ( + h + + ((rot_e >> 6) ^ (rot_e >> 11) ^ (rot_e >> 25)) + + (g ^ (e & (f ^ g))) + + constants[index] + + words[index] + ) + d = (d + t1) & mask + h = ( + t1 + + ((rot_a >> 2) ^ (rot_a >> 13) ^ (rot_a >> 22)) + + ((a & b) | (c & (a | b))) + ) & mask + rot_e = d * 0x100000001 + rot_a = h * 0x100000001 + t1 = ( + g + + ((rot_e >> 6) ^ (rot_e >> 11) ^ (rot_e >> 25)) + + (f ^ (d & (e ^ f))) + + constants[index + 1] + + words[index + 1] + ) + c = (c + t1) & mask + g = ( + t1 + + ((rot_a >> 2) ^ (rot_a >> 13) ^ (rot_a >> 22)) + + ((h & a) | (b & (h | a))) + ) & mask + rot_e = c * 0x100000001 + rot_a = g * 0x100000001 + t1 = ( + f + + ((rot_e >> 6) ^ (rot_e >> 11) ^ (rot_e >> 25)) + + (e ^ (c & (d ^ e))) + + constants[index + 2] + + words[index + 2] + ) + b = (b + t1) & mask + f = ( + t1 + + ((rot_a >> 2) ^ (rot_a >> 13) ^ (rot_a >> 22)) + + ((g & h) | (a & (g | h))) + ) & mask + rot_e = b * 0x100000001 + rot_a = f * 0x100000001 + t1 = ( + e + + ((rot_e >> 6) ^ (rot_e >> 11) ^ (rot_e >> 25)) + + (d ^ (b & (c ^ d))) + + constants[index + 3] + + words[index + 3] + ) + a = (a + t1) & mask + e = ( + t1 + + ((rot_a >> 2) ^ (rot_a >> 13) ^ (rot_a >> 22)) + + ((f & g) | (h & (f | g))) + ) & mask + rot_e = a * 0x100000001 + rot_a = e * 0x100000001 + t1 = ( + d + + ((rot_e >> 6) ^ (rot_e >> 11) ^ (rot_e >> 25)) + + (c ^ (a & (b ^ c))) + + constants[index + 4] + + words[index + 4] + ) + h = (h + t1) & mask + d = ( + t1 + + ((rot_a >> 2) ^ (rot_a >> 13) ^ (rot_a >> 22)) + + ((e & f) | (g & (e | f))) + ) & mask + rot_e = h * 0x100000001 + rot_a = d * 0x100000001 + t1 = ( + c + + ((rot_e >> 6) ^ (rot_e >> 11) ^ (rot_e >> 25)) + + (b ^ (h & (a ^ b))) + + constants[index + 5] + + words[index + 5] + ) + g = (g + t1) & mask + c = ( + t1 + + ((rot_a >> 2) ^ (rot_a >> 13) ^ (rot_a >> 22)) + + ((d & e) | (f & (d | e))) + ) & mask + rot_e = g * 0x100000001 + rot_a = c * 0x100000001 + t1 = ( + b + + ((rot_e >> 6) ^ (rot_e >> 11) ^ (rot_e >> 25)) + + (a ^ (g & (h ^ a))) + + constants[index + 6] + + words[index + 6] + ) + f = (f + t1) & mask + b = ( + t1 + + ((rot_a >> 2) ^ (rot_a >> 13) ^ (rot_a >> 22)) + + ((c & d) | (e & (c | d))) + ) & mask + rot_e = f * 0x100000001 + rot_a = b * 0x100000001 + t1 = ( + a + + ((rot_e >> 6) ^ (rot_e >> 11) ^ (rot_e >> 25)) + + (h ^ (f & (g ^ h))) + + constants[index + 7] + + words[index + 7] + ) + e = (e + t1) & mask + a = ( + t1 + + ((rot_a >> 2) ^ (rot_a >> 13) ^ (rot_a >> 22)) + + ((b & c) | (d & (b | c))) + ) & mask + return a, b, c, d, e, f, g, h + + +def _expand_sha256_schedule(words: List[int], first_word: int = 16) -> None: + """Expand SHA-256 words, combining rotations before modular addition.""" + mask = _U32_MASK + for index in range(first_word, 64): + x, y = words[index - 2], words[index - 15] + # Rotations can retain their high bits until the final sum is masked. + sigma1 = (x >> 17) ^ (x << 15) ^ (x >> 19) ^ (x << 13) ^ (x >> 10) + sigma0 = (y >> 7) ^ (y << 25) ^ (y >> 18) ^ (y << 14) ^ (y >> 3) + words.append((sigma1 + words[index - 7] + sigma0 + words[index - 16]) & mask) + + +@lru_cache(maxsize=1) +def _sha256_init(data: bytes) -> Tuple[int, ...]: + """Compute the initial SHA-256 state for token data.""" + mask = _U32_MASK + words = [0] * 14 + [int.from_bytes(data[:3], "big")] + words.append(int.from_bytes(data[3:7], "big")) + for index in range(16, 30): + value = _SHA256_SCHEDULE[index - 16] + if index - 2 >= 14: + value += ( + (_sigma1(words[index - 2]) ^ _SHA256_SIGMA1_ADJUSTMENT) + if index == 16 + else _sigma1(words[index - 2]) + ) + if index - 7 >= 14: + value += words[index - 7] + if index - 15 >= 14: + value += ( + (_sigma0(words[index - 15]) ^ _SHA256_SIGMA0_ADJUSTMENT) + if index == 29 + else _sigma0(words[index - 15]) + ) + words.append(value & mask) + words.append( + ( + _sigma1(words[28]) + + words[23] + + _sigma0(words[15]) + + words[14] + + _SHA256_ADJUSTMENT + - _SHA256_ROUND_CONSTANTS[14] + ) + & mask + ) + _expand_sha256_schedule(words, 31) + working = _sha256_rounds(_SHA256_STATE, words, 14) + return tuple((x + y) & mask for x, y in zip(_SHA256_INITIAL, working)) + + +def _sha256_digest(data: bytes) -> bytes: + """Compute the SHA-256 digest for token data.""" + if len(data) < 7: + raise ValueError("Token salt must contain at least seven bytes") + mask = _U32_MASK + length = 57 + len(data) + padded = data[7:] + b"\x80" + b"\x00" * ((55 - length) % 64) + padded += struct.pack(">Q", length * 8) + state = _sha256_init(data[:7]) + for offset in range(0, len(padded), 64): + words = list(struct.unpack(">16I", padded[offset : offset + 64])) + _expand_sha256_schedule(words) + working = _sha256_rounds(state, words) + state = tuple((x + y) & mask for x, y in zip(state, working)) + return struct.pack(">8I", *state) + + +def _cs64_digest(data: bytes, digest: bytes) -> Tuple[int, int]: + """Compute the CS64 digest words for token data.""" + mask = _U32_MASK + key_a, key_b = struct.unpack_from(" Tuple[str, str]: + """Compute the token hash components.""" + digest = _sha256_digest(data) + cs64 = struct.pack(" str: + """ + Generates a v2 Sec-MS-GEC token value. + + The token combines CS64 and SHA256 digests in the format + "<16 hex>-<64 hex>". + + Args: + unix_timestamp (float): Value to use instead of the current time, for + testing. + + Returns: + str: The generated v2 token value. + """ + if unix_timestamp is None: + ticks = DRM.get_unix_timestamp() + else: + ticks = float(unix_timestamp) + ticks += WIN_EPOCH + ticks -= ticks % 300 + ticks *= S_TO_NS / 100 + str_to_hash = f"{ticks:.0f}{TRUSTED_CLIENT_TOKEN}".encode("ascii") + cs64, sha256 = _token_hash(str_to_hash) + return f"{cs64}-{sha256}" + + +class DRM: + """ + Class to handle DRM operations with clock skew correction. + """ + + clock_skew_seconds: float = 0.0 + + @staticmethod + def adj_clock_skew_seconds(skew_seconds: float) -> None: + """ + Adjust the clock skew in seconds in case the system clock is off. + + This method updates the `clock_skew_seconds` attribute of the DRM class + to the specified number of seconds. + + Args: + skew_seconds (float): The number of seconds to adjust the clock skew to. + + Returns: + None + """ + DRM.clock_skew_seconds += skew_seconds + + @staticmethod + def get_unix_timestamp() -> float: + """ + Gets the current timestamp in Unix format with clock skew correction. + + Returns: + float: The current timestamp in Unix format with clock skew correction. + """ + return dt.now(tz.utc).timestamp() + DRM.clock_skew_seconds + + @staticmethod + def parse_rfc2616_date(date: str) -> Optional[float]: + """ + Parses an RFC 2616 date string into a Unix timestamp. + + This function parses an RFC 2616 date string into a Unix timestamp. + + Args: + date (str): RFC 2616 date string to parse. + + Returns: + Optional[float]: Unix timestamp of the parsed date string, or None if parsing failed. + """ + try: + return ( + dt.strptime(date, "%a, %d %b %Y %H:%M:%S %Z") + .replace(tzinfo=tz.utc) + .timestamp() + ) + except ValueError: + return None + + @staticmethod + def handle_client_response_error(e: aiohttp.ClientResponseError) -> None: + """ + Handle a client response error. + + This method adjusts the clock skew based on the server date in the response headers + and raises a SkewAdjustmentError if the server date is missing or invalid. + + Args: + e (Exception): The client response error to handle. + + Returns: + None + """ + if e.headers is None: + raise SkewAdjustmentError("No server date in headers.") from e + server_date: Optional[str] = e.headers.get("Date", None) + if server_date is None or not isinstance(server_date, str): + raise SkewAdjustmentError("No server date in headers.") from e + server_date_parsed: Optional[float] = DRM.parse_rfc2616_date(server_date) + if server_date_parsed is None or not isinstance(server_date_parsed, float): + raise SkewAdjustmentError( + f"Failed to parse server date: {server_date}" + ) from e + client_date = DRM.get_unix_timestamp() + DRM.adj_clock_skew_seconds(server_date_parsed - client_date) + + @staticmethod + def generate_sec_ms_gec() -> str: + """ + Generates the Sec-MS-GEC token value. + + The token is derived from the current time in Windows file time format, adjusted for + clock skew and rounded down to the nearest 5 minutes. See + generate_sec_ms_gec_v2 for the v2 digest format. + + Returns: + str: The generated Sec-MS-GEC token value. + + See Also: + https://github.com/rany2/edge-tts/issues/290#issuecomment-2464956570 + """ + + return generate_sec_ms_gec_v2() + + @staticmethod + def generate_muid() -> str: + """ + Generates a random MUID. + + Returns: + str: The generated MUID. + """ + return secrets.token_hex(16).upper() + + @staticmethod + def headers_with_muid(headers: Dict[str, str]) -> Dict[str, str]: + """ + Returns a copy of the given headers with the MUID header added. + + Args: + headers (dict): The original headers. + + Returns: + dict: The headers with the MUID header added. + """ + combined_headers = headers.copy() + assert "Cookie" not in combined_headers + combined_headers["Cookie"] = f"muid={DRM.generate_muid()};" + return combined_headers diff --git a/tests/sapi_smoke.cpp b/tests/sapi_smoke.cpp new file mode 100644 index 0000000..ab1eb2a --- /dev/null +++ b/tests/sapi_smoke.cpp @@ -0,0 +1,250 @@ +// Direct DLL loading and memory-only SAPI tokens: no registration/installation. +#include +#include +#include +#include +#include +#include +#include "../NaturalVoiceSAPIAdapter/SpeechServiceConstants.h" + +static HRESULT Copy(LPCWSTR s, LPWSTR* out) { + size_t bytes = (wcslen(s) + 1) * sizeof(wchar_t); + *out = (LPWSTR)CoTaskMemAlloc(bytes); + if (!*out) return E_OUTOFMEMORY; + memcpy(*out, s, bytes); + return S_OK; +} +class Key : public ISpDataKey { + LONG refs = 1; + unsigned kind; + LPCWSTR voice; + + public: + Key(unsigned k, LPCWSTR v) : kind(k), voice(v) {} + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, void** p) override { + *p = nullptr; + if (iid != IID_IUnknown && iid != __uuidof(ISpDataKey)) return E_NOINTERFACE; + *p = this; + AddRef(); + return S_OK; + } + ULONG STDMETHODCALLTYPE AddRef() override { return InterlockedIncrement(&refs); } + ULONG STDMETHODCALLTYPE Release() override { + ULONG n = InterlockedDecrement(&refs); + if (!n) delete this; + return n; + } + HRESULT STDMETHODCALLTYPE SetData(LPCWSTR, ULONG, const BYTE*) override { return E_ACCESSDENIED; } + HRESULT STDMETHODCALLTYPE GetData(LPCWSTR, ULONG*, BYTE*) override { return SPERR_NOT_FOUND; } + HRESULT STDMETHODCALLTYPE SetStringValue(LPCWSTR, LPCWSTR) override { return E_ACCESSDENIED; } + HRESULT STDMETHODCALLTYPE GetStringValue(LPCWSTR name, LPWSTR* out) override { + *out = nullptr; + if (!name || !*name) return Copy(L"Memory-only test voice", out); + if (kind == 1 && !_wcsicmp(name, L"Voice")) return Copy(voice, out); + if (kind == 1 && !_wcsicmp(name, L"WebsocketURL")) return Copy(EDGE_WEBSOCKET_URL, out); + if (kind == 2 && !_wcsicmp(name, L"Language")) return Copy(!wcsncmp(voice, L"zh-", 3) ? L"804" : L"409", out); + if (kind == 2 && !_wcsicmp(name, L"Locale")) return Copy(!wcsncmp(voice, L"zh-", 3) ? L"zh-CN" : L"en-US", out); + return SPERR_NOT_FOUND; + } + HRESULT STDMETHODCALLTYPE SetDWORD(LPCWSTR, DWORD) override { return E_ACCESSDENIED; } + HRESULT STDMETHODCALLTYPE GetDWORD(LPCWSTR name, DWORD* out) override { + if (kind == 1 && (!_wcsicmp(name, L"ErrorMode") || !_wcsicmp(name, L"IsEdgeVoice"))) { + *out = 1; + return S_OK; + } + return SPERR_NOT_FOUND; + } + HRESULT STDMETHODCALLTYPE OpenKey(LPCWSTR name, ISpDataKey** out) override { + *out = nullptr; + if (kind) return SPERR_NOT_FOUND; + unsigned k = !_wcsicmp(name, L"NaturalVoiceConfig") ? 1 : !_wcsicmp(name, L"Attributes") ? 2 : 0; + if (!k) return SPERR_NOT_FOUND; + *out = new Key(k, voice); + return S_OK; + } + HRESULT STDMETHODCALLTYPE CreateKey(LPCWSTR, ISpDataKey**) override { return E_ACCESSDENIED; } + HRESULT STDMETHODCALLTYPE DeleteKey(LPCWSTR) override { return E_ACCESSDENIED; } + HRESULT STDMETHODCALLTYPE DeleteValue(LPCWSTR) override { return E_ACCESSDENIED; } + HRESULT STDMETHODCALLTYPE EnumKeys(ULONG, LPWSTR*) override { return SPERR_NO_MORE_ITEMS; } + HRESULT STDMETHODCALLTYPE EnumValues(ULONG, LPWSTR*) override { return SPERR_NO_MORE_ITEMS; } +}; + +class Site : public ISpTTSEngineSite { + public: + ULONG bytes = 0, words = 0, sentences = 0, bookmarks = 0, visemes = 0; + bool nonzero = false; + ULONGLONG start = GetTickCount64(), abortAfter = 60000; + FILE* audio = nullptr; + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, void** p) override { + *p = nullptr; + if (iid != IID_IUnknown && iid != __uuidof(ISpTTSEngineSite) && iid != __uuidof(ISpEventSink)) + return E_NOINTERFACE; + *p = this; + return S_OK; + } + ULONG STDMETHODCALLTYPE AddRef() override { return 2; } + ULONG STDMETHODCALLTYPE Release() override { return 1; } + HRESULT STDMETHODCALLTYPE AddEvents(const SPEVENT* events, ULONG count) override { + for (ULONG i = 0; i < count; i++) { + auto& e = events[i]; + if (e.eEventId == SPEI_WORD_BOUNDARY) words++; + if (e.eEventId == SPEI_SENTENCE_BOUNDARY) sentences++; + if (e.eEventId == SPEI_TTS_BOOKMARK) bookmarks++; + if (e.eEventId == SPEI_VISEME) visemes++; + printf("event=%u audio=%llu text=%lld length=%llu\n", e.eEventId, e.ullAudioStreamOffset, + (long long)e.lParam, (unsigned long long)e.wParam); + } + return S_OK; + } + HRESULT STDMETHODCALLTYPE GetEventInterest(ULONGLONG* p) override { + *p = SPFEI_ALL_TTS_EVENTS; + return S_OK; + } + DWORD STDMETHODCALLTYPE GetActions() override { return GetTickCount64() - start >= abortAfter ? SPVES_ABORT : 0; } + HRESULT STDMETHODCALLTYPE Write(const void* p, ULONG n, ULONG* written) override { + *written = n; + bytes += n; + const BYTE* data = (const BYTE*)p; + for (ULONG i = 0; i < n; i++) + if (data[i]) { + nonzero = true; + break; + } + if (audio && fwrite(p, 1, n, audio) != n) return E_FAIL; + return S_OK; + } + HRESULT STDMETHODCALLTYPE GetRate(long* p) override { + *p = 0; + return S_OK; + } + HRESULT STDMETHODCALLTYPE GetVolume(USHORT* p) override { + *p = 100; + return S_OK; + } + HRESULT STDMETHODCALLTYPE GetSkipInfo(SPVSKIPTYPE*, long*) override { return E_NOTIMPL; } + HRESULT STDMETHODCALLTYPE CompleteSkip(long) override { return S_OK; } +}; + +static void Check(HRESULT hr, const char* action) { + if (FAILED(hr)) { + printf("FAIL: %s HRESULT=0x%08lX\n", action, (ULONG)hr); + ExitProcess(1); + } +} +int wmain(int argc, wchar_t** argv) { + if (argc < 3) { + puts("Usage: sapi_smoke DLL-path voice-name [abort-ms] [wav-path]"); + return 2; + } + Check(CoInitializeEx(nullptr, COINIT_MULTITHREADED), "COM init"); + HMODULE dll = LoadLibraryExW(argv[1], nullptr, LOAD_WITH_ALTERED_SEARCH_PATH); + if (!dll) { + printf("LoadLibrary error=%lu\n", GetLastError()); + return 1; + } + auto getClass = (HRESULT(WINAPI*)(REFCLSID, REFIID, void**))GetProcAddress(dll, "DllGetClassObject"); + if (!getClass) return 1; + CLSID cls; + Check(CLSIDFromString(L"{013ab33b-ad1a-401c-8bee-f6e2b046a94e}", &cls), "CLSID"); + CComPtr factory; + Check(getClass(cls, IID_PPV_ARGS(&factory)), "factory"); + CComPtr engine; + Check(factory->CreateInstance(nullptr, IID_PPV_ARGS(&engine)), "engine"); + CComPtr tokenInit; + CComPtr key; + CComPtr token; + bool local = !_wcsicmp(argv[2], L"local"); + if (local) { + CLSID enumCls; + Check(CLSIDFromString(L"{b8b9e38f-e5a2-4661-9fde-4ac7377aa6f6}", &enumCls), "enumerator CLSID"); + CComPtr enumFactory; + Check(getClass(enumCls, IID_PPV_ARGS(&enumFactory)), "enumerator factory"); + CComPtr voices; + Check(enumFactory->CreateInstance(nullptr, IID_PPV_ARGS(&voices)), "enumerate voices"); + ULONG count = 0; + Check(voices->GetCount(&count), "voice count"); + printf("Enumerated %lu tokens\n", count); + for (ULONG i = 0; i < count; i++) { + CComPtr candidate; + CComPtr config; + CSpDynamicString path; + Check(voices->Item(i, &candidate), "voice item"); + if (SUCCEEDED(candidate->OpenKey(L"NaturalVoiceConfig", &config)) && + SUCCEEDED(config->GetStringValue(L"Path", &path))) { + token = candidate; + wprintf(L"Local voice path: %ls\n", (LPCWSTR)path); + break; + } + } + if (!token) { + puts("SKIP: no local Narrator natural voice available"); + return 3; + } + } else { + Check(tokenInit.CoCreateInstance(CLSID_SpObjectToken), "token"); + key.Attach(new Key(0, argv[2])); + Check(tokenInit->InitFromDataKey(SPCAT_VOICES, L"MemoryOnlyNaturalVoiceTest", key), "memory token"); + Check(tokenInit->QueryInterface(&token), "token interface"); + } + CComQIPtr withToken(engine); + Check(withToken->SetObjectToken(token), "SetObjectToken"); + GUID format; + WAVEFORMATEX* wave = nullptr; + Check(engine->GetOutputFormat(nullptr, nullptr, &format, &wave), "format"); + printf("format: rate=%lu bits=%u channels=%u\n", wave->nSamplesPerSec, wave->wBitsPerSample, wave->nChannels); + const wchar_t* text = !wcsncmp(argv[2], L"zh-", 3) ? L"你好,世界。这是联网朗读测试。" + : L"Hello world. This is an online speech test."; + SPVTEXTFRAG frag = {}; + frag.State.eAction = SPVA_Speak; + frag.State.LangID = !wcsncmp(argv[2], L"zh-", 3) ? 0x804 : 0x409; + frag.State.Volume = 100; + frag.pTextStart = text; + frag.ulTextLen = (ULONG)wcslen(text); + SPVTEXTFRAG bookmark = {}; + bookmark.State = frag.State; + bookmark.State.eAction = SPVA_Bookmark; + bookmark.pTextStart = L"test-bookmark"; + bookmark.ulTextLen = 13; + bookmark.ulTextSrcOffset = frag.ulTextLen; + frag.pNext = &bookmark; + Site site; + if (argc > 3) site.abortAfter = wcstoul(argv[3], nullptr, 10); + if (argc > 4) { + _wfopen_s(&site.audio, argv[4], L"wb"); + if (!site.audio) return 1; + BYTE header[44] = {}; + fwrite(header, 1, 44, site.audio); + } + HRESULT hr = engine->Speak(0, format, wave, &frag, &site); + if (site.audio) { + DWORD riff = site.bytes + 36, fmt = 16; + rewind(site.audio); + fwrite("RIFF", 1, 4, site.audio); + fwrite(&riff, 4, 1, site.audio); + fwrite("WAVEfmt ", 1, 8, site.audio); + fwrite(&fmt, 4, 1, site.audio); + fwrite(wave, 1, 16, site.audio); + fwrite("data", 1, 4, site.audio); + fwrite(&site.bytes, 4, 1, site.audio); + fclose(site.audio); + } + printf( + "Speak HRESULT=0x%08lX bytes=%lu nonzero=%u words=%lu sentences=%lu bookmarks=%lu visemes=%lu elapsed=%llu " + "ms\n", + (ULONG)hr, site.bytes, site.nonzero, site.words, site.sentences, site.bookmarks, site.visemes, + GetTickCount64() - site.start); + bool cancelled = site.abortAfter < 60000; + bool pass = SUCCEEDED(hr) && + (cancelled ? GetTickCount64() - site.start < 10000 + : site.nonzero && site.words && (local || site.sentences) && site.bookmarks && site.visemes); + CoTaskMemFree(wave); + withToken.Release(); + engine.Release(); + factory.Release(); + token.Release(); + tokenInit.Release(); + key.Release(); + // Leave the DLL loaded until process exit: its workers use process detach cleanup. + CoUninitialize(); + return pass ? 0 : 1; +} diff --git a/tests/test_gec.py b/tests/test_gec.py new file mode 100644 index 0000000..810cfca --- /dev/null +++ b/tests/test_gec.py @@ -0,0 +1,26 @@ +"""Compare the compiled production C++ with pinned edge-tts 7.3.0.""" +import random +import subprocess +import sys +from pathlib import Path +root = Path(__file__).resolve().parent +source = (root / 'reference/drm.py').read_text() +source = source[:source.index('class DRM:')] +source = '\n'.join(line for line in source.splitlines() if not line.startswith(('import aiohttp','from .'))) +scope = {'TRUSTED_CLIENT_TOKEN': '6A5AA1D4EAFF4E9FB37E23D68491D6F4'} +exec(compile(source, 'edge-tts-7.3.0/drm.py', 'exec'), scope) +random.seed(730) +timestamps = [0, 1, 299, 300, 301, 1700000000, 1791964800, 2147483647] +timestamps += [random.randrange(0, 4000000000) for _ in range(300)] +ticks = [(t + 11644473600)*10000000 for t in timestamps] +# Exact Windows tick boundaries and the last tick before the next window. +ticks += [ticks[6]//3000000000*3000000000 + delta for delta in (-1,0,1,2999999999,3000000000)] +ticks += [0, 1, 2999999999, 3000000000, 18446744073709551615] +actual = subprocess.check_output([str(root/'gec_cli.exe'), *map(str,ticks)], text=True).splitlines() +for tick, value in zip(ticks, actual, strict=True): + # Compare the exact rounded FILETIME string, without Python float rounding. + data = f'{tick//3000000000*3000000000}{scope["TRUSTED_CLIENT_TOKEN"]}'.encode('ascii') + cs64, digest = scope['_token_hash'](data) + expected = f'{cs64}-{digest}' + assert value == expected, (tick,value,expected) +print(f'PASS: {len(ticks)} v2 tokens match edge-tts 7.3.0, including five-minute boundaries')