Skip to content

Instantly share code, notes, and snippets.

@meoyawn
Last active August 18, 2026 00:25
Show Gist options
  • Select an option

  • Save meoyawn/7daf6b80425164daddf715cdb4120e0f to your computer and use it in GitHub Desktop.

Select an option

Save meoyawn/7daf6b80425164daddf715cdb4120e0f to your computer and use it in GitHub Desktop.
#!/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("&amp;", "&")
.replaceAll("&gt;", ">")
.replaceAll("&lt;", "<")
.replaceAll("&quot;", '"')
.replaceAll("&#39;", "'")
.replaceAll("&apos;", "'");
}
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