# 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):

```js
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={})`.

```js
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)`

```js
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)`

```js
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

```js
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

```js
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

```http
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.

```js
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à:

```js
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`

```js
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`.

```http
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`

```http
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)`

```js
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

```bash
# 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)](https://github.com/rany2/edge-tts) — cùng protocol, dễ đối chiếu request format
- [`ms-edge-tts` (Node)](https://github.com/Migushthe2nd/MsEdgeTTS) — reference JS client
- [Hermes bytecode format](https://github.com/facebook/hermes/blob/main/docs/BytecodeFormat.md)
- [`hermes-dec`](https://github.com/P1sec/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.*
