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.
@@ -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
+ }