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/LICENSE +21 -0
- package/README.md +334 -0
- package/dist/asr.js +19 -0
- package/dist/cache.js +28 -0
- package/dist/cli.js +384 -0
- package/dist/config.js +185 -0
- package/dist/cookies.js +121 -0
- package/dist/deps.js +82 -0
- package/dist/doctor.js +140 -0
- package/dist/download-file.js +42 -0
- package/dist/errors.js +68 -0
- package/dist/local-asr.js +218 -0
- package/dist/logger.js +32 -0
- package/dist/media.js +224 -0
- package/dist/output.js +62 -0
- package/dist/platform.js +39 -0
- package/dist/process.js +69 -0
- package/dist/progress.js +79 -0
- package/dist/segments.js +36 -0
- package/dist/system-asr.js +152 -0
- package/dist/types.js +1 -0
- package/helpers/macos-speech-info.plist +21 -0
- package/helpers/macos-transcribe.swift +130 -0
- package/helpers/windows-transcribe.ps1 +45 -0
- package/package.json +62 -0
package/dist/cookies.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { delimiter, join } from "node:path";
|
|
4
|
+
const candidates = [
|
|
5
|
+
{
|
|
6
|
+
id: "chrome",
|
|
7
|
+
name: "Google Chrome",
|
|
8
|
+
macApps: ["Google Chrome.app"],
|
|
9
|
+
commands: ["google-chrome", "google-chrome-stable", "chrome"],
|
|
10
|
+
windowsPaths: [
|
|
11
|
+
"Google\\Chrome\\Application\\chrome.exe",
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: "safari",
|
|
16
|
+
name: "Safari",
|
|
17
|
+
macApps: ["Safari.app"],
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "firefox",
|
|
21
|
+
name: "Firefox",
|
|
22
|
+
macApps: ["Firefox.app"],
|
|
23
|
+
commands: ["firefox"],
|
|
24
|
+
windowsPaths: [
|
|
25
|
+
"Mozilla Firefox\\firefox.exe",
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "edge",
|
|
30
|
+
name: "Microsoft Edge",
|
|
31
|
+
macApps: ["Microsoft Edge.app"],
|
|
32
|
+
commands: ["microsoft-edge", "microsoft-edge-stable"],
|
|
33
|
+
windowsPaths: [
|
|
34
|
+
"Microsoft\\Edge\\Application\\msedge.exe",
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "brave",
|
|
39
|
+
name: "Brave",
|
|
40
|
+
macApps: ["Brave Browser.app"],
|
|
41
|
+
commands: ["brave-browser", "brave"],
|
|
42
|
+
windowsPaths: [
|
|
43
|
+
"BraveSoftware\\Brave-Browser\\Application\\brave.exe",
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "chromium",
|
|
48
|
+
name: "Chromium",
|
|
49
|
+
macApps: ["Chromium.app"],
|
|
50
|
+
commands: ["chromium", "chromium-browser"],
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
async function exists(path) {
|
|
54
|
+
try {
|
|
55
|
+
await access(path);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function commandExists(command) {
|
|
63
|
+
const paths = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
64
|
+
const suffixes = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
|
|
65
|
+
for (const dir of paths) {
|
|
66
|
+
for (const suffix of suffixes) {
|
|
67
|
+
if (await exists(join(dir, `${command}${suffix}`)))
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
async function hasMacApp(candidate) {
|
|
74
|
+
for (const app of candidate.macApps ?? []) {
|
|
75
|
+
if (await exists(join("/Applications", app)))
|
|
76
|
+
return true;
|
|
77
|
+
if (await exists(join(homedir(), "Applications", app)))
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
async function hasWindowsApp(candidate) {
|
|
83
|
+
const roots = [
|
|
84
|
+
process.env.LOCALAPPDATA,
|
|
85
|
+
process.env.PROGRAMFILES,
|
|
86
|
+
process.env["PROGRAMFILES(X86)"],
|
|
87
|
+
].filter(Boolean);
|
|
88
|
+
for (const relative of candidate.windowsPaths ?? []) {
|
|
89
|
+
for (const root of roots) {
|
|
90
|
+
if (await exists(join(root, relative)))
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
async function isInstalled(candidate) {
|
|
97
|
+
if (process.platform === "darwin")
|
|
98
|
+
return hasMacApp(candidate);
|
|
99
|
+
if (process.platform === "win32")
|
|
100
|
+
return hasWindowsApp(candidate);
|
|
101
|
+
for (const command of candidate.commands ?? []) {
|
|
102
|
+
if (await commandExists(command))
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
export function defaultCookiesFromBrowser() {
|
|
108
|
+
return process.platform === "darwin" ? "auto" : undefined;
|
|
109
|
+
}
|
|
110
|
+
export function knownCookieBrowsers() {
|
|
111
|
+
return candidates.map(({ id, name }) => ({ id, name }));
|
|
112
|
+
}
|
|
113
|
+
export async function detectCookieBrowsers() {
|
|
114
|
+
const detected = [];
|
|
115
|
+
for (const candidate of candidates) {
|
|
116
|
+
if (await isInstalled(candidate)) {
|
|
117
|
+
detected.push({ id: candidate.id, name: candidate.name });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return detected;
|
|
121
|
+
}
|
package/dist/deps.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { access, rm } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { downloadFile } from "./download-file.js";
|
|
5
|
+
import { executableName, ytdlpReleaseUrl } from "./platform.js";
|
|
6
|
+
import { runCommand } from "./process.js";
|
|
7
|
+
import { asError, Yt2TextError } from "./errors.js";
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
async function exists(path) {
|
|
10
|
+
try {
|
|
11
|
+
await access(path);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function commandExists(command) {
|
|
19
|
+
try {
|
|
20
|
+
await runCommand(command, ["--version"], { quiet: true });
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function ensureYtDlp(paths, options) {
|
|
28
|
+
const localPath = join(paths.toolsDir, executableName("yt-dlp"));
|
|
29
|
+
if ((await exists(localPath)) && !options.updateYtDlp)
|
|
30
|
+
return localPath;
|
|
31
|
+
if ((await commandExists("yt-dlp")) && !options.updateYtDlp)
|
|
32
|
+
return "yt-dlp";
|
|
33
|
+
if (options.offline) {
|
|
34
|
+
throw new Yt2TextError("yt-dlp is not cached and --offline was set", "MISSING_DEPENDENCY");
|
|
35
|
+
}
|
|
36
|
+
if (options.updateYtDlp) {
|
|
37
|
+
await rm(localPath, { force: true });
|
|
38
|
+
}
|
|
39
|
+
console.error(`Downloading yt-dlp to ${localPath}`);
|
|
40
|
+
await downloadFile(ytdlpReleaseUrl(), localPath, "Downloading yt-dlp");
|
|
41
|
+
return localPath;
|
|
42
|
+
}
|
|
43
|
+
export async function getFfmpegPath() {
|
|
44
|
+
try {
|
|
45
|
+
const ffmpeg = require("@ffmpeg-installer/ffmpeg");
|
|
46
|
+
if (ffmpeg.path)
|
|
47
|
+
return ffmpeg.path;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Fall through to PATH.
|
|
51
|
+
}
|
|
52
|
+
if (await commandExists("ffmpeg"))
|
|
53
|
+
return "ffmpeg";
|
|
54
|
+
throw new Yt2TextError("ffmpeg was not found", "MISSING_DEPENDENCY");
|
|
55
|
+
}
|
|
56
|
+
export async function ensureMacosSwiftc(options) {
|
|
57
|
+
if (process.platform !== "darwin")
|
|
58
|
+
return;
|
|
59
|
+
if (await commandExists("swiftc"))
|
|
60
|
+
return;
|
|
61
|
+
if (options.offline) {
|
|
62
|
+
throw new Yt2TextError("swiftc is missing and --offline was set", "MISSING_DEPENDENCY");
|
|
63
|
+
}
|
|
64
|
+
console.error("Xcode Command Line Tools are required for macOS system ASR. Opening Apple's installer...");
|
|
65
|
+
try {
|
|
66
|
+
await runCommand("xcode-select", ["--install"], { quiet: true });
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const message = asError(error).message;
|
|
70
|
+
const lower = message.toLowerCase();
|
|
71
|
+
const installerAlreadyActive = lower.includes("install requested") ||
|
|
72
|
+
lower.includes("already installed") ||
|
|
73
|
+
lower.includes("software update") ||
|
|
74
|
+
lower.includes("currently being installed");
|
|
75
|
+
if (!installerAlreadyActive) {
|
|
76
|
+
throw new Yt2TextError(`Could not start Xcode Command Line Tools installer.\n${message}\n\nRun manually: xcode-select --install`, "MISSING_DEPENDENCY");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (await commandExists("swiftc"))
|
|
80
|
+
return;
|
|
81
|
+
throw new Yt2TextError("Xcode Command Line Tools installer was opened. Finish the macOS installation, then rerun yt2text.", "MISSING_DEPENDENCY");
|
|
82
|
+
}
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { detectCookieBrowsers } from "./cookies.js";
|
|
5
|
+
import { commandExists, ensureMacosSwiftc, ensureYtDlp, getFfmpegPath } from "./deps.js";
|
|
6
|
+
import { defaultAsrProvider, executableName, isSupportedPlatform, platformName } from "./platform.js";
|
|
7
|
+
import { runCommand } from "./process.js";
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
async function exists(path) {
|
|
10
|
+
try {
|
|
11
|
+
await access(path);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function commandVersion(command) {
|
|
19
|
+
try {
|
|
20
|
+
return (await runCommand(command, ["--version"], { quiet: true })).stdout.trim().split(/\r?\n/)[0];
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function packageInstalled(name) {
|
|
27
|
+
try {
|
|
28
|
+
require.resolve(name);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function redactUrl(value) {
|
|
36
|
+
if (!value)
|
|
37
|
+
return undefined;
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(value);
|
|
40
|
+
if (url.password)
|
|
41
|
+
url.password = "****";
|
|
42
|
+
return url.toString();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function effectiveOptions(options, paths) {
|
|
49
|
+
return {
|
|
50
|
+
configPath: options.configPath,
|
|
51
|
+
asr: options.asr,
|
|
52
|
+
systemMode: options.systemMode,
|
|
53
|
+
fallback: options.fallback,
|
|
54
|
+
language: options.language,
|
|
55
|
+
model: options.model,
|
|
56
|
+
diarize: options.diarize,
|
|
57
|
+
format: options.format,
|
|
58
|
+
outputDir: options.outputDir,
|
|
59
|
+
cacheDir: paths.cacheDir,
|
|
60
|
+
offline: options.offline,
|
|
61
|
+
keepAudio: options.keepAudio,
|
|
62
|
+
keepOriginalAudio: options.keepOriginalAudio,
|
|
63
|
+
chunkSeconds: options.chunkSeconds,
|
|
64
|
+
filenameTemplate: options.filenameTemplate,
|
|
65
|
+
audioQuality: options.audioQuality,
|
|
66
|
+
browserCookies: options.browserCookies,
|
|
67
|
+
cookiesFromBrowser: options.cookiesFromBrowser,
|
|
68
|
+
cookies: options.cookies,
|
|
69
|
+
concurrentFragments: options.concurrentFragments,
|
|
70
|
+
retries: options.retries,
|
|
71
|
+
fragmentRetries: options.fragmentRetries,
|
|
72
|
+
socketTimeout: options.socketTimeout,
|
|
73
|
+
downloadTimeoutSeconds: options.downloadTimeoutSeconds,
|
|
74
|
+
convertTimeoutSeconds: options.convertTimeoutSeconds,
|
|
75
|
+
asrChunkTimeoutSeconds: options.asrChunkTimeoutSeconds,
|
|
76
|
+
proxy: redactUrl(options.proxy),
|
|
77
|
+
rateLimit: options.rateLimit,
|
|
78
|
+
userAgent: options.userAgent,
|
|
79
|
+
logFile: options.logFile,
|
|
80
|
+
updateYtDlp: options.updateYtDlp,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export async function runDoctor(paths, options, fix = false, showConfig = false) {
|
|
84
|
+
console.log(`Platform: ${platformName()} (${process.platform}/${process.arch})`);
|
|
85
|
+
console.log(`Supported: ${isSupportedPlatform() ? "yes" : "no; macOS, Linux, and Windows only"}`);
|
|
86
|
+
console.log(`Node: ${process.version}`);
|
|
87
|
+
console.log(`Config: ${options.configPath}`);
|
|
88
|
+
console.log(`Output: ${options.outputDir}`);
|
|
89
|
+
console.log(`Default ASR: ${isSupportedPlatform() ? defaultAsrProvider() : "unsupported"}`);
|
|
90
|
+
console.log(`Cache: ${paths.cacheDir}`);
|
|
91
|
+
const cachedYtDlp = join(paths.toolsDir, executableName("yt-dlp"));
|
|
92
|
+
const ytdlpCached = await exists(cachedYtDlp);
|
|
93
|
+
const ytdlpOnPath = await commandExists("yt-dlp");
|
|
94
|
+
const ytdlpPath = ytdlpCached ? cachedYtDlp : ytdlpOnPath ? "yt-dlp" : undefined;
|
|
95
|
+
console.log(`yt-dlp: ${ytdlpPath ?? "missing"}`);
|
|
96
|
+
if (ytdlpPath) {
|
|
97
|
+
const version = await commandVersion(ytdlpPath);
|
|
98
|
+
if (version)
|
|
99
|
+
console.log(`yt-dlp version: ${version}`);
|
|
100
|
+
}
|
|
101
|
+
if (fix && !ytdlpPath) {
|
|
102
|
+
console.log("yt-dlp: downloading");
|
|
103
|
+
await ensureYtDlp(paths, options);
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
console.log(`ffmpeg: ${await getFfmpegPath()}`);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
console.log("ffmpeg: missing");
|
|
110
|
+
}
|
|
111
|
+
console.log(`tar: ${(await commandExists("tar")) ? "PATH" : "missing; needed for local ASR model archives"}`);
|
|
112
|
+
console.log(`sherpa-onnx-node: ${packageInstalled("sherpa-onnx-node") ? "installed" : "missing optional dependency"}`);
|
|
113
|
+
const browsers = await detectCookieBrowsers();
|
|
114
|
+
console.log(`browser cookies: ${browsers.length ? browsers.map((browser) => `${browser.name} (${browser.id})`).join(", ") : "none detected"}`);
|
|
115
|
+
if (process.platform === "darwin") {
|
|
116
|
+
console.log("system ASR: macOS Speech.framework helper");
|
|
117
|
+
const hasSwiftc = await commandExists("swiftc");
|
|
118
|
+
console.log(`swiftc: ${hasSwiftc ? "PATH" : "missing; Xcode Command Line Tools required"}`);
|
|
119
|
+
if (fix && !hasSwiftc) {
|
|
120
|
+
await ensureMacosSwiftc(options);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else if (process.platform === "win32") {
|
|
124
|
+
console.log("system ASR: Windows SAPI helper (offline; untested)");
|
|
125
|
+
console.log(`powershell.exe: ${(await commandExists("powershell.exe")) ? "PATH" : "missing"}`);
|
|
126
|
+
}
|
|
127
|
+
else if (process.platform === "linux") {
|
|
128
|
+
console.log("system ASR: unavailable; local ASR is the default");
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
console.log("system ASR: unsupported platform");
|
|
132
|
+
}
|
|
133
|
+
const localModel = join(paths.modelsDir, `sherpa-onnx-whisper-${options.model}`);
|
|
134
|
+
console.log(`local ASR model (${options.model}): ${(await exists(localModel)) ? "cached" : "not cached"}`);
|
|
135
|
+
console.log(`diarization: ${(await exists(join(paths.modelsDir, "sherpa-onnx-pyannote-segmentation-3-0"))) ? "cached" : "not cached"}`);
|
|
136
|
+
if (showConfig) {
|
|
137
|
+
console.log("Effective options:");
|
|
138
|
+
console.log(JSON.stringify(effectiveOptions(options, paths), null, 2));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { chmod, mkdir, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { Readable, Transform } from "node:stream";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import { Yt2TextError } from "./errors.js";
|
|
7
|
+
import { ProgressBar } from "./progress.js";
|
|
8
|
+
export async function downloadFile(url, destination, label) {
|
|
9
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
10
|
+
const tmp = `${destination}.tmp`;
|
|
11
|
+
const response = await fetch(url, {
|
|
12
|
+
headers: { "user-agent": "yt2text/0.1" },
|
|
13
|
+
redirect: "follow",
|
|
14
|
+
});
|
|
15
|
+
if (!response.ok || !response.body) {
|
|
16
|
+
throw new Yt2TextError(`Failed to download ${url}: HTTP ${response.status}`, "DOWNLOAD_FAILED");
|
|
17
|
+
}
|
|
18
|
+
await rm(tmp, { force: true });
|
|
19
|
+
const totalHeader = response.headers.get("content-length");
|
|
20
|
+
const total = totalHeader ? Number(totalHeader) : undefined;
|
|
21
|
+
let downloaded = 0;
|
|
22
|
+
const progress = new ProgressBar(label ?? "Downloading file");
|
|
23
|
+
const meter = new Transform({
|
|
24
|
+
transform(chunk, _encoding, callback) {
|
|
25
|
+
downloaded += chunk.length;
|
|
26
|
+
progress.updateBytes(downloaded, total);
|
|
27
|
+
callback(null, chunk);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
await pipeline(Readable.fromWeb(response.body), meter, createWriteStream(tmp));
|
|
32
|
+
progress.finish();
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
progress.fail();
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
await rename(tmp, destination);
|
|
39
|
+
if (process.platform !== "win32") {
|
|
40
|
+
await chmod(destination, 0o755);
|
|
41
|
+
}
|
|
42
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export class Yt2TextError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(message, code = "YT2TEXT_ERROR") {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function asError(error) {
|
|
9
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
10
|
+
}
|
|
11
|
+
export function explainError(error) {
|
|
12
|
+
const err = asError(error);
|
|
13
|
+
const message = err.message;
|
|
14
|
+
const lower = message.toLowerCase();
|
|
15
|
+
const hints = [];
|
|
16
|
+
if (lower.includes("could not find") && lower.includes("cookies")) {
|
|
17
|
+
hints.push("Cookie 读取失败:确认浏览器已安装并登录 YouTube,或改用 --cookies <file> 指定 cookies.txt。");
|
|
18
|
+
}
|
|
19
|
+
if (lower.includes("permission") && lower.includes("cookies")) {
|
|
20
|
+
hints.push("Cookie 权限失败:关闭浏览器后重试,或导出 cookies.txt 后用 --cookies 指定。");
|
|
21
|
+
}
|
|
22
|
+
if (lower.includes("database is locked") || (lower.includes("sqlite") && lower.includes("locked"))) {
|
|
23
|
+
hints.push("Cookie 数据库被浏览器锁定:完全退出浏览器后重试。");
|
|
24
|
+
}
|
|
25
|
+
if (lower.includes("sign in to confirm") || lower.includes("not a bot")) {
|
|
26
|
+
hints.push("YouTube 要求登录/验证:请使用 --cookies-from-browser chrome/safari/firefox,或在交互模式里选择浏览器 cookies。");
|
|
27
|
+
}
|
|
28
|
+
if (lower.includes("http error 403") || lower.includes("forbidden")) {
|
|
29
|
+
hints.push("下载被拒绝:先试浏览器 cookies;如果仍失败,运行 yt2text doctor 检查 yt-dlp,并可用 --update-ytdlp 更新下载器。");
|
|
30
|
+
}
|
|
31
|
+
if (lower.includes("yt-dlp") && lower.includes("not cached") && lower.includes("offline")) {
|
|
32
|
+
hints.push("当前是离线模式但 yt-dlp 尚未缓存:去掉 --offline 让工具自动下载依赖。");
|
|
33
|
+
}
|
|
34
|
+
if (lower.includes("ffmpeg was not found")) {
|
|
35
|
+
hints.push("ffmpeg 不可用:重新 npm install,或安装系统 ffmpeg 后重试。");
|
|
36
|
+
}
|
|
37
|
+
if (lower.includes("swiftc was not found")) {
|
|
38
|
+
hints.push("macOS 系统语音 helper 需要 Swift 编译器:yt2text 会自动尝试打开 Xcode Command Line Tools 安装器;安装完成后重试。");
|
|
39
|
+
}
|
|
40
|
+
if (lower.includes("xcode command line tools installer was opened")) {
|
|
41
|
+
hints.push("请在 macOS 弹出的安装窗口里完成 Xcode Command Line Tools 安装,完成后重新运行刚才的 yt2text 命令。");
|
|
42
|
+
}
|
|
43
|
+
if (lower.includes("xcode-select --install")) {
|
|
44
|
+
hints.push("如果自动打开失败,可手动运行 xcode-select --install。");
|
|
45
|
+
}
|
|
46
|
+
if (lower.includes("tar was not found") || lower.includes("could not extract")) {
|
|
47
|
+
hints.push("模型解压失败:需要系统 tar 支持 .tar.bz2;macOS/Linux 通常自带,Windows 建议安装 bsdtar/libarchive 或先用 --asr system。");
|
|
48
|
+
}
|
|
49
|
+
if (lower.includes("timed out after")) {
|
|
50
|
+
hints.push("任务超时:可以调大 --download-timeout、--convert-timeout 或 --asr-chunk-timeout;网络下载慢时也可设置 --proxy。");
|
|
51
|
+
}
|
|
52
|
+
if (lower.includes("input media file not found")) {
|
|
53
|
+
hints.push("本地文件路径不可读:确认当前目录,或使用绝对路径。");
|
|
54
|
+
}
|
|
55
|
+
if (lower.includes("invalid config")) {
|
|
56
|
+
hints.push("配置文件字段名、类型或枚举值不正确:运行 yt2text config --print 查看可用字段。");
|
|
57
|
+
}
|
|
58
|
+
if (lower.includes("enotfound") || lower.includes("econnreset") || lower.includes("etimedout")) {
|
|
59
|
+
hints.push("网络连接失败:检查代理/网络,或设置 --proxy;依赖和模型只在首次使用时下载。");
|
|
60
|
+
}
|
|
61
|
+
if (lower.includes("whisper") && lower.includes("not cached") && lower.includes("offline")) {
|
|
62
|
+
hints.push("本地 ASR 模型未缓存:去掉 --offline 让工具自动下载模型,或先运行一次 --multilingual。");
|
|
63
|
+
}
|
|
64
|
+
if (lower.includes("sherpa-onnx-node is not installed")) {
|
|
65
|
+
hints.push("本地 ASR 依赖缺失:重新运行 npm install,避免使用 --no-optional;也可以先用 --asr system。");
|
|
66
|
+
}
|
|
67
|
+
return hints.length > 0 ? `${message}\n\n${hints.map((hint) => `Hint: ${hint}`).join("\n")}` : message;
|
|
68
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { access, mkdir, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { downloadFile } from "./download-file.js";
|
|
5
|
+
import { ProgressBar } from "./progress.js";
|
|
6
|
+
import { runCommand } from "./process.js";
|
|
7
|
+
import { asError, Yt2TextError } from "./errors.js";
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
async function exists(path) {
|
|
10
|
+
try {
|
|
11
|
+
await access(path);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function nonEmptyFile(path) {
|
|
19
|
+
try {
|
|
20
|
+
return (await stat(path)).size > 0;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function extractTar(archive, outputDir) {
|
|
27
|
+
try {
|
|
28
|
+
await runCommand("tar", ["-xjf", archive, "-C", outputDir], { quiet: true });
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
throw new Yt2TextError(`Could not extract ${archive}. Install a tar implementation with bzip2 support and retry.\n${asError(error).message}`, "EXTRACT_FAILED");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function modelFolder(model) {
|
|
35
|
+
return `sherpa-onnx-whisper-${model}`;
|
|
36
|
+
}
|
|
37
|
+
async function whisperModelReady(folder, model) {
|
|
38
|
+
return ((await nonEmptyFile(join(folder, `${model}-encoder.int8.onnx`))) &&
|
|
39
|
+
(await nonEmptyFile(join(folder, `${model}-decoder.int8.onnx`))) &&
|
|
40
|
+
(await nonEmptyFile(join(folder, `${model}-tokens.txt`))));
|
|
41
|
+
}
|
|
42
|
+
async function ensureWhisperModel(paths, options) {
|
|
43
|
+
const folder = join(paths.modelsDir, modelFolder(options.model));
|
|
44
|
+
if (await whisperModelReady(folder, options.model))
|
|
45
|
+
return folder;
|
|
46
|
+
if (options.offline) {
|
|
47
|
+
throw new Yt2TextError(`Whisper ${options.model} model is not cached and --offline was set`, "MISSING_MODEL");
|
|
48
|
+
}
|
|
49
|
+
if (await exists(folder)) {
|
|
50
|
+
console.error(`Removing incomplete local ASR model cache: ${folder}`);
|
|
51
|
+
await rm(folder, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
await mkdir(paths.modelsDir, { recursive: true });
|
|
54
|
+
const archive = join(paths.modelsDir, `${modelFolder(options.model)}.tar.bz2`);
|
|
55
|
+
const url = `https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/${modelFolder(options.model)}.tar.bz2`;
|
|
56
|
+
console.error(`Downloading local ASR model ${options.model}`);
|
|
57
|
+
await downloadFile(url, archive, `Downloading Whisper ${options.model}`);
|
|
58
|
+
await extractTar(archive, paths.modelsDir);
|
|
59
|
+
await rm(archive, { force: true });
|
|
60
|
+
if (!(await whisperModelReady(folder, options.model))) {
|
|
61
|
+
throw new Yt2TextError(`Downloaded Whisper ${options.model} model is incomplete`, "MISSING_MODEL");
|
|
62
|
+
}
|
|
63
|
+
return folder;
|
|
64
|
+
}
|
|
65
|
+
async function ensureVadModel(paths, options) {
|
|
66
|
+
const model = join(paths.modelsDir, "silero_vad.onnx");
|
|
67
|
+
if (await nonEmptyFile(model))
|
|
68
|
+
return model;
|
|
69
|
+
if (options.offline)
|
|
70
|
+
throw new Yt2TextError("VAD model is not cached and --offline was set", "MISSING_MODEL");
|
|
71
|
+
await downloadFile("https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/silero_vad.onnx", model, "Downloading VAD model");
|
|
72
|
+
if (!(await nonEmptyFile(model)))
|
|
73
|
+
throw new Yt2TextError("Downloaded VAD model is empty", "MISSING_MODEL");
|
|
74
|
+
return model;
|
|
75
|
+
}
|
|
76
|
+
function loadSherpa() {
|
|
77
|
+
try {
|
|
78
|
+
return require("sherpa-onnx-node");
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
throw new Yt2TextError("sherpa-onnx-node is not installed for this platform; reinstall without --no-optional or use --asr system.", "MISSING_DEPENDENCY");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function whisperLanguage(locale) {
|
|
85
|
+
const normalized = locale.trim().toLowerCase();
|
|
86
|
+
if (!normalized || normalized === "auto")
|
|
87
|
+
return undefined;
|
|
88
|
+
return normalized.split(/[-_]/)[0];
|
|
89
|
+
}
|
|
90
|
+
export async function transcribeWithLocal(wavPath, paths, options) {
|
|
91
|
+
const sherpa = loadSherpa();
|
|
92
|
+
const modelDir = await ensureWhisperModel(paths, options);
|
|
93
|
+
const vadModel = await ensureVadModel(paths, options);
|
|
94
|
+
const prefix = options.model;
|
|
95
|
+
const recognizer = new sherpa.OfflineRecognizer({
|
|
96
|
+
featConfig: { sampleRate: 16000, featureDim: 80 },
|
|
97
|
+
modelConfig: {
|
|
98
|
+
whisper: {
|
|
99
|
+
encoder: join(modelDir, `${prefix}-encoder.int8.onnx`),
|
|
100
|
+
decoder: join(modelDir, `${prefix}-decoder.int8.onnx`),
|
|
101
|
+
language: whisperLanguage(options.language),
|
|
102
|
+
task: "transcribe",
|
|
103
|
+
},
|
|
104
|
+
tokens: join(modelDir, `${prefix}-tokens.txt`),
|
|
105
|
+
numThreads: Math.max(1, Math.min(4, Math.floor((await import("node:os")).cpus().length / 2))),
|
|
106
|
+
provider: "cpu",
|
|
107
|
+
debug: 0,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
const vad = new sherpa.Vad({
|
|
111
|
+
sileroVad: {
|
|
112
|
+
model: vadModel,
|
|
113
|
+
threshold: 0.5,
|
|
114
|
+
minSpeechDuration: 0.25,
|
|
115
|
+
minSilenceDuration: 0.5,
|
|
116
|
+
maxSpeechDuration: 15,
|
|
117
|
+
windowSize: 512,
|
|
118
|
+
},
|
|
119
|
+
sampleRate: 16000,
|
|
120
|
+
debug: false,
|
|
121
|
+
numThreads: 1,
|
|
122
|
+
}, 120);
|
|
123
|
+
const wave = sherpa.readWave(wavPath);
|
|
124
|
+
const segments = [];
|
|
125
|
+
const windowSize = vad.config.sileroVad.windowSize;
|
|
126
|
+
const progress = new ProgressBar("Transcribing audio");
|
|
127
|
+
const drain = () => {
|
|
128
|
+
while (!vad.isEmpty()) {
|
|
129
|
+
const segment = vad.front();
|
|
130
|
+
vad.pop();
|
|
131
|
+
const stream = recognizer.createStream();
|
|
132
|
+
stream.acceptWaveform({ samples: segment.samples, sampleRate: wave.sampleRate });
|
|
133
|
+
recognizer.decode(stream);
|
|
134
|
+
const result = recognizer.getResult(stream);
|
|
135
|
+
const text = String(result.text ?? "").trim();
|
|
136
|
+
if (!text)
|
|
137
|
+
continue;
|
|
138
|
+
const start = segment.start / wave.sampleRate;
|
|
139
|
+
segments.push({
|
|
140
|
+
start,
|
|
141
|
+
end: start + segment.samples.length / wave.sampleRate,
|
|
142
|
+
text,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
try {
|
|
147
|
+
for (let offset = 0; offset < wave.samples.length; offset += windowSize) {
|
|
148
|
+
vad.acceptWaveform(wave.samples.subarray(offset, offset + windowSize));
|
|
149
|
+
drain();
|
|
150
|
+
progress.update(offset / wave.samples.length);
|
|
151
|
+
}
|
|
152
|
+
vad.flush();
|
|
153
|
+
drain();
|
|
154
|
+
progress.finish();
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
progress.fail();
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
return { engine: `sherpa-onnx-whisper-${options.model}`, segments };
|
|
161
|
+
}
|
|
162
|
+
export async function diarizeWithLocal(wavPath, paths, options) {
|
|
163
|
+
const sherpa = loadSherpa();
|
|
164
|
+
const segmentation = await ensureDiarizationSegmentation(paths, options);
|
|
165
|
+
const embedding = await ensureDiarizationEmbedding(paths, options);
|
|
166
|
+
const diarizer = new sherpa.OfflineSpeakerDiarization({
|
|
167
|
+
segmentation: { pyannote: { model: join(segmentation, "model.onnx") } },
|
|
168
|
+
embedding: { model: embedding },
|
|
169
|
+
clustering: { numClusters: -1, threshold: 0.5 },
|
|
170
|
+
minDurationOn: 0.2,
|
|
171
|
+
minDurationOff: 0.5,
|
|
172
|
+
});
|
|
173
|
+
const wave = sherpa.readWave(wavPath);
|
|
174
|
+
const raw = diarizer.process(wave.samples);
|
|
175
|
+
return raw.map((segment, index) => ({
|
|
176
|
+
start: Number(segment.start ?? segment.begin ?? 0),
|
|
177
|
+
end: Number(segment.end ?? segment.stop ?? 0),
|
|
178
|
+
speaker: normalizeSpeaker(segment.speaker ?? segment.label ?? segment.cluster ?? index),
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
async function ensureDiarizationSegmentation(paths, options) {
|
|
182
|
+
const folder = join(paths.modelsDir, "sherpa-onnx-pyannote-segmentation-3-0");
|
|
183
|
+
if (await nonEmptyFile(join(folder, "model.onnx")))
|
|
184
|
+
return folder;
|
|
185
|
+
if (options.offline)
|
|
186
|
+
throw new Yt2TextError("Diarization segmentation model is not cached", "MISSING_MODEL");
|
|
187
|
+
if (await exists(folder)) {
|
|
188
|
+
console.error(`Removing incomplete diarization segmentation cache: ${folder}`);
|
|
189
|
+
await rm(folder, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
const archive = join(paths.modelsDir, `${basename(folder)}.tar.bz2`);
|
|
192
|
+
console.error("Downloading speaker diarization segmentation model");
|
|
193
|
+
await downloadFile("https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-segmentation-models/sherpa-onnx-pyannote-segmentation-3-0.tar.bz2", archive, "Downloading diarization segmentation");
|
|
194
|
+
await extractTar(archive, paths.modelsDir);
|
|
195
|
+
await rm(archive, { force: true });
|
|
196
|
+
if (!(await nonEmptyFile(join(folder, "model.onnx")))) {
|
|
197
|
+
throw new Yt2TextError("Downloaded diarization segmentation model is incomplete", "MISSING_MODEL");
|
|
198
|
+
}
|
|
199
|
+
return folder;
|
|
200
|
+
}
|
|
201
|
+
async function ensureDiarizationEmbedding(paths, options) {
|
|
202
|
+
const model = join(paths.modelsDir, "3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx");
|
|
203
|
+
if (await nonEmptyFile(model))
|
|
204
|
+
return model;
|
|
205
|
+
if (options.offline)
|
|
206
|
+
throw new Yt2TextError("Diarization embedding model is not cached", "MISSING_MODEL");
|
|
207
|
+
console.error("Downloading speaker diarization embedding model");
|
|
208
|
+
await downloadFile("https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx", model, "Downloading diarization embedding");
|
|
209
|
+
if (!(await nonEmptyFile(model)))
|
|
210
|
+
throw new Yt2TextError("Downloaded diarization embedding model is empty", "MISSING_MODEL");
|
|
211
|
+
return model;
|
|
212
|
+
}
|
|
213
|
+
function normalizeSpeaker(value) {
|
|
214
|
+
const raw = String(value);
|
|
215
|
+
const match = raw.match(/\d+/);
|
|
216
|
+
const index = match ? Number(match[0]) + 1 : 1;
|
|
217
|
+
return `Speaker ${index}`;
|
|
218
|
+
}
|