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