@tiktool/live 2.6.1 → 2.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ TikTokCaptions: () => TikTokCaptions,
33
34
  TikTokLive: () => TikTokLive,
34
35
  getRanklist: () => getRanklist,
35
36
  getRegionalRanklist: () => getRegionalRanklist
@@ -335,8 +336,10 @@ function parseBattleTeamFromArmies(itemBuf) {
335
336
  const userBufs = getAllBytes(gf, 1);
336
337
  for (const ub of userBufs) {
337
338
  try {
339
+ const userFields = decodeProto(ub);
340
+ const individualScore = getInt(userFields, 2);
338
341
  const user = parseUser(ub);
339
- users.push({ user, score: points });
342
+ users.push({ user, score: individualScore });
340
343
  } catch {
341
344
  }
342
345
  }
@@ -1167,6 +1170,447 @@ var TikTokLive = class extends import_events.EventEmitter {
1167
1170
  }
1168
1171
  };
1169
1172
 
1173
+ // src/captions.ts
1174
+ var import_events2 = require("events");
1175
+ var import_ws2 = __toESM(require("ws"));
1176
+ var FLV_TAG_AUDIO = 8;
1177
+ var FLV_HEADER_SIZE = 9;
1178
+ var FLV_PREV_TAG_SIZE = 4;
1179
+ var FlvAudioExtractor = class {
1180
+ buffer = new Uint8Array(0);
1181
+ headerParsed = false;
1182
+ onAudio;
1183
+ aacProfile = 2;
1184
+ sampleRateIndex = 4;
1185
+ channelConfig = 2;
1186
+ ascParsed = false;
1187
+ constructor(onAudio) {
1188
+ this.onAudio = onAudio;
1189
+ }
1190
+ parseASC(asc) {
1191
+ if (asc.length < 2) return;
1192
+ this.aacProfile = asc[0] >> 3 & 31;
1193
+ this.sampleRateIndex = (asc[0] & 7) << 1 | asc[1] >> 7 & 1;
1194
+ this.channelConfig = asc[1] >> 3 & 15;
1195
+ this.ascParsed = true;
1196
+ }
1197
+ buildAdtsHeader(frameLength) {
1198
+ const adts = new Uint8Array(7);
1199
+ const fullLength = frameLength + 7;
1200
+ const profile = this.aacProfile - 1;
1201
+ adts[0] = 255;
1202
+ adts[1] = 241;
1203
+ adts[2] = (profile & 3) << 6 | (this.sampleRateIndex & 15) << 2 | this.channelConfig >> 2 & 1;
1204
+ adts[3] = (this.channelConfig & 3) << 6 | fullLength >> 11 & 3;
1205
+ adts[4] = fullLength >> 3 & 255;
1206
+ adts[5] = (fullLength & 7) << 5 | 31;
1207
+ adts[6] = 252;
1208
+ return adts;
1209
+ }
1210
+ push(chunk) {
1211
+ const newBuf = new Uint8Array(this.buffer.length + chunk.length);
1212
+ newBuf.set(this.buffer, 0);
1213
+ newBuf.set(chunk, this.buffer.length);
1214
+ this.buffer = newBuf;
1215
+ if (!this.headerParsed) {
1216
+ if (this.buffer.length < FLV_HEADER_SIZE + FLV_PREV_TAG_SIZE) return;
1217
+ if (this.buffer[0] !== 70 || this.buffer[1] !== 76 || this.buffer[2] !== 86) return;
1218
+ const dv = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
1219
+ const dataOffset = dv.getUint32(5);
1220
+ this.buffer = this.buffer.subarray(dataOffset + FLV_PREV_TAG_SIZE);
1221
+ this.headerParsed = true;
1222
+ }
1223
+ while (this.buffer.length >= 11) {
1224
+ const tagType = this.buffer[0] & 31;
1225
+ const dataSize = this.buffer[1] << 16 | this.buffer[2] << 8 | this.buffer[3];
1226
+ const totalTagSize = 11 + dataSize + FLV_PREV_TAG_SIZE;
1227
+ if (this.buffer.length < totalTagSize) break;
1228
+ if (tagType === FLV_TAG_AUDIO) {
1229
+ const audioData = this.buffer.subarray(11, 11 + dataSize);
1230
+ if (audioData.length > 0) {
1231
+ const soundFormat = audioData[0] >> 4 & 15;
1232
+ if (soundFormat === 10 && audioData.length > 2) {
1233
+ const aacPacketType = audioData[1];
1234
+ if (aacPacketType === 0) {
1235
+ this.parseASC(audioData.subarray(2));
1236
+ } else if (aacPacketType === 1 && this.ascParsed) {
1237
+ const rawFrame = audioData.subarray(2);
1238
+ const adtsHeader = this.buildAdtsHeader(rawFrame.length);
1239
+ const adtsFrame = new Uint8Array(adtsHeader.length + rawFrame.length);
1240
+ adtsFrame.set(adtsHeader, 0);
1241
+ adtsFrame.set(rawFrame, adtsHeader.length);
1242
+ this.onAudio(adtsFrame);
1243
+ }
1244
+ }
1245
+ }
1246
+ }
1247
+ this.buffer = this.buffer.subarray(totalTagSize);
1248
+ }
1249
+ }
1250
+ reset() {
1251
+ this.buffer = new Uint8Array(0);
1252
+ this.headerParsed = false;
1253
+ this.ascParsed = false;
1254
+ }
1255
+ };
1256
+ var DEFAULT_CAPTIONS_SERVER = "wss://api.tik.tools";
1257
+ var TikTokCaptions = class extends import_events2.EventEmitter {
1258
+ ws = null;
1259
+ _connected = false;
1260
+ intentionalClose = false;
1261
+ reconnectAttempts = 0;
1262
+ uniqueId;
1263
+ apiKey;
1264
+ serverUrl;
1265
+ autoReconnect;
1266
+ maxReconnectAttempts;
1267
+ debug;
1268
+ _translate;
1269
+ _diarization;
1270
+ _maxDurationMinutes;
1271
+ _language;
1272
+ streamAbortController = null;
1273
+ flvExtractor = null;
1274
+ streamUrl = null;
1275
+ constructor(options) {
1276
+ super();
1277
+ this.uniqueId = options.uniqueId.replace(/^@/, "");
1278
+ if (!options.apiKey) throw new Error("apiKey is required. Get a free key at https://tik.tools");
1279
+ this.apiKey = options.apiKey;
1280
+ this._language = options.language || "";
1281
+ this._translate = options.translate || "";
1282
+ this._diarization = options.diarization ?? true;
1283
+ this._maxDurationMinutes = options.maxDurationMinutes ?? 60;
1284
+ this.serverUrl = (options.signServerUrl || DEFAULT_CAPTIONS_SERVER).replace(/\/$/, "");
1285
+ this.autoReconnect = options.autoReconnect ?? true;
1286
+ this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
1287
+ this.debug = options.debug ?? false;
1288
+ }
1289
+ /**
1290
+ * Start real-time captions for the configured TikTok user.
1291
+ * Connects to the captions WebSocket relay and begins transcription
1292
+ * once the user goes live (or immediately if already live).
1293
+ */
1294
+ async start() {
1295
+ this.intentionalClose = false;
1296
+ const wsUrl = this.buildWsUrl();
1297
+ if (this.debug) console.log(`[Captions] Connecting to ${wsUrl}`);
1298
+ return new Promise((resolve, reject) => {
1299
+ this.ws = new import_ws2.default(wsUrl);
1300
+ this.ws.on("open", () => {
1301
+ this._connected = true;
1302
+ this.reconnectAttempts = 0;
1303
+ if (this.debug) console.log("[Captions] Connected");
1304
+ this.emit("connected");
1305
+ resolve();
1306
+ });
1307
+ this.ws.on("message", (data) => {
1308
+ this.handleMessage(typeof data === "string" ? data : data.toString());
1309
+ });
1310
+ this.ws.on("close", (code, reason) => {
1311
+ this._connected = false;
1312
+ const reasonStr = reason?.toString() || "";
1313
+ if (this.debug) console.log(`[Captions] Disconnected: ${code} ${reasonStr}`);
1314
+ this.emit("disconnected", code, reasonStr);
1315
+ if (!this.intentionalClose && this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
1316
+ this.reconnectAttempts++;
1317
+ const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts - 1), 3e4);
1318
+ if (this.debug) console.log(`[Captions] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
1319
+ setTimeout(() => this.start().catch((e) => this.emit("error", { code: "RECONNECT_FAILED", message: e.message })), delay);
1320
+ }
1321
+ });
1322
+ this.ws.on("error", (err) => {
1323
+ this.emit("error", { code: "WS_ERROR", message: err.message });
1324
+ if (!this._connected) reject(err);
1325
+ });
1326
+ });
1327
+ }
1328
+ /**
1329
+ * Stop captions and disconnect.
1330
+ */
1331
+ stop() {
1332
+ this.intentionalClose = true;
1333
+ if (this.streamAbortController) {
1334
+ this.streamAbortController.abort();
1335
+ this.streamAbortController = null;
1336
+ }
1337
+ if (this.flvExtractor) {
1338
+ this.flvExtractor.reset();
1339
+ this.flvExtractor = null;
1340
+ }
1341
+ if (this.ws) {
1342
+ this.send({ action: "stop" });
1343
+ this.ws.close(1e3);
1344
+ this.ws = null;
1345
+ }
1346
+ this._connected = false;
1347
+ }
1348
+ /**
1349
+ * Switch the translation target language on-the-fly.
1350
+ * Causes a brief interruption while the transcription engine reconfigures.
1351
+ */
1352
+ setLanguage(language) {
1353
+ this._language = language;
1354
+ this.send({ action: "set_language", language });
1355
+ }
1356
+ /**
1357
+ * Request a credit balance update from the server.
1358
+ */
1359
+ getCredits() {
1360
+ this.send({ action: "get_credits" });
1361
+ }
1362
+ /** Whether the WebSocket is currently connected */
1363
+ get connected() {
1364
+ return this._connected;
1365
+ }
1366
+ /** The current target language */
1367
+ get language() {
1368
+ return this._language;
1369
+ }
1370
+ on(event, listener) {
1371
+ return super.on(event, listener);
1372
+ }
1373
+ once(event, listener) {
1374
+ return super.once(event, listener);
1375
+ }
1376
+ off(event, listener) {
1377
+ return super.off(event, listener);
1378
+ }
1379
+ emit(event, ...args) {
1380
+ return super.emit(event, ...args);
1381
+ }
1382
+ buildWsUrl() {
1383
+ const base = this.serverUrl.replace(/^http/, "ws");
1384
+ const params = new URLSearchParams({
1385
+ uniqueId: this.uniqueId,
1386
+ apiKey: this.apiKey
1387
+ });
1388
+ if (this._language) params.set("language", this._language);
1389
+ if (this._translate) params.set("translate", this._translate);
1390
+ if (this._diarization !== void 0) params.set("diarization", String(this._diarization));
1391
+ if (this._maxDurationMinutes) params.set("max_duration_minutes", String(this._maxDurationMinutes));
1392
+ return `${base}/captions?${params}`;
1393
+ }
1394
+ send(msg) {
1395
+ if (this.ws?.readyState === import_ws2.default.OPEN) {
1396
+ this.ws.send(JSON.stringify(msg));
1397
+ }
1398
+ }
1399
+ handleMessage(raw) {
1400
+ try {
1401
+ const msg = JSON.parse(raw);
1402
+ switch (msg.type) {
1403
+ case "stream_info":
1404
+ if (this.debug) console.log(`[Captions] Received stream_info: flv=${!!msg.flvUrl}, hls=${!!msg.hlsUrl}, ao=${!!msg.audioOnlyUrl}`);
1405
+ this.connectToStream(msg);
1406
+ break;
1407
+ case "caption":
1408
+ this.emit("caption", {
1409
+ text: msg.text,
1410
+ language: msg.language,
1411
+ isFinal: msg.isFinal,
1412
+ confidence: msg.confidence,
1413
+ speaker: msg.speaker,
1414
+ startMs: msg.startMs,
1415
+ endMs: msg.endMs
1416
+ });
1417
+ break;
1418
+ case "translation":
1419
+ this.emit("translation", {
1420
+ text: msg.text,
1421
+ language: msg.language,
1422
+ isFinal: msg.isFinal,
1423
+ confidence: msg.confidence,
1424
+ speaker: msg.speaker
1425
+ });
1426
+ break;
1427
+ case "status":
1428
+ this.emit("status", {
1429
+ status: msg.status,
1430
+ uniqueId: msg.uniqueId,
1431
+ roomId: msg.roomId,
1432
+ language: msg.language,
1433
+ message: msg.message
1434
+ });
1435
+ break;
1436
+ case "credits":
1437
+ this.emit("credits", {
1438
+ remaining: msg.remaining,
1439
+ total: msg.total,
1440
+ used: msg.used,
1441
+ warning: msg.warning
1442
+ });
1443
+ break;
1444
+ case "credits_low":
1445
+ this.emit("credits_low", {
1446
+ remaining: msg.remaining,
1447
+ total: msg.total,
1448
+ percent: msg.percent
1449
+ });
1450
+ break;
1451
+ case "error":
1452
+ this.emit("error", {
1453
+ code: msg.code,
1454
+ message: msg.message
1455
+ });
1456
+ break;
1457
+ // Handle interim/final captions from server (sentence-level accumulation)
1458
+ case "interim":
1459
+ this.emit("caption", {
1460
+ text: msg.text,
1461
+ language: msg.language,
1462
+ isFinal: false,
1463
+ confidence: msg.confidence || 0,
1464
+ speaker: msg.speaker
1465
+ });
1466
+ break;
1467
+ case "final":
1468
+ this.emit("caption", {
1469
+ text: msg.text,
1470
+ language: msg.language,
1471
+ isFinal: true,
1472
+ confidence: msg.confidence || 0,
1473
+ speaker: msg.speaker
1474
+ });
1475
+ break;
1476
+ case "translation_interim":
1477
+ this.emit("translation", {
1478
+ text: msg.text,
1479
+ language: msg.language,
1480
+ isFinal: false,
1481
+ confidence: msg.confidence || 0,
1482
+ speaker: msg.speaker
1483
+ });
1484
+ break;
1485
+ case "translation_final":
1486
+ this.emit("translation", {
1487
+ text: msg.text,
1488
+ language: msg.language,
1489
+ isFinal: true,
1490
+ confidence: msg.confidence || 0,
1491
+ speaker: msg.speaker
1492
+ });
1493
+ break;
1494
+ default:
1495
+ if (this.debug) {
1496
+ console.log(`[Captions] Unknown message type: ${msg.type}`, msg);
1497
+ }
1498
+ }
1499
+ } catch {
1500
+ if (this.debug) console.error("[Captions] Failed to parse message:", raw);
1501
+ }
1502
+ }
1503
+ /**
1504
+ * Connect to the TikTok FLV stream and extract audio.
1505
+ * Sends binary audio buffers to the server via WebSocket.
1506
+ */
1507
+ async connectToStream(streamInfo) {
1508
+ const url = streamInfo.audioOnlyUrl || streamInfo.flvUrl;
1509
+ if (!url) {
1510
+ this.emit("error", { code: "NO_STREAM_URL", message: "Server did not provide a usable stream URL" });
1511
+ return;
1512
+ }
1513
+ this.streamUrl = url;
1514
+ if (this.debug) console.log(`[Captions] connectToStream: URL selected: ${url.substring(0, 80)}...`);
1515
+ if (this.streamAbortController) {
1516
+ this.streamAbortController.abort();
1517
+ }
1518
+ this.streamAbortController = new AbortController();
1519
+ let audioFramesSent = 0;
1520
+ let audioBytesSent = 0;
1521
+ this.flvExtractor = new FlvAudioExtractor((adtsFrame) => {
1522
+ if (this.ws?.readyState === import_ws2.default.OPEN) {
1523
+ this.ws.send(adtsFrame);
1524
+ audioFramesSent++;
1525
+ audioBytesSent += adtsFrame.length;
1526
+ if (this.debug && (audioFramesSent <= 3 || audioFramesSent % 100 === 0)) {
1527
+ console.log(`[Captions] Audio frame #${audioFramesSent}: ${adtsFrame.length}b (total: ${audioBytesSent}b)`);
1528
+ }
1529
+ } else if (this.debug && audioFramesSent === 0) {
1530
+ console.log(`[Captions] WARNING: WS not open (readyState=${this.ws?.readyState}), cannot send audio`);
1531
+ }
1532
+ });
1533
+ try {
1534
+ if (this.debug) console.log(`[Captions] connectToStream: calling fetch()...`);
1535
+ const resp = await fetch(url, {
1536
+ signal: this.streamAbortController.signal,
1537
+ headers: {
1538
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
1539
+ }
1540
+ });
1541
+ if (this.debug) console.log(`[Captions] connectToStream: fetch returned status=${resp.status}, hasBody=${!!resp.body}`);
1542
+ if (!resp.ok || !resp.body) {
1543
+ throw new Error(`FLV stream HTTP ${resp.status}`);
1544
+ }
1545
+ if (this.debug) console.log(`[Captions] FLV stream connected (${resp.status})`);
1546
+ const reader = resp.body.getReader ? resp.body.getReader() : null;
1547
+ if (this.debug) console.log(`[Captions] connectToStream: hasReader=${!!reader}, hasAsyncIterator=${typeof resp.body[Symbol.asyncIterator] === "function"}`);
1548
+ if (reader) {
1549
+ const processStream = async () => {
1550
+ let chunks = 0;
1551
+ try {
1552
+ while (true) {
1553
+ const { done, value } = await reader.read();
1554
+ if (done || this.intentionalClose) {
1555
+ if (this.debug) console.log(`[Captions] FLV stream ended (done=${done}, intentionalClose=${this.intentionalClose}), chunks=${chunks}, audioFrames=${audioFramesSent}`);
1556
+ break;
1557
+ }
1558
+ chunks++;
1559
+ if (value && this.flvExtractor) {
1560
+ this.flvExtractor.push(value);
1561
+ }
1562
+ if (this.debug && chunks <= 3) {
1563
+ console.log(`[Captions] FLV chunk #${chunks}: ${value?.length || 0}b`);
1564
+ }
1565
+ }
1566
+ } catch (err) {
1567
+ if (err.name !== "AbortError" && !this.intentionalClose) {
1568
+ if (this.debug) console.error("[Captions] FLV stream read error:", err.message);
1569
+ this.emit("error", { code: "STREAM_READ_ERROR", message: err.message });
1570
+ } else if (this.debug) {
1571
+ console.log(`[Captions] FLV stream aborted after ${chunks} chunks, ${audioFramesSent} audio frames`);
1572
+ }
1573
+ }
1574
+ };
1575
+ processStream();
1576
+ } else if (typeof resp.body[Symbol.asyncIterator] === "function") {
1577
+ const processNodeStream = async () => {
1578
+ let chunks = 0;
1579
+ try {
1580
+ for await (const chunk of resp.body) {
1581
+ if (this.intentionalClose) break;
1582
+ chunks++;
1583
+ const u8 = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
1584
+ if (this.flvExtractor) {
1585
+ this.flvExtractor.push(u8);
1586
+ }
1587
+ if (this.debug && chunks <= 3) {
1588
+ console.log(`[Captions] FLV chunk #${chunks}: ${u8.length}b`);
1589
+ }
1590
+ }
1591
+ if (this.debug) console.log(`[Captions] Node stream ended, chunks=${chunks}, audioFrames=${audioFramesSent}`);
1592
+ } catch (err) {
1593
+ if (err.name !== "AbortError" && !this.intentionalClose) {
1594
+ if (this.debug) console.error("[Captions] FLV stream read error:", err.message);
1595
+ this.emit("error", { code: "STREAM_READ_ERROR", message: err.message });
1596
+ } else if (this.debug) {
1597
+ console.log(`[Captions] FLV node stream aborted after ${chunks} chunks, ${audioFramesSent} audio frames`);
1598
+ }
1599
+ }
1600
+ };
1601
+ processNodeStream();
1602
+ } else {
1603
+ if (this.debug) console.error(`[Captions] ERROR: resp.body has no getReader() and no asyncIterator!`);
1604
+ }
1605
+ } catch (err) {
1606
+ if (err.name !== "AbortError" && !this.intentionalClose) {
1607
+ console.error("[Captions] FLV stream connect error:", err.message);
1608
+ this.emit("error", { code: "STREAM_CONNECT_ERROR", message: err.message });
1609
+ }
1610
+ }
1611
+ }
1612
+ };
1613
+
1170
1614
  // src/api.ts
1171
1615
  var DEFAULT_SIGN_SERVER2 = "https://api.tik.tools";
1172
1616
  var PAGE_CACHE_TTL = 5 * 60 * 1e3;
@@ -1224,6 +1668,7 @@ async function getRegionalRanklist(opts) {
1224
1668
  }
1225
1669
  // Annotate the CommonJS export names for ESM import in node:
1226
1670
  0 && (module.exports = {
1671
+ TikTokCaptions,
1227
1672
  TikTokLive,
1228
1673
  getRanklist,
1229
1674
  getRegionalRanklist