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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { runCommand } from "./process.js";
|
|
5
|
+
import { ensureMacosSwiftc } from "./deps.js";
|
|
6
|
+
import { splitWav } from "./media.js";
|
|
7
|
+
import { mergeWordSegments } from "./segments.js";
|
|
8
|
+
import { ProgressBar } from "./progress.js";
|
|
9
|
+
import { Yt2TextError } from "./errors.js";
|
|
10
|
+
const sourceDir = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const projectRoot = resolve(sourceDir, "..");
|
|
12
|
+
const macosHelperVersion = "2";
|
|
13
|
+
async function exists(path) {
|
|
14
|
+
try {
|
|
15
|
+
await access(path);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function isNoSpeechError(message) {
|
|
23
|
+
return /No speech detected|kAFAssistantErrorDomain Code=1110|Speech recognition produced no result/i.test(message);
|
|
24
|
+
}
|
|
25
|
+
function timeoutMs(seconds) {
|
|
26
|
+
return seconds > 0 ? seconds * 1000 : undefined;
|
|
27
|
+
}
|
|
28
|
+
function offsetSegments(segments, offset) {
|
|
29
|
+
return segments.map((segment) => ({
|
|
30
|
+
...segment,
|
|
31
|
+
start: segment.start + offset,
|
|
32
|
+
end: segment.end + offset,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
async function transcribeChunks(wavPath, paths, options, runChunk) {
|
|
36
|
+
const chunkDir = join(paths.workDir, "system-chunks");
|
|
37
|
+
await mkdir(chunkDir, { recursive: true });
|
|
38
|
+
const chunks = await splitWav(wavPath, chunkDir, options.chunkSeconds, options.convertTimeoutSeconds);
|
|
39
|
+
const all = [];
|
|
40
|
+
let engine = "system";
|
|
41
|
+
const progress = new ProgressBar("Transcribing audio");
|
|
42
|
+
try {
|
|
43
|
+
progress.update(0, `0/${chunks.length} chunks`);
|
|
44
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
45
|
+
const result = await runChunk(chunks[index]);
|
|
46
|
+
engine = result.engine ?? engine;
|
|
47
|
+
all.push(...offsetSegments(result.segments ?? [], index * options.chunkSeconds));
|
|
48
|
+
progress.update((index + 1) / chunks.length, `${index + 1}/${chunks.length} chunks`);
|
|
49
|
+
}
|
|
50
|
+
progress.finish();
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
progress.fail();
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
return { engine, segments: mergeWordSegments(all) };
|
|
57
|
+
}
|
|
58
|
+
export async function transcribeWithSystem(wavPath, paths, options) {
|
|
59
|
+
if (process.platform === "darwin") {
|
|
60
|
+
const helperApp = await ensureMacosSpeechHelper(paths, options);
|
|
61
|
+
return transcribeChunks(wavPath, paths, options, async (chunkPath) => {
|
|
62
|
+
const mode = options.systemMode;
|
|
63
|
+
const outputPath = join(paths.workDir, `macos-speech-${Date.now()}-${Math.random().toString(16).slice(2)}.json`);
|
|
64
|
+
await rm(outputPath, { force: true });
|
|
65
|
+
try {
|
|
66
|
+
await runCommand("open", ["-n", "-j", "-g", "-W", helperApp, "--args", chunkPath, options.language, mode, outputPath], {
|
|
67
|
+
quiet: true,
|
|
68
|
+
timeoutMs: timeoutMs(options.asrChunkTimeoutSeconds),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (!(await exists(outputPath)))
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
if (!(await exists(outputPath))) {
|
|
76
|
+
throw new Yt2TextError("macOS Speech helper did not write an output file", "SYSTEM_ASR_FAILED");
|
|
77
|
+
}
|
|
78
|
+
const body = await readFile(outputPath, "utf8");
|
|
79
|
+
const parsed = JSON.parse(body);
|
|
80
|
+
if (parsed.error) {
|
|
81
|
+
if (isNoSpeechError(parsed.error)) {
|
|
82
|
+
return {
|
|
83
|
+
engine: mode === "offline" ? "macos-speech-on-device" : "macos-speech-system",
|
|
84
|
+
language: options.language,
|
|
85
|
+
segments: [],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
throw new Yt2TextError(parsed.error, "SYSTEM_ASR_FAILED");
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
engine: parsed.engine ?? "macos-speech-system",
|
|
92
|
+
language: parsed.language ?? options.language,
|
|
93
|
+
segments: parsed.segments ?? [],
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (process.platform === "win32") {
|
|
98
|
+
if (options.systemMode === "online") {
|
|
99
|
+
throw new Yt2TextError("Windows system online file transcription is not available; use --system-mode offline, --system-mode auto, or --asr local.", "UNSUPPORTED_PROVIDER");
|
|
100
|
+
}
|
|
101
|
+
const helper = join(projectRoot, "helpers", "windows-transcribe.ps1");
|
|
102
|
+
return transcribeChunks(wavPath, paths, options, async (chunkPath) => {
|
|
103
|
+
const result = await runCommand("powershell.exe", [
|
|
104
|
+
"-NoProfile",
|
|
105
|
+
"-ExecutionPolicy",
|
|
106
|
+
"Bypass",
|
|
107
|
+
"-File",
|
|
108
|
+
helper,
|
|
109
|
+
"-AudioPath",
|
|
110
|
+
chunkPath,
|
|
111
|
+
"-Language",
|
|
112
|
+
options.language,
|
|
113
|
+
], { quiet: true, timeoutMs: timeoutMs(options.asrChunkTimeoutSeconds) });
|
|
114
|
+
return JSON.parse(result.stdout);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
throw new Yt2TextError("No cross-distro system ASR exists on Linux; use --asr local.", "UNSUPPORTED_PROVIDER");
|
|
118
|
+
}
|
|
119
|
+
async function ensureMacosSpeechHelper(paths, options) {
|
|
120
|
+
const app = join(paths.toolsDir, "macos-transcribe.app");
|
|
121
|
+
const binary = join(app, "Contents", "MacOS", "macos-transcribe");
|
|
122
|
+
const infoPlist = join(app, "Contents", "Info.plist");
|
|
123
|
+
const versionFile = join(app, "Contents", "Resources", "yt2text-helper-version");
|
|
124
|
+
if ((await exists(binary)) && (await exists(infoPlist)) && (await exists(versionFile))) {
|
|
125
|
+
const version = (await readFile(versionFile, "utf8")).trim();
|
|
126
|
+
if (version === macosHelperVersion)
|
|
127
|
+
return app;
|
|
128
|
+
}
|
|
129
|
+
const source = join(projectRoot, "helpers", "macos-transcribe.swift");
|
|
130
|
+
const plist = join(projectRoot, "helpers", "macos-speech-info.plist");
|
|
131
|
+
await ensureMacosSwiftc(options);
|
|
132
|
+
await rm(app, { recursive: true, force: true });
|
|
133
|
+
await mkdir(join(app, "Contents", "MacOS"), { recursive: true });
|
|
134
|
+
await mkdir(join(app, "Contents", "Resources"), { recursive: true });
|
|
135
|
+
await runCommand("swiftc", [
|
|
136
|
+
source,
|
|
137
|
+
"-o",
|
|
138
|
+
binary,
|
|
139
|
+
"-Xlinker",
|
|
140
|
+
"-sectcreate",
|
|
141
|
+
"-Xlinker",
|
|
142
|
+
"__TEXT",
|
|
143
|
+
"-Xlinker",
|
|
144
|
+
"__info_plist",
|
|
145
|
+
"-Xlinker",
|
|
146
|
+
plist,
|
|
147
|
+
], { quiet: true });
|
|
148
|
+
const { copyFile } = await import("node:fs/promises");
|
|
149
|
+
await copyFile(plist, infoPlist);
|
|
150
|
+
await writeFile(versionFile, `${macosHelperVersion}\n`);
|
|
151
|
+
return app;
|
|
152
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
3
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
4
|
+
<plist version="1.0">
|
|
5
|
+
<dict>
|
|
6
|
+
<key>CFBundleIdentifier</key>
|
|
7
|
+
<string>dev.yt2text.macos-transcribe</string>
|
|
8
|
+
<key>CFBundleName</key>
|
|
9
|
+
<string>yt2text macOS Speech Helper</string>
|
|
10
|
+
<key>CFBundleExecutable</key>
|
|
11
|
+
<string>macos-transcribe</string>
|
|
12
|
+
<key>CFBundlePackageType</key>
|
|
13
|
+
<string>APPL</string>
|
|
14
|
+
<key>LSUIElement</key>
|
|
15
|
+
<true/>
|
|
16
|
+
<key>NSSpeechRecognitionUsageDescription</key>
|
|
17
|
+
<string>yt2text uses macOS Speech Recognition to transcribe audio files locally when requested.</string>
|
|
18
|
+
<key>NSMicrophoneUsageDescription</key>
|
|
19
|
+
<string>yt2text does not record microphone audio, but macOS Speech may require this usage string.</string>
|
|
20
|
+
</dict>
|
|
21
|
+
</plist>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Speech
|
|
3
|
+
|
|
4
|
+
struct Segment: Encodable {
|
|
5
|
+
let start: Double
|
|
6
|
+
let end: Double
|
|
7
|
+
let text: String
|
|
8
|
+
let confidence: Float?
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
struct Output: Encodable {
|
|
12
|
+
let engine: String
|
|
13
|
+
let language: String
|
|
14
|
+
let segments: [Segment]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
enum HelperError: Error, CustomStringConvertible {
|
|
18
|
+
case badArguments
|
|
19
|
+
case authorizationDenied
|
|
20
|
+
case recognizerUnavailable
|
|
21
|
+
case noResult
|
|
22
|
+
|
|
23
|
+
var description: String {
|
|
24
|
+
switch self {
|
|
25
|
+
case .badArguments:
|
|
26
|
+
return "Usage: macos-transcribe <audio.wav> <locale> <online|offline|auto> [output.json]"
|
|
27
|
+
case .authorizationDenied:
|
|
28
|
+
return "Speech recognition authorization was denied"
|
|
29
|
+
case .recognizerUnavailable:
|
|
30
|
+
return "SFSpeechRecognizer is unavailable for the requested locale"
|
|
31
|
+
case .noResult:
|
|
32
|
+
return "Speech recognition produced no result"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func requestAuthorization() async -> Bool {
|
|
38
|
+
await withCheckedContinuation { continuation in
|
|
39
|
+
SFSpeechRecognizer.requestAuthorization { status in
|
|
40
|
+
continuation.resume(returning: status == .authorized)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func recognize(path: String, localeID: String, mode: String) async throws -> Output {
|
|
46
|
+
guard await requestAuthorization() else {
|
|
47
|
+
throw HelperError.authorizationDenied
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let locale = Locale(identifier: localeID)
|
|
51
|
+
guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else {
|
|
52
|
+
throw HelperError.recognizerUnavailable
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let request = SFSpeechURLRecognitionRequest(url: URL(fileURLWithPath: path))
|
|
56
|
+
request.shouldReportPartialResults = false
|
|
57
|
+
if mode == "offline" {
|
|
58
|
+
request.requiresOnDeviceRecognition = true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let result = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<SFSpeechRecognitionResult, Error>) in
|
|
62
|
+
var didFinish = false
|
|
63
|
+
recognizer.recognitionTask(with: request) { result, error in
|
|
64
|
+
if didFinish {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if let error = error {
|
|
69
|
+
didFinish = true
|
|
70
|
+
continuation.resume(throwing: error)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if let result = result, result.isFinal {
|
|
75
|
+
didFinish = true
|
|
76
|
+
continuation.resume(returning: result)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let segments = result.bestTranscription.segments.map { segment in
|
|
82
|
+
Segment(
|
|
83
|
+
start: segment.timestamp,
|
|
84
|
+
end: segment.timestamp + segment.duration,
|
|
85
|
+
text: segment.substring,
|
|
86
|
+
confidence: segment.confidence
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if segments.isEmpty {
|
|
91
|
+
throw HelperError.noResult
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let engine = mode == "offline" ? "macos-speech-on-device" : "macos-speech-system"
|
|
95
|
+
return Output(engine: engine, language: localeID, segments: segments)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
do {
|
|
99
|
+
guard CommandLine.arguments.count >= 4 else {
|
|
100
|
+
throw HelperError.badArguments
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let output = try await recognize(
|
|
104
|
+
path: CommandLine.arguments[1],
|
|
105
|
+
localeID: CommandLine.arguments[2],
|
|
106
|
+
mode: CommandLine.arguments[3]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
let encoder = JSONEncoder()
|
|
110
|
+
let data = try encoder.encode(output)
|
|
111
|
+
if CommandLine.arguments.count >= 5 {
|
|
112
|
+
try data.write(to: URL(fileURLWithPath: CommandLine.arguments[4]))
|
|
113
|
+
} else {
|
|
114
|
+
FileHandle.standardOutput.write(data)
|
|
115
|
+
FileHandle.standardOutput.write(Data("\n".utf8))
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
if CommandLine.arguments.count >= 5 {
|
|
119
|
+
let message = String(describing: error)
|
|
120
|
+
let escaped = message
|
|
121
|
+
.replacingOccurrences(of: "\\", with: "\\\\")
|
|
122
|
+
.replacingOccurrences(of: "\"", with: "\\\"")
|
|
123
|
+
.replacingOccurrences(of: "\n", with: "\\n")
|
|
124
|
+
let data = Data("{\"error\":\"\(escaped)\"}\n".utf8)
|
|
125
|
+
try? data.write(to: URL(fileURLWithPath: CommandLine.arguments[4]))
|
|
126
|
+
}
|
|
127
|
+
FileHandle.standardError.write(Data(String(describing: error).utf8))
|
|
128
|
+
FileHandle.standardError.write(Data("\n".utf8))
|
|
129
|
+
exit(1)
|
|
130
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[Parameter(Mandatory = $true)]
|
|
3
|
+
[string]$AudioPath,
|
|
4
|
+
|
|
5
|
+
[string]$Language = "zh-CN"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
$ErrorActionPreference = "Stop"
|
|
9
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
10
|
+
|
|
11
|
+
Add-Type -AssemblyName System.Speech
|
|
12
|
+
|
|
13
|
+
$cultureName = if ([string]::IsNullOrWhiteSpace($Language) -or $Language -eq "auto") { [System.Globalization.CultureInfo]::CurrentCulture.Name } else { $Language }
|
|
14
|
+
$culture = [System.Globalization.CultureInfo]::GetCultureInfo($cultureName)
|
|
15
|
+
$engine = New-Object System.Speech.Recognition.SpeechRecognitionEngine($culture)
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
$engine.LoadGrammar((New-Object System.Speech.Recognition.DictationGrammar))
|
|
19
|
+
$engine.SetInputToWaveFile($AudioPath)
|
|
20
|
+
$segments = New-Object System.Collections.Generic.List[object]
|
|
21
|
+
|
|
22
|
+
while ($true) {
|
|
23
|
+
$result = $engine.Recognize()
|
|
24
|
+
if ($null -eq $result) {
|
|
25
|
+
break
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
foreach ($word in $result.Words) {
|
|
29
|
+
$segments.Add([pscustomobject]@{
|
|
30
|
+
start = $word.AudioPosition.TotalSeconds
|
|
31
|
+
end = ($word.AudioPosition + $word.AudioDuration).TotalSeconds
|
|
32
|
+
text = $word.Text
|
|
33
|
+
confidence = $word.Confidence
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
[pscustomobject]@{
|
|
39
|
+
engine = "windows-sapi"
|
|
40
|
+
language = $culture.Name
|
|
41
|
+
segments = $segments
|
|
42
|
+
} | ConvertTo-Json -Depth 6 -Compress
|
|
43
|
+
} finally {
|
|
44
|
+
$engine.Dispose()
|
|
45
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yt2text",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Download audio from YouTube and transcribe it with system or local ASR.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"homepage": "https://github.com/lopleec/yt2text#readme",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/lopleec/yt2text/issues"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/lopleec/yt2text.git"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"youtube",
|
|
16
|
+
"transcript",
|
|
17
|
+
"speech-to-text",
|
|
18
|
+
"asr",
|
|
19
|
+
"yt-dlp",
|
|
20
|
+
"cli"
|
|
21
|
+
],
|
|
22
|
+
"os": [
|
|
23
|
+
"darwin",
|
|
24
|
+
"linux",
|
|
25
|
+
"win32"
|
|
26
|
+
],
|
|
27
|
+
"bin": {
|
|
28
|
+
"yt2text": "./dist/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"helpers",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.json",
|
|
37
|
+
"dev": "tsx src/cli.ts",
|
|
38
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
39
|
+
"test": "npm run check",
|
|
40
|
+
"prepack": "npm run build",
|
|
41
|
+
"prepare": "npm run build"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
48
|
+
"commander": "^12.1.0"
|
|
49
|
+
},
|
|
50
|
+
"optionalDependencies": {
|
|
51
|
+
"sherpa-onnx-node": "^1.13.3"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^22.10.2",
|
|
58
|
+
"tsx": "^4.19.2",
|
|
59
|
+
"typescript": "^5.7.2"
|
|
60
|
+
},
|
|
61
|
+
"license": "MIT"
|
|
62
|
+
}
|