md2link

TTS Reader Pro — Reverse Engineering của Flow TTS

DraftApr 24, 2026

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.ipaPayload/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 diskhà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ằng AudioPlayer.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.voice required — else throw 'Voice must be specified'
  • voice phải có ít nhất 3 phần cách nhau bởi - (format lang-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:"([^"]+)"IG
  • data-iid="([^"]+)"IID
  • params_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 → arrayBufferToBase64 trả về
  • 401 → throw 'Authentication failed. Please try again.' và invalidate token (_closure1_slot14 = null)
  • 403 → throw với message server
  • 429 → 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ế

  1. 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.
  2. TrustedClientToken hardcode 6A5AA1D4EAFF4E9FB37E23D68491D6F4 — public token của extension Edge Read Aloud. Giống hệt các thư viện OSS edge-tts (Python) / ms-edge-tts (Node). Microsoft có thể revoke bất cứ lúc nào → app die.
  3. Giả mạo Origin chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold để qua check CORS. Nếu Microsoft bắt đầu validate nghiêm ngặt hơn → fail.
  4. 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.
  5. Cache key không bao gồm rate/pitch → một text + voice chỉ tạo một file MP3.
  6. Sec-MS-GEC-Version hardcode 1-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-tts library).
  7. outputFormat fixed audio-24khz-48kbitrate-mono-mp3 — không cho chọn chất lượng cao hơn mặc dù endpoint hỗ trợ.
  8. Fallback Bing Translator scrape HTML bằng regex → rất dễ break khi Microsoft đổi markup.
  9. 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.
  10. Không có retry với exponential backoff trên WS error — chỉ retry đúng 1 lần ở performEdgeTTSRequest catch block.
  11. 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


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.