Last active
August 18, 2026 00:25
-
-
Save meoyawn/7daf6b80425164daddf715cdb4120e0f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bun | |
| import { $ } from "bun"; | |
| import { createHash, randomUUID } from "node:crypto"; | |
| import { mkdir, readFile, rename, stat, unlink } from "node:fs/promises"; | |
| import { dirname, join, resolve } from "node:path"; | |
| const FLUIDAUDIO_PATH = `${process.env["HOME"] ?? ""}/Developer/github/FluidAudio/.build/release/fluidaudiocli`; | |
| const TEMP_DIRECTORY = "/tmp"; | |
| const CACHE_DIRECTORY = resolve( | |
| process.env["TRANSCRIBE_CACHE_DIR"] ?? join(TEMP_DIRECTORY, "transcribe-cache"), | |
| ); | |
| if (!CACHE_DIRECTORY.startsWith(`${TEMP_DIRECTORY}/`)) { | |
| throw new Error(`TRANSCRIBE_CACHE_DIR must be inside ${TEMP_DIRECTORY}: ${CACHE_DIRECTORY}`); | |
| } | |
| const YOUTUBE_TRANSCRIPTS_DIRECTORY = join(CACHE_DIRECTORY, "youtube-transcripts"); | |
| const DOWNLOADS_DIRECTORY = join(CACHE_DIRECTORY, "media"); | |
| const URL_MAPPINGS_DIRECTORY = join(CACHE_DIRECTORY, "url-to-media"); | |
| const TRANSCRIPT_MAPPINGS_DIRECTORY = join(CACHE_DIRECTORY, "media-to-transcript"); | |
| const TRANSCRIPTS_DIRECTORY = join(CACHE_DIRECTORY, "transcripts"); | |
| function usage(): never { | |
| console.error("Usage: scripts/transcribe.ts [--file] <url>"); | |
| console.error(" scripts/transcribe.ts --help"); | |
| process.exit(2); | |
| } | |
| function printHelp(): never { | |
| console.log(`Usage: | |
| scripts/transcribe.ts [--file] <url> | |
| scripts/transcribe.ts --help | |
| Prints a URL's transcript to stdout. YouTube URLs use available captions; other URLs download audio with yt-dlp and transcribe it with FluidAudio. Transcript .txt files are cached under /tmp/transcribe-cache. | |
| Arguments: | |
| <url> Media URL. Must use http or https. | |
| Options: | |
| --file Print the absolute cached transcript path instead of its contents. | |
| -h, --help Print this help. | |
| `); | |
| process.exit(0); | |
| } | |
| function fail(message: string): never { | |
| console.error(message); | |
| process.exit(1); | |
| } | |
| function parseArgs(): { url: URL; printFile: boolean } { | |
| const args = Bun.argv.slice(2); | |
| if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) { | |
| printHelp(); | |
| } | |
| const printFile = args.includes("--file"); | |
| const urlArgs = args.filter((arg) => arg !== "--file"); | |
| if (urlArgs.length !== 1 || args.length !== urlArgs.length + (printFile ? 1 : 0)) { | |
| usage(); | |
| } | |
| const url = urlArgs[0]; | |
| if (url === undefined) { | |
| usage(); | |
| } | |
| try { | |
| const parsedUrl = new URL(url); | |
| if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { | |
| fail(`URL must use http or https: ${url}`); | |
| } | |
| return { url: parsedUrl, printFile }; | |
| } catch { | |
| fail(`Invalid URL: ${url}`); | |
| } | |
| } | |
| interface CaptionFormat { | |
| ext?: string; | |
| url?: string; | |
| name?: string; | |
| } | |
| interface CaptionCandidate { | |
| language: string; | |
| kind: "subtitles" | "automatic_captions"; | |
| format: CaptionFormat; | |
| original: boolean; | |
| score: number; | |
| } | |
| interface TranscriptSegment { | |
| startTimeMs?: number; | |
| text: string; | |
| } | |
| function commandErrorMessage(error: unknown, fallback: string): string { | |
| if (typeof error === "object" && error !== null) { | |
| const stderr = "stderr" in error ? String(error.stderr).trim() : ""; | |
| const stdout = "stdout" in error ? String(error.stdout).trim() : ""; | |
| const detail = stderr || stdout; | |
| if (detail.length > 0) { | |
| return `${fallback}:\n${detail}`; | |
| } | |
| } | |
| return error instanceof Error ? error.message : fallback; | |
| } | |
| const cacheKey = (value: string): string => createHash("sha256").update(value).digest("hex"); | |
| const urlMappingPath = (url: string): string => | |
| join(URL_MAPPINGS_DIRECTORY, `${cacheKey(url)}.path`); | |
| const transcriptMappingPath = (mediaPath: string): string => | |
| join(TRANSCRIPT_MAPPINGS_DIRECTORY, `${cacheKey(mediaPath)}.path`); | |
| const defaultTranscriptPath = (mediaPath: string): string => | |
| join(TRANSCRIPTS_DIRECTORY, `${cacheKey(mediaPath)}.txt`); | |
| const youtubeTranscriptPath = (url: string): string => | |
| join(YOUTUBE_TRANSCRIPTS_DIRECTORY, `${cacheKey(url)}.txt`); | |
| const isYouTubeUrl = (url: URL): boolean => | |
| url.hostname === "youtu.be" || | |
| url.hostname === "youtube.com" || | |
| url.hostname.endsWith(".youtube.com") || | |
| url.hostname === "youtube-nocookie.com" || | |
| url.hostname.endsWith(".youtube-nocookie.com"); | |
| function isRecord(value: unknown): value is Record<string, unknown> { | |
| return typeof value === "object" && value !== null && !Array.isArray(value); | |
| } | |
| const getString = (value: unknown): string | null => (typeof value === "string" ? value : null); | |
| function getCaptionMap(value: unknown): Record<string, CaptionFormat[]> { | |
| if (!isRecord(value)) { | |
| return {}; | |
| } | |
| const captions: Record<string, CaptionFormat[]> = {}; | |
| for (const [language, formats] of Object.entries(value)) { | |
| if (!Array.isArray(formats)) { | |
| continue; | |
| } | |
| const parsedFormats: CaptionFormat[] = []; | |
| for (const format of formats) { | |
| if (!isRecord(format)) { | |
| continue; | |
| } | |
| const url = getString(format["url"]); | |
| if (url === null) { | |
| continue; | |
| } | |
| parsedFormats.push({ | |
| ext: getString(format["ext"]) ?? undefined, | |
| url, | |
| name: getString(format["name"]) ?? undefined, | |
| }); | |
| } | |
| if (parsedFormats.length > 0) { | |
| captions[language] = parsedFormats; | |
| } | |
| } | |
| return captions; | |
| } | |
| function hasTranslatedLanguage(format: CaptionFormat): boolean { | |
| if (format.url === undefined) { | |
| return false; | |
| } | |
| try { | |
| return new URL(format.url).searchParams.has("tlang"); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function hasAsrKind(format: CaptionFormat): boolean { | |
| if (format.url === undefined) { | |
| return false; | |
| } | |
| try { | |
| return new URL(format.url).searchParams.get("kind") === "asr"; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function baseLanguage(language: string | null): string | null { | |
| if (language === null || language.length === 0) { | |
| return null; | |
| } | |
| return ( | |
| language | |
| .replace(/-orig$/, "") | |
| .split("-")[0] | |
| ?.toLowerCase() ?? null | |
| ); | |
| } | |
| function bestFormatScore(format: CaptionFormat): number { | |
| switch (format.ext) { | |
| case "json3": | |
| return 40; | |
| case "vtt": | |
| return 30; | |
| case "srt": | |
| return 20; | |
| case "ttml": | |
| return 10; | |
| default: | |
| return 0; | |
| } | |
| } | |
| function bestFormat(formats: CaptionFormat[]): CaptionFormat | undefined { | |
| return [...formats].sort((left, right) => bestFormatScore(right) - bestFormatScore(left))[0]; | |
| } | |
| function buildCandidates(info: Record<string, unknown>): CaptionCandidate[] { | |
| const preferredBaseLanguage = baseLanguage(getString(info["language"])); | |
| const candidates: CaptionCandidate[] = []; | |
| for (const [kind, captionMap] of [ | |
| ["subtitles", getCaptionMap(info["subtitles"])], | |
| ["automatic_captions", getCaptionMap(info["automatic_captions"])], | |
| ] as const) { | |
| for (const [language, formats] of Object.entries(captionMap)) { | |
| const format = bestFormat(formats); | |
| if (format === undefined) { | |
| continue; | |
| } | |
| const languageBase = baseLanguage(language); | |
| const original = | |
| !hasTranslatedLanguage(format) && (kind === "subtitles" || hasAsrKind(format)); | |
| const languageScore = | |
| preferredBaseLanguage !== null && languageBase === preferredBaseLanguage ? 100000 : 0; | |
| const originalScore = original ? 10000 : 0; | |
| const kindScore = kind === "subtitles" ? 1000 : 0; | |
| const englishFallbackScore = languageBase === "en" ? 25 : 0; | |
| candidates.push({ | |
| language, | |
| kind, | |
| format, | |
| original, | |
| score: | |
| languageScore + | |
| originalScore + | |
| kindScore + | |
| englishFallbackScore + | |
| bestFormatScore(format), | |
| }); | |
| } | |
| } | |
| return candidates.sort((left, right) => right.score - left.score); | |
| } | |
| function decodeEntities(text: string): string { | |
| return text | |
| .replaceAll("&", "&") | |
| .replaceAll(">", ">") | |
| .replaceAll("<", "<") | |
| .replaceAll(""", '"') | |
| .replaceAll("'", "'") | |
| .replaceAll("'", "'"); | |
| } | |
| const cleanText = (text: string): string => | |
| decodeEntities(text.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ")).trim(); | |
| const getNumber = (value: unknown): number | null => | |
| typeof value === "number" && Number.isFinite(value) ? value : null; | |
| function parseTimestamp(timestamp: string): number | null { | |
| const match = /^(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{3})$/.exec(timestamp); | |
| if (match === null) { | |
| return null; | |
| } | |
| const hours = Number(match[1] ?? 0); | |
| const minutes = Number(match[2]); | |
| const seconds = Number(match[3]); | |
| const milliseconds = Number(match[4]); | |
| return (hours * 60 * 60 + minutes * 60 + seconds) * 1000 + milliseconds; | |
| } | |
| function formatTimestamp(startTimeMs: number | undefined): string { | |
| if (startTimeMs === undefined) { | |
| return "[--:--]"; | |
| } | |
| const totalSeconds = Math.floor(startTimeMs / 1000); | |
| const hours = Math.floor(totalSeconds / 3600); | |
| const minutes = Math.floor((totalSeconds % 3600) / 60); | |
| const seconds = totalSeconds % 60; | |
| const time = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; | |
| return hours === 0 ? `[${time}]` : `[${String(hours).padStart(2, "0")}:${time}]`; | |
| } | |
| function parseJson3(raw: string): TranscriptSegment[] { | |
| const parsed: unknown = JSON.parse(raw); | |
| if (!isRecord(parsed) || !Array.isArray(parsed["events"])) { | |
| return []; | |
| } | |
| const segments: TranscriptSegment[] = []; | |
| for (const event of parsed["events"]) { | |
| if (!isRecord(event) || !Array.isArray(event["segs"])) { | |
| continue; | |
| } | |
| let text = ""; | |
| for (const segment of event["segs"]) { | |
| if (isRecord(segment) && typeof segment["utf8"] === "string") { | |
| text += segment["utf8"]; | |
| } | |
| } | |
| const cleaned = cleanText(text); | |
| if (cleaned.length > 0) { | |
| const startTimeMs = getNumber(event["tStartMs"]); | |
| segments.push({ ...(startTimeMs === null ? {} : { startTimeMs }), text: cleaned }); | |
| } | |
| } | |
| return segments; | |
| } | |
| function parseTimedText(raw: string): TranscriptSegment[] { | |
| const segments: TranscriptSegment[] = []; | |
| let textLines: string[] = []; | |
| let startTimeMs: number | undefined; | |
| function flush(): void { | |
| const text = cleanText(textLines.join(" ")); | |
| textLines = []; | |
| if (text.length === 0 || segments.at(-1)?.text === text) { | |
| return; | |
| } | |
| segments.push({ ...(startTimeMs === undefined ? {} : { startTimeMs }), text }); | |
| startTimeMs = undefined; | |
| } | |
| for (const line of raw.split(/\r?\n/)) { | |
| const trimmed = line.trim(); | |
| if (trimmed.length === 0) { | |
| flush(); | |
| continue; | |
| } | |
| if (trimmed === "WEBVTT" || trimmed.startsWith("Kind:") || trimmed.startsWith("Language:")) { | |
| continue; | |
| } | |
| if (/^\d+$/.test(trimmed)) { | |
| continue; | |
| } | |
| const timing = | |
| /^(\d{1,2}:\d{2}(?::\d{2})?[,.]\d{3})\s+-->\s+\d{1,2}:\d{2}(?::\d{2})?[,.]\d{3}/.exec( | |
| trimmed, | |
| ); | |
| if (timing !== null) { | |
| flush(); | |
| startTimeMs = parseTimestamp(timing[1] ?? "") ?? undefined; | |
| continue; | |
| } | |
| textLines.push(trimmed); | |
| } | |
| flush(); | |
| return segments; | |
| } | |
| function parseTranscript(raw: string, format: CaptionFormat): TranscriptSegment[] { | |
| if (format.ext === "json3") { | |
| return parseJson3(raw); | |
| } | |
| if (format.ext === "vtt" || format.ext === "srt" || format.ext === "ttml") { | |
| return parseTimedText(raw); | |
| } | |
| const text = cleanText(raw); | |
| return text.length === 0 ? [] : [{ text }]; | |
| } | |
| function isNotFoundError(error: unknown): boolean { | |
| return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; | |
| } | |
| async function isFile(path: string): Promise<boolean> { | |
| try { | |
| return (await stat(path)).isFile(); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| async function removeFileIfPresent(path: string): Promise<void> { | |
| try { | |
| await unlink(path); | |
| } catch (error) { | |
| if (!isNotFoundError(error)) { | |
| throw error; | |
| } | |
| } | |
| } | |
| async function writeFileAtomically(path: string, contents: string): Promise<void> { | |
| await mkdir(dirname(path), { recursive: true }); | |
| const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`; | |
| try { | |
| await Bun.write(temporaryPath, contents); | |
| await rename(temporaryPath, path); | |
| } finally { | |
| await removeFileIfPresent(temporaryPath); | |
| } | |
| } | |
| async function readYouTubeInfo(url: string): Promise<Record<string, unknown>> { | |
| try { | |
| const raw = | |
| await $`yt-dlp --skip-download --dump-single-json --no-warnings --no-playlist ${url}`.text(); | |
| const parsed: unknown = JSON.parse(raw); | |
| if (!isRecord(parsed)) { | |
| fail("yt-dlp returned non-object JSON"); | |
| } | |
| return parsed; | |
| } catch (error) { | |
| fail(commandErrorMessage(error, "yt-dlp failed")); | |
| } | |
| } | |
| async function transcribeYouTube(url: string): Promise<string> { | |
| const transcriptPath = youtubeTranscriptPath(url); | |
| if (await isFile(transcriptPath)) { | |
| return await readFile(transcriptPath, "utf8"); | |
| } | |
| const candidate = buildCandidates(await readYouTubeInfo(url))[0]; | |
| if (candidate === undefined || candidate.format.url === undefined) { | |
| fail("No transcript found"); | |
| } | |
| const response = await fetch(candidate.format.url); | |
| if (!response.ok) { | |
| fail(`Transcript download failed: HTTP ${response.status} ${response.statusText}`); | |
| } | |
| const segments = parseTranscript(await response.text(), candidate.format); | |
| if (segments.length === 0) { | |
| fail("Transcript parsed to zero text segments"); | |
| } | |
| const transcript = `${segments.map((segment) => `${formatTimestamp(segment.startTimeMs)} ${segment.text}`).join("\n")}\n`; | |
| await writeFileAtomically(transcriptPath, transcript); | |
| return transcript; | |
| } | |
| async function readCachedPath(mappingPath: string): Promise<string | undefined> { | |
| try { | |
| const path = await readFile(mappingPath, "utf8"); | |
| return path.length === 0 ? undefined : path; | |
| } catch (error) { | |
| if (isNotFoundError(error)) { | |
| return undefined; | |
| } | |
| throw error; | |
| } | |
| } | |
| async function cachedTranscriptPaths( | |
| url: string, | |
| ): Promise<{ mediaPath: string; transcriptPath: string } | undefined> { | |
| const mediaPath = await readCachedPath(urlMappingPath(url)); | |
| if (mediaPath === undefined) { | |
| return undefined; | |
| } | |
| const transcriptPath = await readCachedPath(transcriptMappingPath(mediaPath)); | |
| if (transcriptPath === undefined || !(await isFile(transcriptPath))) { | |
| return undefined; | |
| } | |
| return { mediaPath, transcriptPath }; | |
| } | |
| function downloadedPathFrom(stdout: string): string { | |
| const paths = stdout | |
| .split("\n") | |
| .map((line) => line.trim()) | |
| .filter((line) => line.length > 0); | |
| const path = paths.at(-1); | |
| if (path === undefined) { | |
| fail("yt-dlp did not report a downloaded file path"); | |
| } | |
| return path; | |
| } | |
| async function assertReadableFile(path: string, label: string): Promise<void> { | |
| try { | |
| const fileStat = await stat(path); | |
| if (!fileStat.isFile()) { | |
| fail(`${label} is not a file: ${path}`); | |
| } | |
| } catch { | |
| fail(`${label} not found: ${path}`); | |
| } | |
| } | |
| async function downloadAudio(url: string): Promise<string> { | |
| await mkdir(DOWNLOADS_DIRECTORY, { recursive: true }); | |
| const outputTemplate = join(DOWNLOADS_DIRECTORY, `${cacheKey(url)}.%(ext)s`); | |
| try { | |
| console.error("Downloading audio..."); | |
| const stdout = | |
| await $`yt-dlp --no-playlist --format bestaudio/best --extract-audio --audio-format wav --audio-quality 0 --output ${outputTemplate} --print after_move:filepath --no-warnings --no-progress ${url}`.text(); | |
| const audioPath = downloadedPathFrom(stdout); | |
| await assertReadableFile(audioPath, "Downloaded audio"); | |
| return audioPath; | |
| } catch (error) { | |
| fail(commandErrorMessage(error, "yt-dlp failed")); | |
| } | |
| } | |
| async function mediaPathFor(url: string): Promise<string> { | |
| const cachedMediaPath = await readCachedPath(urlMappingPath(url)); | |
| if (cachedMediaPath !== undefined && (await isFile(cachedMediaPath))) { | |
| return cachedMediaPath; | |
| } | |
| const mediaPath = await downloadAudio(url); | |
| await writeFileAtomically(urlMappingPath(url), mediaPath); | |
| return mediaPath; | |
| } | |
| async function transcriptPathFor(mediaPath: string): Promise<string> { | |
| const mappingPath = transcriptMappingPath(mediaPath); | |
| const cachedTranscriptPath = await readCachedPath(mappingPath); | |
| if (cachedTranscriptPath !== undefined) { | |
| return cachedTranscriptPath; | |
| } | |
| const transcriptPath = defaultTranscriptPath(mediaPath); | |
| await writeFileAtomically(mappingPath, transcriptPath); | |
| return transcriptPath; | |
| } | |
| async function transcribe(audioPath: string): Promise<string> { | |
| await assertReadableFile(FLUIDAUDIO_PATH, "FluidAudio CLI"); | |
| try { | |
| console.error("Transcribing audio..."); | |
| return await $`${FLUIDAUDIO_PATH} transcribe ${audioPath} --model-version v3`.text(); | |
| } catch (error) { | |
| fail(commandErrorMessage(error, "FluidAudio transcription failed")); | |
| } | |
| } | |
| async function transcribeOther(url: string): Promise<string> { | |
| const cachedPaths = await cachedTranscriptPaths(url); | |
| if (cachedPaths !== undefined) { | |
| await removeFileIfPresent(cachedPaths.mediaPath); | |
| return await readFile(cachedPaths.transcriptPath, "utf8"); | |
| } | |
| const mediaPath = await mediaPathFor(url); | |
| const transcriptPath = await transcriptPathFor(mediaPath); | |
| const transcript = await transcribe(mediaPath); | |
| try { | |
| await writeFileAtomically(transcriptPath, transcript); | |
| } finally { | |
| await removeFileIfPresent(mediaPath); | |
| } | |
| return await readFile(transcriptPath, "utf8"); | |
| } | |
| async function main(): Promise<void> { | |
| const { url, printFile } = parseArgs(); | |
| const youtube = isYouTubeUrl(url); | |
| const transcript = youtube ? await transcribeYouTube(url.href) : await transcribeOther(url.href); | |
| if (!printFile) { | |
| process.stdout.write(transcript); | |
| return; | |
| } | |
| if (youtube) { | |
| process.stdout.write(`${resolve(youtubeTranscriptPath(url.href))}\n`); | |
| return; | |
| } | |
| const cachedPaths = await cachedTranscriptPaths(url.href); | |
| if (cachedPaths === undefined) { | |
| fail("Cached transcript path not found"); | |
| } | |
| process.stdout.write(`${resolve(cachedPaths.transcriptPath)}\n`); | |
| } | |
| if (import.meta.main) { | |
| try { | |
| await main(); | |
| } catch (error) { | |
| fail(commandErrorMessage(error, "Transcription failed")); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment