@tiktool/live 2.6.2 → 2.6.4

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
@@ -1134,6 +1134,86 @@ var TikTokLive = class extends EventEmitter {
1134
1134
  // src/captions.ts
1135
1135
  import { EventEmitter as EventEmitter2 } from "events";
1136
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
+ };
1137
1217
  var DEFAULT_CAPTIONS_SERVER = "wss://api.tik.tools";
1138
1218
  var TikTokCaptions = class extends EventEmitter2 {
1139
1219
  ws = null;
@@ -1150,6 +1230,9 @@ var TikTokCaptions = class extends EventEmitter2 {
1150
1230
  _diarization;
1151
1231
  _maxDurationMinutes;
1152
1232
  _language;
1233
+ streamAbortController = null;
1234
+ flvExtractor = null;
1235
+ streamUrl = null;
1153
1236
  constructor(options) {
1154
1237
  super();
1155
1238
  this.uniqueId = options.uniqueId.replace(/^@/, "");
@@ -1208,6 +1291,14 @@ var TikTokCaptions = class extends EventEmitter2 {
1208
1291
  */
1209
1292
  stop() {
1210
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
+ }
1211
1302
  if (this.ws) {
1212
1303
  this.send({ action: "stop" });
1213
1304
  this.ws.close(1e3);
@@ -1270,6 +1361,10 @@ var TikTokCaptions = class extends EventEmitter2 {
1270
1361
  try {
1271
1362
  const msg = JSON.parse(raw);
1272
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;
1273
1368
  case "caption":
1274
1369
  this.emit("caption", {
1275
1370
  text: msg.text,
@@ -1320,15 +1415,161 @@ var TikTokCaptions = class extends EventEmitter2 {
1320
1415
  message: msg.message
1321
1416
  });
1322
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;
1323
1455
  default:
1324
1456
  if (this.debug) {
1325
- console.log(`[Captions] Unknown message type: ${msg.type}`, msg);
1457
+ if (this.debug) console.log(`[Captions] Unknown message type: ${msg.type}`, msg);
1326
1458
  }
1327
1459
  }
1328
1460
  } catch {
1329
1461
  if (this.debug) console.error("[Captions] Failed to parse message:", raw);
1330
1462
  }
1331
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
+ if (this.debug) console.log(`[Captions] Audio frame #${audioFramesSent}: ${adtsFrame.length}b (total: ${audioBytesSent}b)`);
1489
+ }
1490
+ } else if (this.debug && audioFramesSent === 0) {
1491
+ if (this.debug) 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
+ if (this.debug) 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
+ if (this.debug) 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
+ if (this.debug) 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
+ if (this.debug) 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
+ if (this.debug) console.error("[Captions] FLV stream connect error:", err.message);
1569
+ this.emit("error", { code: "STREAM_CONNECT_ERROR", message: err.message });
1570
+ }
1571
+ }
1572
+ }
1332
1573
  };
1333
1574
 
1334
1575
  // src/api.ts
@@ -1386,9 +1627,46 @@ async function getRegionalRanklist(opts) {
1386
1627
  }
1387
1628
  return data;
1388
1629
  }
1630
+ async function getLiveFeed(opts) {
1631
+ const base = (opts.serverUrl || DEFAULT_SIGN_SERVER2).replace(/\/$/, "");
1632
+ const params = new URLSearchParams();
1633
+ params.set("apiKey", opts.apiKey);
1634
+ if (opts.region) params.set("region", opts.region);
1635
+ if (opts.channelId) params.set("channel_id", opts.channelId);
1636
+ if (opts.count !== void 0) params.set("count", String(Math.min(opts.count, 50)));
1637
+ if (opts.maxTime) params.set("max_time", opts.maxTime);
1638
+ if (opts.sessionId) params.set("session_id", opts.sessionId);
1639
+ if (opts.ttwid) params.set("ttwid", opts.ttwid);
1640
+ if (opts.msToken) params.set("ms_token", opts.msToken);
1641
+ const resp = await fetch(`${base}/webcast/feed?${params.toString()}`);
1642
+ const data = await resp.json();
1643
+ if (resp.status === 429) {
1644
+ throw new Error(data.error || "Feed daily limit reached. Upgrade your plan for more calls.");
1645
+ }
1646
+ if (!resp.ok) {
1647
+ throw new Error(data.error || `Feed request failed (HTTP ${resp.status})`);
1648
+ }
1649
+ return data;
1650
+ }
1651
+ async function fetchFeed(opts) {
1652
+ const signed = await getLiveFeed(opts);
1653
+ const headers = { ...signed.headers || {} };
1654
+ if (signed.cookies) {
1655
+ headers["Cookie"] = signed.cookies;
1656
+ }
1657
+ const resp = await fetch(signed.signed_url, { headers, redirect: "follow" });
1658
+ const text = await resp.text();
1659
+ try {
1660
+ return JSON.parse(text);
1661
+ } catch {
1662
+ return null;
1663
+ }
1664
+ }
1389
1665
  export {
1390
1666
  TikTokCaptions,
1391
1667
  TikTokLive,
1668
+ fetchFeed,
1669
+ getLiveFeed,
1392
1670
  getRanklist,
1393
1671
  getRegionalRanklist
1394
1672
  };