yt2text 0.1.0

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/logger.js ADDED
@@ -0,0 +1,32 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { createWriteStream } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ let stream;
5
+ function serialize(value) {
6
+ if (typeof value === "string")
7
+ return value;
8
+ if (value instanceof Error)
9
+ return value.stack ?? value.message;
10
+ try {
11
+ return JSON.stringify(value);
12
+ }
13
+ catch {
14
+ return String(value);
15
+ }
16
+ }
17
+ export async function installLogger(path) {
18
+ if (!path || stream)
19
+ return;
20
+ await mkdir(dirname(path), { recursive: true });
21
+ stream = createWriteStream(path, { flags: "a" });
22
+ const originalError = console.error.bind(console);
23
+ const originalLog = console.log.bind(console);
24
+ console.error = (...args) => {
25
+ stream?.write(`[${new Date().toISOString()}] ${args.map(serialize).join(" ")}\n`);
26
+ originalError(...args);
27
+ };
28
+ console.log = (...args) => {
29
+ stream?.write(`[${new Date().toISOString()}] ${args.map(serialize).join(" ")}\n`);
30
+ originalLog(...args);
31
+ };
32
+ }
package/dist/media.js ADDED
@@ -0,0 +1,224 @@
1
+ import { basename, join } from "node:path";
2
+ import { access, copyFile } from "node:fs/promises";
3
+ import { detectCookieBrowsers } from "./cookies.js";
4
+ import { ensureYtDlp, getFfmpegPath } from "./deps.js";
5
+ import { asError, Yt2TextError } from "./errors.js";
6
+ import { ProgressBar } from "./progress.js";
7
+ import { runCommand } from "./process.js";
8
+ function sanitizeName(value) {
9
+ return value.replace(/[\\/:*?"<>|]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 160) || "audio";
10
+ }
11
+ function timeoutMs(seconds) {
12
+ return seconds > 0 ? seconds * 1000 : undefined;
13
+ }
14
+ function audioFormatSelector(quality) {
15
+ if (quality === "small")
16
+ return "bestaudio[abr<=64]/bestaudio[abr<=96]/bestaudio/best";
17
+ if (quality === "balanced")
18
+ return "bestaudio[abr<=160]/bestaudio[abr<=192]/bestaudio/best";
19
+ return "bestaudio/best";
20
+ }
21
+ async function assertReadable(path, kind) {
22
+ try {
23
+ await access(path);
24
+ }
25
+ catch {
26
+ if (kind === "media") {
27
+ throw new Yt2TextError(`Input media file not found or not readable: ${path}\n\nHint: 检查文件路径是否正确;如果路径里有空格,请用引号包起来。`, "INPUT_FILE_NOT_FOUND");
28
+ }
29
+ throw new Yt2TextError(`Cookies file not found: ${path}\n\nHint: 用浏览器 cookies 时可以省略 --cookies,改用 --cookies-from-browser chrome/safari/firefox;手动 cookies 文件需要是 Netscape cookies.txt 格式。`, "COOKIE_FILE_NOT_FOUND");
30
+ }
31
+ }
32
+ function explainYtDlpFailure(error, context) {
33
+ const message = asError(error).message;
34
+ const lower = message.toLowerCase();
35
+ const hints = [];
36
+ if (context.noDetectedBrowsers) {
37
+ hints.push("没有检测到可用浏览器 cookies。请确认 Chrome/Safari/Firefox 已安装并登录 YouTube,或用 --cookies <cookies.txt>。");
38
+ }
39
+ if (context.browser) {
40
+ hints.push(`刚才尝试读取 ${context.browser} cookies 失败。可以关闭浏览器后重试,或换 --cookies-from-browser safari/firefox。`);
41
+ }
42
+ if (lower.includes("sign in to confirm") || lower.includes("not a bot")) {
43
+ hints.push("YouTube 要求登录验证。请使用浏览器 cookies,或在交互模式里选择已登录 YouTube 的浏览器。");
44
+ }
45
+ if (lower.includes("could not find") && lower.includes("cookies")) {
46
+ hints.push("yt-dlp 没找到该浏览器的 cookies 数据库。确认浏览器已安装,或手动导出 cookies.txt 后用 --cookies 指定。");
47
+ }
48
+ if (lower.includes("database is locked") || lower.includes("permission")) {
49
+ hints.push("浏览器 cookies 可能被锁定或无权限读取。完全退出浏览器后重试。");
50
+ }
51
+ if (lower.includes("http error 403") || lower.includes("forbidden")) {
52
+ hints.push("403 通常是 cookies/yt-dlp 过期或地区/网络限制。可试 --update-ytdlp,或设置 --proxy。");
53
+ }
54
+ return new Yt2TextError(`Failed to download audio with yt-dlp.\n\n${message}${hints.length ? `\n\n${hints.map((hint) => `Hint: ${hint}`).join("\n")}` : ""}`, "YTDLP_FAILED");
55
+ }
56
+ function createYtdlpProgressHandler(progress) {
57
+ let buffer = "";
58
+ return (text) => {
59
+ buffer += text;
60
+ const lines = buffer.split(/\r?\n/);
61
+ buffer = lines.pop() ?? "";
62
+ for (const raw of lines) {
63
+ const line = raw.trim();
64
+ if (!line.startsWith("YT2TEXT_PROGRESS:"))
65
+ continue;
66
+ const [percentText, downloaded, total, speed, eta] = line.replace("YT2TEXT_PROGRESS:", "").split("|");
67
+ const match = percentText.match(/(\d+(?:\.\d+)?)%/);
68
+ if (!match)
69
+ continue;
70
+ const details = [downloaded, total && total !== "N/A" ? `of ${total}` : "", speed && speed !== "N/A" ? `at ${speed}` : "", eta && eta !== "N/A" ? `ETA ${eta}` : ""]
71
+ .filter(Boolean)
72
+ .join(" ");
73
+ progress.updatePercent(Number(match[1]), details);
74
+ }
75
+ };
76
+ }
77
+ export async function downloadMedia(url, paths, options) {
78
+ const ytdlp = await ensureYtDlp(paths, options);
79
+ if (options.cookies) {
80
+ await assertReadable(options.cookies, "cookies");
81
+ }
82
+ const template = join(paths.workDir, "%(title).160B [%(id)s].%(ext)s");
83
+ const baseArgs = [
84
+ "--no-playlist",
85
+ "--no-warnings",
86
+ "--quiet",
87
+ "--progress",
88
+ "--newline",
89
+ "--progress-delta",
90
+ "0.5",
91
+ "--progress-template",
92
+ "download:YT2TEXT_PROGRESS:%(progress._percent_str)s|%(progress._downloaded_bytes_str)s|%(progress._total_bytes_str)s|%(progress._speed_str)s|%(progress._eta_str)s",
93
+ "--remote-components",
94
+ "ejs:github",
95
+ "--js-runtimes",
96
+ "node",
97
+ "-f",
98
+ audioFormatSelector(options.audioQuality),
99
+ "-N",
100
+ String(options.concurrentFragments),
101
+ "--retries",
102
+ String(options.retries),
103
+ "--fragment-retries",
104
+ String(options.fragmentRetries),
105
+ "--socket-timeout",
106
+ String(options.socketTimeout),
107
+ "-o",
108
+ template,
109
+ "--print",
110
+ "after_move:filepath",
111
+ ];
112
+ if (options.proxy) {
113
+ baseArgs.push("--proxy", options.proxy);
114
+ }
115
+ if (options.rateLimit) {
116
+ baseArgs.push("--limit-rate", options.rateLimit);
117
+ }
118
+ if (options.userAgent) {
119
+ baseArgs.push("--user-agent", options.userAgent);
120
+ }
121
+ const browserCandidates = options.cookies || !options.browserCookies
122
+ ? []
123
+ : options.cookiesFromBrowser === "auto"
124
+ ? await detectCookieBrowsers()
125
+ : options.cookiesFromBrowser
126
+ ? [{ id: options.cookiesFromBrowser, name: options.cookiesFromBrowser }]
127
+ : [];
128
+ const attempts = browserCandidates.length > 0 ? browserCandidates : [undefined];
129
+ const noDetectedBrowsers = Boolean(options.browserCookies && options.cookiesFromBrowser === "auto" && browserCandidates.length === 0);
130
+ let lastError;
131
+ for (const browser of attempts) {
132
+ const args = [...baseArgs];
133
+ const progress = new ProgressBar("Downloading audio");
134
+ const handleProgress = createYtdlpProgressHandler(progress);
135
+ if (options.cookies) {
136
+ args.push("--cookies", options.cookies);
137
+ }
138
+ if (browser) {
139
+ console.error(`Using browser cookies: ${browser.name}`);
140
+ args.push("--cookies-from-browser", browser.id);
141
+ }
142
+ else if (options.cookiesFromBrowser === "auto") {
143
+ console.error("No supported browser cookies found; trying without cookies.");
144
+ }
145
+ args.push(url);
146
+ try {
147
+ const result = await runCommand(ytdlp, args, {
148
+ quiet: true,
149
+ onStdout: handleProgress,
150
+ onStderr: handleProgress,
151
+ timeoutMs: timeoutMs(options.downloadTimeoutSeconds),
152
+ });
153
+ progress.finish();
154
+ const mediaPath = result.stdout
155
+ .trim()
156
+ .split(/\r?\n/)
157
+ .map((line) => line.trim())
158
+ .filter((line) => line && !line.startsWith("YT2TEXT_PROGRESS:"))
159
+ .at(-1);
160
+ if (!mediaPath) {
161
+ throw new Error("yt-dlp did not report an output file path");
162
+ }
163
+ return {
164
+ mediaPath,
165
+ title: sanitizeName(basename(mediaPath).replace(/\.[^.]+$/, "")),
166
+ };
167
+ }
168
+ catch (error) {
169
+ lastError = error;
170
+ progress.fail();
171
+ if (options.cookiesFromBrowser !== "auto" || !browser) {
172
+ throw explainYtDlpFailure(error, {
173
+ browser: browser?.name,
174
+ autoCookies: options.cookiesFromBrowser === "auto",
175
+ noDetectedBrowsers,
176
+ });
177
+ }
178
+ console.error(`Could not use ${browser.name} cookies; trying another browser.`);
179
+ }
180
+ }
181
+ throw explainYtDlpFailure(lastError ?? new Error("yt-dlp download failed"), {
182
+ autoCookies: options.cookiesFromBrowser === "auto",
183
+ noDetectedBrowsers,
184
+ });
185
+ }
186
+ export async function prepareLocalMedia(inputPath, paths) {
187
+ await assertReadable(inputPath, "media");
188
+ const title = sanitizeName(basename(inputPath).replace(/\.[^.]+$/, ""));
189
+ const copied = join(paths.workDir, basename(inputPath));
190
+ await copyFile(inputPath, copied);
191
+ return { mediaPath: copied, title };
192
+ }
193
+ export async function normalizeToWav(inputPath, outputPath, timeoutSeconds = 0) {
194
+ const ffmpeg = await getFfmpegPath();
195
+ await runCommand(ffmpeg, ["-y", "-hide_banner", "-loglevel", "error", "-i", inputPath, "-vn", "-ac", "1", "-ar", "16000", outputPath], { quiet: true, timeoutMs: timeoutMs(timeoutSeconds) });
196
+ return outputPath;
197
+ }
198
+ export async function splitWav(wavPath, outputDir, chunkSeconds, timeoutSeconds = 0) {
199
+ if (chunkSeconds <= 0)
200
+ return [wavPath];
201
+ const ffmpeg = await getFfmpegPath();
202
+ const pattern = join(outputDir, "chunk_%04d.wav");
203
+ await runCommand(ffmpeg, [
204
+ "-y",
205
+ "-hide_banner",
206
+ "-loglevel",
207
+ "error",
208
+ "-i",
209
+ wavPath,
210
+ "-f",
211
+ "segment",
212
+ "-segment_time",
213
+ String(chunkSeconds),
214
+ "-c",
215
+ "copy",
216
+ pattern,
217
+ ], { quiet: true, timeoutMs: timeoutMs(timeoutSeconds) });
218
+ const { readdir } = await import("node:fs/promises");
219
+ const files = await readdir(outputDir);
220
+ return files
221
+ .filter((file) => /^chunk_\d+\.wav$/.test(file))
222
+ .sort()
223
+ .map((file) => join(outputDir, file));
224
+ }
package/dist/output.js ADDED
@@ -0,0 +1,62 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ function sanitizeFilePart(value) {
4
+ return value.replace(/[\\/:*?"<>|]+/g, " ").replace(/\s+/g, " ").trim();
5
+ }
6
+ function timestamp(seconds) {
7
+ const safe = Math.max(0, seconds);
8
+ const hours = Math.floor(safe / 3600);
9
+ const minutes = Math.floor((safe % 3600) / 60);
10
+ const secs = Math.floor(safe % 60);
11
+ const millis = Math.round((safe - Math.floor(safe)) * 1000);
12
+ if (hours > 0) {
13
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${secs
14
+ .toString()
15
+ .padStart(2, "0")}.${millis.toString().padStart(3, "0")}`;
16
+ }
17
+ return `${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${millis
18
+ .toString()
19
+ .padStart(3, "0")}`;
20
+ }
21
+ function srtTimestamp(seconds) {
22
+ return timestamp(seconds).replace(".", ",").padStart(12, "00:");
23
+ }
24
+ function textLine(segment) {
25
+ const speaker = segment.speaker ? `${segment.speaker}: ` : "";
26
+ return `[${timestamp(segment.start)} - ${timestamp(segment.end)}] ${speaker}${segment.text}`;
27
+ }
28
+ function toTxt(document) {
29
+ return `${document.segments.map(textLine).join("\n")}\n`;
30
+ }
31
+ function toSrt(document) {
32
+ return `${document.segments
33
+ .map((segment, index) => {
34
+ const speaker = segment.speaker ? `${segment.speaker}: ` : "";
35
+ return `${index + 1}\n${srtTimestamp(segment.start)} --> ${srtTimestamp(segment.end)}\n${speaker}${segment.text}`;
36
+ })
37
+ .join("\n\n")}\n`;
38
+ }
39
+ export async function writeTranscript(document, outputDir, format, filenameTemplate = "{title}") {
40
+ await mkdir(outputDir, { recursive: true });
41
+ const outputPath = join(outputDir, `${renderOutputStem(document, filenameTemplate)}.${format}`);
42
+ const body = format === "json" ? `${JSON.stringify(document, null, 2)}\n` : format === "srt" ? toSrt(document) : toTxt(document);
43
+ await writeFile(outputPath, body, "utf8");
44
+ return outputPath;
45
+ }
46
+ export function renderOutputStem(document, template) {
47
+ const createdAt = new Date(document.createdAt);
48
+ const date = Number.isNaN(createdAt.getTime()) ? new Date().toISOString().slice(0, 10) : createdAt.toISOString().slice(0, 10);
49
+ const datetime = Number.isNaN(createdAt.getTime())
50
+ ? new Date().toISOString().replace(/[:.]/g, "-")
51
+ : createdAt.toISOString().replace(/[:.]/g, "-");
52
+ const values = {
53
+ title: document.title ?? "transcript",
54
+ language: document.language,
55
+ engine: document.engine,
56
+ date,
57
+ datetime,
58
+ source: document.source,
59
+ };
60
+ const rendered = template.replace(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, (match, key) => values[key] ?? match);
61
+ return sanitizeFilePart(rendered) || "transcript";
62
+ }
@@ -0,0 +1,39 @@
1
+ export function isSupportedPlatform() {
2
+ return process.platform === "darwin" || process.platform === "linux" || process.platform === "win32";
3
+ }
4
+ export function defaultAsrProvider() {
5
+ return process.platform === "linux" ? "local" : "system";
6
+ }
7
+ export function defaultSystemMode() {
8
+ return process.platform === "darwin" || process.platform === "win32" ? "offline" : "online";
9
+ }
10
+ export function platformName() {
11
+ if (process.platform === "darwin")
12
+ return "macOS";
13
+ if (process.platform === "linux")
14
+ return "Linux";
15
+ if (process.platform === "win32")
16
+ return "Windows";
17
+ return process.platform;
18
+ }
19
+ export function executableName(name) {
20
+ return process.platform === "win32" ? `${name}.exe` : name;
21
+ }
22
+ export function ytdlpReleaseUrl() {
23
+ if (process.platform === "darwin") {
24
+ return "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos";
25
+ }
26
+ if (process.platform === "win32") {
27
+ if (process.arch === "arm64") {
28
+ return "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_arm64.exe";
29
+ }
30
+ return "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe";
31
+ }
32
+ if (process.platform === "linux") {
33
+ if (process.arch === "arm64") {
34
+ return "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux_aarch64";
35
+ }
36
+ return "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux";
37
+ }
38
+ throw new Error(`Unsupported platform: ${process.platform}. yt2text supports macOS, Linux, and Windows only.`);
39
+ }
@@ -0,0 +1,69 @@
1
+ import { spawn } from "node:child_process";
2
+ import { Yt2TextError } from "./errors.js";
3
+ export function runCommand(command, args, options = {}) {
4
+ return new Promise((resolve, reject) => {
5
+ let settled = false;
6
+ let killTimer;
7
+ const child = spawn(command, args, {
8
+ cwd: options.cwd,
9
+ env: { ...process.env, ...options.env },
10
+ windowsHide: true,
11
+ });
12
+ const cleanup = () => {
13
+ if (timeout)
14
+ clearTimeout(timeout);
15
+ if (killTimer)
16
+ clearTimeout(killTimer);
17
+ };
18
+ const timeout = options.timeoutMs && options.timeoutMs > 0
19
+ ? setTimeout(() => {
20
+ settled = true;
21
+ child.kill("SIGTERM");
22
+ killTimer = setTimeout(() => child.kill("SIGKILL"), 2000);
23
+ reject(new Yt2TextError(`${command} timed out after ${Math.round(options.timeoutMs / 1000)} seconds`, "COMMAND_TIMEOUT"));
24
+ }, options.timeoutMs)
25
+ : undefined;
26
+ let stdout = "";
27
+ let stderr = "";
28
+ child.stdout.on("data", (chunk) => {
29
+ const text = chunk.toString();
30
+ stdout += text;
31
+ options.onStdout?.(text);
32
+ if (!options.quiet)
33
+ process.stdout.write(text);
34
+ });
35
+ child.stderr.on("data", (chunk) => {
36
+ const text = chunk.toString();
37
+ stderr += text;
38
+ options.onStderr?.(text);
39
+ if (!options.quiet)
40
+ process.stderr.write(text);
41
+ });
42
+ child.on("error", (error) => {
43
+ if (settled) {
44
+ cleanup();
45
+ return;
46
+ }
47
+ settled = true;
48
+ cleanup();
49
+ if (error.code === "ENOENT") {
50
+ reject(new Yt2TextError(`${command} was not found in PATH`, "COMMAND_NOT_FOUND"));
51
+ return;
52
+ }
53
+ reject(error);
54
+ });
55
+ child.on("close", (code) => {
56
+ if (settled) {
57
+ cleanup();
58
+ return;
59
+ }
60
+ settled = true;
61
+ cleanup();
62
+ if (code === 0) {
63
+ resolve({ stdout, stderr });
64
+ return;
65
+ }
66
+ reject(new Yt2TextError(`${command} exited with code ${code ?? "unknown"}\n${stderr.trim() || stdout.trim()}`, "COMMAND_FAILED"));
67
+ });
68
+ });
69
+ }
@@ -0,0 +1,79 @@
1
+ function clamp(value) {
2
+ if (!Number.isFinite(value))
3
+ return 0;
4
+ return Math.max(0, Math.min(1, value));
5
+ }
6
+ function formatBytes(bytes) {
7
+ if (!Number.isFinite(bytes) || bytes < 0)
8
+ return "";
9
+ const units = ["B", "KB", "MB", "GB", "TB"];
10
+ let value = bytes;
11
+ let unit = 0;
12
+ while (value >= 1024 && unit < units.length - 1) {
13
+ value /= 1024;
14
+ unit += 1;
15
+ }
16
+ const precision = unit === 0 || value >= 10 ? 0 : 1;
17
+ return `${value.toFixed(precision)} ${units[unit]}`;
18
+ }
19
+ export class ProgressBar {
20
+ label;
21
+ width;
22
+ lastRender = 0;
23
+ lastLinePercent = -1;
24
+ active = false;
25
+ isTty = Boolean(process.stderr.isTTY);
26
+ constructor(label, width = 28) {
27
+ this.label = label;
28
+ this.width = width;
29
+ }
30
+ updatePercent(percent, detail = "") {
31
+ this.update(percent / 100, detail);
32
+ }
33
+ updateBytes(done, total, detail = "") {
34
+ if (total && total > 0) {
35
+ this.update(done / total, `${formatBytes(done)}/${formatBytes(total)}${detail ? ` ${detail}` : ""}`);
36
+ return;
37
+ }
38
+ this.render(undefined, `${formatBytes(done)}${detail ? ` ${detail}` : ""}`);
39
+ }
40
+ update(ratio, detail = "") {
41
+ this.render(clamp(ratio), detail);
42
+ }
43
+ finish(detail = "done") {
44
+ this.render(1, detail, true);
45
+ if (this.isTty)
46
+ process.stderr.write("\n");
47
+ this.active = false;
48
+ }
49
+ fail(detail = "failed") {
50
+ this.render(undefined, detail, true);
51
+ if (this.isTty)
52
+ process.stderr.write("\n");
53
+ this.active = false;
54
+ }
55
+ render(ratio, detail = "", force = false) {
56
+ const now = Date.now();
57
+ if (!force && now - this.lastRender < 120)
58
+ return;
59
+ this.lastRender = now;
60
+ this.active = true;
61
+ if (!this.isTty) {
62
+ const percent = ratio === undefined ? -1 : Math.floor(ratio * 100);
63
+ if (!force && percent >= 0 && percent < this.lastLinePercent + 10)
64
+ return;
65
+ this.lastLinePercent = percent;
66
+ const prefix = percent >= 0 ? `${percent.toString().padStart(3, " ")}%` : "...";
67
+ console.error(`${this.label}: ${prefix}${detail ? ` ${detail}` : ""}`);
68
+ return;
69
+ }
70
+ const percent = ratio === undefined ? undefined : Math.round(ratio * 100);
71
+ const filled = ratio === undefined ? 0 : Math.round(ratio * this.width);
72
+ const bar = ratio === undefined
73
+ ? `${".".repeat(Math.min(this.width, 3))}${" ".repeat(Math.max(0, this.width - 3))}`
74
+ : `${"#".repeat(filled)}${"-".repeat(this.width - filled)}`;
75
+ const percentText = percent === undefined ? " " : `${percent.toString().padStart(3, " ")}%`;
76
+ const text = `${this.label} [${bar}] ${percentText}${detail ? ` ${detail}` : ""}`;
77
+ process.stderr.write(`\r${text.slice(0, process.stderr.columns ?? 120).padEnd(process.stderr.columns ?? 120, " ")}`);
78
+ }
79
+ }
@@ -0,0 +1,36 @@
1
+ export function mergeWordSegments(words) {
2
+ const sorted = words
3
+ .filter((word) => word.text.trim())
4
+ .sort((a, b) => a.start - b.start);
5
+ const merged = [];
6
+ for (const word of sorted) {
7
+ const previous = merged.at(-1);
8
+ const gap = previous ? word.start - previous.end : Number.POSITIVE_INFINITY;
9
+ const candidateLength = previous ? `${previous.text} ${word.text}`.length : 0;
10
+ if (!previous || gap > 0.85 || candidateLength > 180) {
11
+ merged.push({ ...word, text: word.text.trim() });
12
+ continue;
13
+ }
14
+ previous.end = Math.max(previous.end, word.end);
15
+ previous.text = `${previous.text} ${word.text.trim()}`;
16
+ if (word.confidence !== undefined) {
17
+ previous.confidence =
18
+ previous.confidence === undefined ? word.confidence : Math.min(previous.confidence, word.confidence);
19
+ }
20
+ }
21
+ return merged;
22
+ }
23
+ export function assignSpeakers(transcript, speakers) {
24
+ return transcript.map((segment) => {
25
+ let bestSpeaker;
26
+ let bestOverlap = 0;
27
+ for (const speaker of speakers) {
28
+ const overlap = Math.max(0, Math.min(segment.end, speaker.end) - Math.max(segment.start, speaker.start));
29
+ if (overlap > bestOverlap) {
30
+ bestOverlap = overlap;
31
+ bestSpeaker = speaker.speaker;
32
+ }
33
+ }
34
+ return bestSpeaker ? { ...segment, speaker: bestSpeaker } : segment;
35
+ });
36
+ }