TTS Reader Pro — Reverse Engineering của Flow TTS
App: TTS Reader Pro v0.3.10 (com.1letters.voice-reader-ai, App Store ID 6746346171)
Stack: React Native + Expo (Hermes bytecode v96)
Ngày phân tích: 2026-04-24
Artifact nguồn: TTS Reader Pro-0.3.10.ipa → Payload/TTSReaderVoiceAloudReader.app/main.jsbundle
Công cụ: hermes-dec (v0.1.3) để disassemble/decompile
1. Tóm tắt
App không tự host dịch vụ TTS. Toàn bộ audio được generate từ hai endpoint public của Microsoft (dùng chung token mà extension "Edge Read Aloud" dùng):
| Path | Endpoint | Khi nào dùng |
|---|---|---|
| Primary | wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1 |
Mặc định, mọi request |
| Fallback | https://www.bing.com/tfettts |
Khi WebSocket fail / primary bị rate limit |
App chỉ là client gói hai pattern đó lại (giống thư viện edge-tts Python / ms-edge-tts Node), cộng thêm cache MP3 trên disk và hàng đợi dedup để tiết kiệm request.
2. Kiến trúc tổng quan
┌────────────────┐ text ┌──────────────────────┐
│ UI / Reader │───────────────▶│ playTtsNew │
└────────────────┘ └──────────┬───────────┘
│
▼
┌──────────────────────────┐
│ getOrCreateCachedAudioFile│
│ (cacheDir/<hash>.mp3) │
└──────────┬───────────────┘
│ cache MISS
▼
┌──────────────────────────┐
│ createEdgeTTSAudio │
│ (dedup Map + queue) │
└──────────┬───────────────┘
▼
┌──────────────────────────┐
│ performEdgeTTSRequest │
└──────────┬───────────────┘
try │ catch
┌─────────────┘ └──────────────┐
▼ ▼
┌──────────────────────────┐ ┌───────────────────────────────┐
│ EdgeSpeechTTS.create │ │ performBingTranslatorTTSRequest│
│ (WebSocket Bing/Edge) │ │ (HTTPS POST tfettts) │
└──────────────┬───────────┘ └──────────────┬─────────────────┘
└──────────────┬─────────────────────┘
│ base64 MP3
▼
┌──────────────────────────┐
│ FileSystem.writeAsStringAsync │
│ → cacheDir/<hash>.mp3 │
└──────────┬───────────────┘
▼
┌──────────────────────────┐
│ expo-audio AudioPlayer │
│ (replace + play) │
└──────────────────────────┘
3. Hằng số quan trọng
Trích từ module EDGE_TTS_OPTIONS (disasm dòng ~834890–834945):
EDGE_SPEECH_URL = 'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1'
EDGE_API_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4' // TrustedClientToken public của Edge Read Aloud
VOICE_TTS_OPTIONS_DEFAULT = { platform: 'edge-ultra', voice: 'en-US-BrianMultilingualNeural' }
VOICE_TTS_FALLBACK_DEFAULT = { platform: 'edge', voice: 'en-US-BrianMultilingualNeural' }
Danh sách EDGE_TTS_ULTRA_OPTIONS chứa 170 giọng DragonHDLatestNeural / DragonHDOmniLatestNeural / MultilingualNeural (en-US, en-GB, fr-FR, fr-BE, fr-CA, fr-CH, de-DE, de-AT, de-CH, es-ES, es-MX, it-IT, v.v.).
4. Các hàm chính & vị trí
Tất cả tham chiếu là dòng trong disasm/main.decomp.js (output của hbc-decompiler).
| Hàm | Dòng | Vai trò |
|---|---|---|
playTtsNew / _playTtsNew |
1090062 / 1090072 | Entry point khi user bấm play |
resolveEdgeVoiceSetting |
1088516 | Đọc voice từ settingDb$.voice, fallback default |
buildBaseTtsCacheKey |
1088537 | Hash {lang, text, voice, platform, rate:1, pitch:0} |
getOrCreateCachedAudioFile |
1089460 | Kiểm tra <cacheDir>/<hash>.mp3, generate nếu miss |
getEdgeTtsAudio |
1089357 | Wrapper gọi createEdgeTTSAudio |
createEdgeTTSAudio / _createEdgeTTSAudio |
1100312 / 1099417 | Dedup bằng Map + push vào queue |
processQueue |
1099325 | Concurrency limiter |
performEdgeTTSRequest / _performEdgeTTSRequest |
1100185 / 1100196 | Try Edge → catch → fallback |
EdgeSpeechTTS.create |
1110248 | Mở WebSocket tới Bing, stream MP3 |
genSSMLWithPitch |
1110157 | Build SSML từ text + voice |
getBingAccessToken / _getBingAccessToken |
1099570 / 1099581 | Scrape IG/IID/token từ trang Bing Translator |
performBingTranslatorTTSRequest / _performBingTranslatorTTSRequest |
1099752 / 1099763 | Fallback qua tfettts |
ensureMainPlayer |
1088765 | createAudioPlayer() lazy, reuse |
playAudioFile / _playAudioFile |
1089673 / 1089684 | player.replace() + play() |
ensureAudioModeConfigured |
1088621 | Expo-audio session setup |
5. Flow chi tiết
5.1 User bấm Play → playTtsNew
Signature (reconstructed): playTtsNew(text, speed=1, lang='', prompt='language-tutor', voiceOverride, opts={}).
const voice = resolveEdgeVoiceSetting(voiceOverride)
// → { platform: 'edge-ultra'|'edge', voice: 'en-US-BrianMultilingualNeural', ... }
audioDb$.audio.isProcessing.set(true)
audioDb$.audio.lastPlayedSegment.set({
text, voicePlatform: voice.platform, voiceName: voice.voice,
languageText: lang, playbackRate: speed, prompt
})
trackEvent(EVENTS.PLAY_START, {
voice: voice.voice || 'default',
platform: voice.platform || 'default',
speed, lang: lang || 'auto'
})
const net = await NetInfo.fetch()
if (!net.isConnected && !(await isAudioAvailable())) {
Toast.show({ type:'error', text1:'Audio Error',
text2:'Audio playback is not available on this platform.' })
return
}
const audioUri = await getOrCreateCachedAudioFile(text, voice, lang, prompt)
if (!audioUri) throw new Error('Failed to prepare TTS audio file')
const player = ensureMainPlayer()
await stopPlayer(player, { resetPosition: true })
await playAudioFile(player, audioUri, speed, {
lockScreenTitle: text.slice(0, 80) || 'TTS Segment'
})
5.2 Cache layer — getOrCreateCachedAudioFile(text, voice, lang, prompt)
const key = buildBaseTtsCacheKey(text, voice, lang)
// = hashPayload({ lang: lang||'en-US', text, voice: voice.voice,
// platform: voice.platform, rate: 1, pitch: 0 })
const path = FileSystem.cacheDirectory + key + '.mp3'
if (await fileExists(path)) return path // HIT
const base64Mp3 = await getEdgeTtsAudio(text, voice, lang, prompt, /*rate*/1)
if (!base64Mp3) return null
await FileSystem.writeAsStringAsync(path, base64Mp3, {
encoding: FileSystem.EncodingType.Base64
})
return path
Log lỗi: 'Failed to cache TTS audio file'.
5.3 Dedup + queue — createEdgeTTSAudio(payload)
const k = hashPayload(payload)
if (inflightMap.has(k)) return inflightMap.get(k)
const promise = new Promise((resolve, reject) => {
queue.push(async () => {
try {
const result = await performEdgeTTSRequest(payload)
resolve(result)
} catch (err) {
console.error('Error in Edge TTS request:', err)
reject(err)
} finally {
inflightMap.delete(k)
}
})
processQueue()
})
inflightMap.set(k, promise)
return promise
processQueue giới hạn concurrent (slot9 = inflight count, slot10 = max). Cùng một (text, voice, lang) được request song song chỉ sinh một WS.
5.4 performEdgeTTSRequest — Try Edge, fallback Bing
try {
const r = await EdgeSpeechTTS.create(payload)
if (audioDb$.audio.errorState.peek())
setAudioDb({ errorState: null })
return r
} catch (err) {
logger.warn('EdgeSpeechTTS failed, falling back to Bing Translator API:', err)
const msg = (err instanceof Error) ? err.message : 'Unknown error'
const errState = msg.includes('Rate limit exceeded')
? 'Ultra Voice is rate limited. Use Fallback Voice in Settings'
: 'Ultra Voice issue. Use Fallback Voice in Settings'
setAudioDb({ errorState: errState })
return await EdgeSpeechTTS.create(payload) // retry; nhánh khác nối vào performBingTranslatorTTSRequest
}
5.5 EdgeSpeechTTS.create(payload) — WebSocket
Build URL
const connectionId = uuidv4().replace(/-/g, '') // cũng dùng làm X-RequestId
const secMsGec = await genSecMSGEC() // token đồng bộ theo đồng hồ Edge
const qs = new URLSearchParams({
ConnectionId: connectionId,
TrustedClientToken: '6A5AA1D4EAFF4E9FB37E23D68491D6F4',
'Sec-MS-GEC': secMsGec,
'Sec-MS-GEC-Version':'1-143.0.3650.75'
})
const url = 'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?' + qs
Handshake headers
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
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: en-US,en;q=0.9
Pragma: no-cache
Cache-Control: no-cache
Origin: chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold
Sec-WebSocket-Version: 13
Cookie: muid=<randomHex>;
Origin giả là extension ID thật của Edge Read Aloud — nhằm qua kiểm tra CORS/origin phía Microsoft.
ws.binaryType = 'arraybuffer'
Message #1 — speech.config (text frame)
X-Timestamp:<ISO Date>
Content-Type:application/json; charset=utf-8
Path:speech.config
{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":false,"wordBoundaryEnabled":true},"outputFormat":"audio-24khz-48kbitrate-mono-mp3"}}}}
Output format cố định (không configurable): audio-24khz-48kbitrate-mono-mp3 (MP3 mono 24 kHz, 48 kbps).
Message #2 — SSML (text frame)
X-RequestId:<connectionId>
X-Timestamp:<ISO Date>
Content-Type:application/ssml+xml
Path:ssml
<speak version='1.0' xml:lang='en-US'><voice name='en-US-BrianMultilingualNeural'>Hello world</voice></speak>
Build bởi genSSMLWithPitch(_, text, voice, rate, pitch):
- Strip leading
<break>tag:text.replace(/^<break\b[^>]*>/i, '') - Escape
& < > " 'sang entity tương ứng - Clamp
rate ∈ [0.1, 5],pitch ∈ [0.1, 2], quy đổi sang phần trăm và clamp lần hai[-200, 200]/[0, 100] - Ghép chuỗi:
<speak version='1.0' xml:lang='en-US'><voice name='${voice}'>${escaped}</voice></speak>
Đáng chú ý: các giá trị rate/pitch đã được tính toán nhưng không được emit vào
<prosody>tag ở bytecode cuối. Thay đổi tốc độ thực tế xảy ra ở client bằngAudioPlayer.setPlaybackRate(speed)khi phát — nên đổi speed không cần re-generate MP3, chỉ replay từ cache.
Nhận response — onmessage
Server stream kiểu MIME-multipart-over-WS. Mỗi frame có thể là:
if (typeof data === 'string') {
const {headers, body} = parseHeadersAndBody(data)
if (headers.Path === 'turn.end') {
ws.close()
if (accumulated.byteLength === 0)
reject(new Error('No audio data received.'))
else
resolve(Buffer.from(accumulated).toString('base64'))
}
// các Path khác: 'turn.start', 'response', 'audio.metadata' — ignore
} else if (data instanceof ArrayBuffer) {
// Frame binary:
// [0..1] big-endian uint16 = độ dài header text
// [2..2+len] header text (Path: audio, v.v.)
// [rest] MP3 payload chunk
const headerLen = new DataView(data).getInt16(0)
if (data.byteLength > 2 + headerLen) {
const mp3Chunk = data.slice(2 + headerLen)
const merged = new Uint8Array(accumulated.byteLength + mp3Chunk.byteLength)
merged.set(new Uint8Array(accumulated), 0)
merged.set(new Uint8Array(mp3Chunk), accumulated.byteLength)
accumulated = merged.buffer
}
}
onerror / onclose
ws.onerror = () => {
ws.close()
reject(new Error('WebSocket error occurred.'))
}
ws.onclose = () => { /* byteLength check only, no reject */ }
Kết quả trả về: chuỗi base64 của MP3 24 kHz 48 kbps mono.
5.6 Fallback — performBingTranslatorTTSRequest(payload)
Chỉ kích hoạt khi EdgeSpeechTTS.create fail dai dẳng.
Validation:
payload.text.trim().length > 0— else throw'Text cannot be empty'payload.voicerequired — else throw'Voice must be specified'voicephải có ít nhất 3 phần cách nhau bởi-(formatlang-REGION-VoiceNeural) — else throw'Invalid voice format: … Expected format: lang-REGION-VoiceNeural'text.trim().length ≤ 10000— else throw'Text too long. Maximum length is 10,000 characters.'
Step 1 — getBingAccessToken()
Cache trong closure (_closure1_slot14). Refresh khi Date.now() - tokenTs > tokenExpiryInterval.
GET https://www.bing.com/translator
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/120.0.0.0 Safari/537.36
Accept: */*
Accept-Language: en-US,en;q=0.9
Origin: https://www.bing.com
Referer: https://www.bing.com/translator
sec-ch-ua: "Not_A Brand";v="8", "Chromium";v="120"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
sec-fetch-dest: empty
sec-fetch-mode: cors
sec-fetch-site: same-origin
Parse HTML bằng regex:
IG:"([^"]+)"→IGdata-iid="([^"]+)"→IIDparams_AbusePreventionHelper\s?=\s?([^\]]+\])→JSON.parse→[key, token, tokenExpiryInterval]
Store: { IG, IID, key, token, tokenTs: Date.now(), tokenExpiryInterval, count: 0 }.
Lỗi: nếu không parse đủ 3 field → throw 'Could not extract required tokens from Bing translator page'. Fetch lỗi → 'Failed to get Bing access token'.
Step 2 — POST tfettts
POST https://www.bing.com/tfettts?IG=<IG>&IID=<IID>.<count>&isVertical=1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 ... Chrome/120.0.0.0 ...
Origin: https://www.bing.com
Referer: https://www.bing.com/translator
sec-ch-ua: "Not_A Brand";v="8", "Chromium";v="120"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
sec-fetch-dest: empty
sec-fetch-mode: cors
sec-fetch-site: same-origin
ssml=<speak version='1.0' xml:lang='en-US'><voice name='en-US-BrianNeural'>...</voice></speak>
&token=<token>
&key=<key>
count tăng dần mỗi request (IID.0, IID.1, …) để né rate-limit — giống extension chính chủ.
Status codes:
200→ body binary MP3 →arrayBufferToBase64trả về401→ throw'Authentication failed. Please try again.'và invalidate token (_closure1_slot14 = null)403→ throw với message server429→ throw'Rate limit exceeded. Please try again later.'
5.7 Phát audio — playAudioFile(player, uri, speed, opts)
await ensureAudioModeConfigured()
// expo-audio: { playsInSilentMode: true, shouldPlayInBackground: true,
// interruptionMode: 'doNotMix', shouldRouteThroughEarpiece: false }
player.replace({ uri })
player.setPlaybackRate(speed)
player.play()
updateNowPlayingInfo({ title: opts.lockScreenTitle, ... })
// cập nhật lock-screen / control center
Listener playbackStatusUpdate (attach 1 lần trong ensureMainPlayer):
didJustFinish === true→ advance sang câu kế tiếp (_closure1_slot55) nếu có, reset processing flags.
6. Sơ đồ tuần tự (sequence)
User UI playTtsNew Cache Edge WS expo-audio
│ tap │ │ │ │ │
│─────────▶│ │ │ │ │
│ │── play(text) ─▶│ │ │ │
│ │ │── hash ──▶│ │ │
│ │ │ │ MISS │ │
│ │ │ │ │ │
│ │ │────── open WS ───────▶│ │
│ │ │ │ │ │
│ │ │──── speech.config ───▶│ │
│ │ │──── SSML ────────────▶│ │
│ │ │ │ │
│ │ │◀─── turn.start ───────│ │
│ │ │◀─── audio chunk 1 ────│ │
│ │ │◀─── audio chunk 2 ────│ │
│ │ │◀─── ... ─────│ │
│ │ │◀─── turn.end ─────────│ │
│ │ │── close WS ──────────▶│ │
│ │ │ │
│ │ │── base64 ────▶writeAsStringAsync(.mp3) │
│ │ │ │
│ │ │─────────── player.replace(uri) ────────────▶│
│ │ │─────────── player.play() ──────────────────▶│
│ │ │ │── audio ──▶ 🔊
│ │ │ │
│ │ │◀──── didJustFinish ─────────────────────────│
│ │ │── advance next sentence │
7. Điểm đáng chú ý / lỗ hổng thiết kế
- Không có backend TTS riêng của app. Mọi audio đến trực tiếp từ Microsoft. App đang free-ride trên Edge Read Aloud.
TrustedClientTokenhardcode6A5AA1D4EAFF4E9FB37E23D68491D6F4— public token của extension Edge Read Aloud. Giống hệt các thư viện OSSedge-tts(Python) /ms-edge-tts(Node). Microsoft có thể revoke bất cứ lúc nào → app die.- Giả mạo
Originchrome-extension://jdiccldimpdaibmpdkjnbmckianbfoldđể qua check CORS. Nếu Microsoft bắt đầu validate nghiêm ngặt hơn → fail. - Rate/pitch không được truyền vào SSML. Chỉ dùng playback rate phía client — giới hạn độ tự nhiên ở các tốc độ cao, nhưng ngược lại chia sẻ được cache giữa các speed.
- Cache key không bao gồm rate/pitch → một text + voice chỉ tạo một file MP3.
Sec-MS-GEC-Versionhardcode1-143.0.3650.75. Khi Edge release major mới, Microsoft thường reject version cũ → cần update token version (giá trị này thay đổi thường xuyên trong lịch sửedge-ttslibrary).outputFormatfixedaudio-24khz-48kbitrate-mono-mp3— không cho chọn chất lượng cao hơn mặc dù endpoint hỗ trợ.- Fallback Bing Translator scrape HTML bằng regex → rất dễ break khi Microsoft đổi markup.
- Queue concurrency có giới hạn nhưng giá trị (
_closure1_slot10) không reveal rõ trong disasm; nếu limit thấp thì prefetch nhiều câu sẽ chậm, nếu cao thì rate-limit nhanh. - Không có retry với exponential backoff trên WS error — chỉ retry đúng 1 lần ở
performEdgeTTSRequestcatch block. - Không có TLS pinning — request từ app có thể được MITM dễ dàng để inspect/modify.
8. Dịch vụ & credential phụ trợ (không phải TTS)
Các service bên ngoài khác khi làm việc trong code path TTS:
| Service | Mục đích | Credential/config |
|---|---|---|
| PostHog (EU + US) | trackEvent(PLAY_START) |
phc_4daPMtsIxEP7AOCYF8FaX54Ohs69AQ1lvizZqpYybrTentar, phc_qfXGHLk4X4KfFGRJLJNVd8ExCcsLkKcho4xQFmSRtoSVG (trong JS bundle) |
| Firebase | Crashlytics / RemoteConfig | Project voice-reader-ai, iOS API key AIzaSyChDkSxA0rf0x31CqLM7gcgwJEz-nuqm4w (GoogleService-Info.plist) |
| RevenueCat | Subscription gating (audioDb$.isPro) |
appl_ylEsQBNGVRaHMfZyUXtatbHTjy |
| Cloudflare Worker | Auth / user flow (không liên quan TTS trực tiếp) | worker-voice-reader.voice-reader.workers.dev |
| InstantDB | Sync settings (voice, library) | api.instantdb.com, DB name setting-db-2 |
| OneSignal | Push notification | Bundled frameworks |
| Microsoft Clarity | Session replay | Clarity.framework |
9. Lệnh dùng để reproduce
# Extract ipa
unzip "TTS Reader Pro-0.3.10.ipa" -d extracted/
# Install hermes-dec
pip3 install --user --break-system-packages hermes-dec
export PATH="$HOME/Library/Python/3.14/bin:$PATH"
# Disassemble + decompile
hbc-disassembler extracted/Payload/TTSReaderVoiceAloudReader.app/main.jsbundle disasm/main.hasm
hbc-decompiler extracted/Payload/TTSReaderVoiceAloudReader.app/main.jsbundle disasm/main.decomp.js
# Tìm TTS logic
grep -n "speech.platform.bing.com\|EdgeSpeechTTS\|performEdgeTTSRequest\|buildBaseTtsCacheKey\|playTtsNew" disasm/main.decomp.js
10. Tham khảo
edge-tts(Python) — cùng protocol, dễ đối chiếu request formatms-edge-tts(Node) — reference JS client- Hermes bytecode format
hermes-dec— tool dùng để decompile
Tài liệu này được tạo từ phân tích tĩnh Hermes bytecode v96 của main.jsbundle. Không có traffic thực tế được capture; tất cả chi tiết giao thức (headers, message format, error messages) đều trích xuất từ bytecode decompiled.