Repository: TejasQ/gen-subs Branch: main Commit: d26193de6d87 Files: 26 Total size: 71.6 KB Directory structure: gitextract_tnt85tu5/ ├── .github/ │ └── FUNDING.yml ├── .gitignore ├── .prettierrc ├── README.md ├── actions/ │ ├── burnIn.ts │ ├── embed.ts │ ├── for.ts │ ├── models.ts │ ├── modelsLs.ts │ └── modelsPurge.ts ├── burnInSubtitles.ts ├── cli.ts ├── createAssfromRecognitionResults.ts ├── createCueFromWords.ts ├── createSrtFromRecognitionResults.ts ├── createTextFromAudioFile.ts ├── downloadAndUnzip.ts ├── embedSubtitles.ts ├── extractAudio.ts ├── formatDuration.ts ├── loadModel.ts ├── package.json ├── processAudio.ts ├── splitFilePath.ts ├── tsconfig.json └── util.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: tejasq # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .gitignore ================================================ # Created by https://www.toptal.com/developers/gitignore/api/node,macos,next,typescript,react,visualstudiocode,nextjs,solidjs,qwik,mitosis # Edit at https://www.toptal.com/developers/gitignore?templates=node,macos,next,typescript,react,visualstudiocode,nextjs,solidjs,qwik,mitosis ### macOS ### # General .DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk ### macOS Patch ### # iCloud generated files *.icloud #!! ERROR: mitosis is undefined. Use list command to see defined gitignore types !!# #!! ERROR: next is undefined. Use list command to see defined gitignore types !!# ### NextJS ### # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js /.next/ /out/ # production /build # misc *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts ### Node ### # Logs logs *.log lerna-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # vuepress v2.x temp and cache directory .temp # Docusaurus cache and generated files .docusaurus # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* ### Node Patch ### # Serverless Webpack directories .webpack/ # Optional stylelint cache # SvelteKit build / generate output .svelte-kit #!! ERROR: qwik is undefined. Use list command to see defined gitignore types !!# ### react ### .DS_* **/*.backup.* **/*.back.* node_modules *.sublime* psd thumb sketch #!! ERROR: solidjs is undefined. Use list command to see defined gitignore types !!# #!! ERROR: typescript is undefined. Use list command to see defined gitignore types !!# ### VisualStudioCode ### .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json !.vscode/*.code-snippets # Local History for Visual Studio Code .history/ # Built Visual Studio Code Extensions *.vsix ### VisualStudioCode Patch ### # Ignore all local history of files .history .ionide # End of https://www.toptal.com/developers/gitignore/api/node,macos,next,typescript,react,visualstudiocode,nextjs,solidjs,qwik,mitosis models *.mp4 *.wav model *.mov postcss.config.js postcss.config.cjs ================================================ FILE: .prettierrc ================================================ { "printWidth": 80 } ================================================ FILE: README.md ================================================ # `gen-subs` This project uses on-device machine learning models to generate subtitles for your videos. https://github.com/TejasQ/gen-subs/assets/9947422/bc8df523-b62a-4123-a62d-2df17832e2ac ## Features - 🔒 **Secure and offline** - All machine learning models are downloaded and run locally on your device. No data is sent to any server. There is zero dependency on OpenAI or other cloud services. - 🌐 **Multilingual** - Supports a wide variety of languages. Namely, | Languages | | | | --------- | --------- | --------- | | 🇺🇸 English | 🇮🇳 Indian English | 🇨🇳 Chinese | | 🇷🇺 Russian | 🇫🇷 French | 🇩🇪 German | | 🇪🇸 Spanish | 🇵🇹 Portuguese/Brazilian | 🇬🇷 Greek | | 🇹🇷 Turkish | 🇻🇳 Vietnamese | 🇮🇹 Italian | | 🇳🇱 Dutch | 🇪🇸 Catalan | 🇸🇦 Arabic | | 🇮🇷 Farsi | 🇵🇭 Filipino | 🇺🇦 Ukrainian | | 🇰🇿 Kazakh | 🇸🇪 Swedish | 🇯🇵 Japanese | | 🇪🇸 Esperanto | 🇮🇳 Hindi | 🇨🇿 Czech | | 🇵🇱 Polish | 🇺🇿 Uzbek | 🇰🇷 Korean | | 🇫🇷 Breton | | | - 🎨 **Customizable** - Choose from getting just an `srt` file, having the subtitles burned in to your video, and even embedding the subtitles in your video's metadata. You can also have **focus words** where the active word is highlighted in a different color. - 🎧 **Multi-modal** - Supports both audio and video files and generates subtitles for each. - 📊 **Multi-model** - Choose from a variety of machine learning models ranging from 40MB to >2GB in size. The larger the model, the more accurate the subtitles, but smaller models are also quite capable. ## Usage You can generate subtitles for any video using the following command: ```bash npx gen-subs for ./your/video.mp4 ``` If you run this for the first time, you will be required to download a machine learning model to generate your subtitles. This needs to be done at least one time. Then, the program will generate a `.srt` file in your current working directory containing the subtitles for your video. ### Inaccuracies Please note that you may get inaccurate results with the default, basic English model. This model is 40MB and is meant to be a quick way to get started. It's not very smart, so your mileage may vary. If you'd like more accurate results, you can download a larger model by running the following command: ```bash npx gen-subs models ``` This will have you choose a language and then show you a collection of models, their sizes, and intended use cases (like podcasting, content, etc.). You can then choose a model and download it. Once downloaded, you can use it to generate subtitles for your video. You only download models once, and can remove them any time by running `npx gen-subs models purge`. You can also list all your downloaded models by running `npx gen-subs models ls`. ### Other Languages You can install a wide variety of models that can "hear" different languages. To generate subs for any language, follow these steps: 1. First, install a model with `npx gen-subs models`. You will be asked to choose a language here. 2. Then, run `npx gen-subs for ./your/video.mp4` to generate subtitles for your video, You will be asked which model to use. 3. Enjoy! ## API This project has a few options that you can use to customize your subtitles. Let's enumerate them here. Each command comes after `npx gen-subs` and is followed by a list of options. | Command | Description | | ------------------------------------ | -------------------------------------------------------------------- | | `for ` | Generate subtitles for a given video or audio file. | | `models` | Manage models | | `models purge` | Delete all downloaded models. | | `models ls` | Show a list of all models downloaded to the system. | | `burn-in ` | Burns subtitles into the video and gives you a new video. | | `embed ` | Adds subtitles to the video's metadata but does not alter the video. | ### `gen-subs for [media]` | Option | Description | Default | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | | `-m, --model [modelName]` | The name of the machine learning model you'd like to use to generate subtitles. | `vosk-model-small-en-us-0.15` | | `-b, --burn-in` | Whether to layer subtitles atop the video (burn them in). | None | | `-e, --embed` | Whether to embed subtitles in the video's metadata. | None | | `-o, --out-dir [path]` | Where to output the subtitle and final video files. | `process.cwd()` | | `-f, --format [format]` | Choose between `srt` or `ass` formats. `ass` lets you do more cool stuff like focus words. (Default `srt`) | `srt` | | `-h --highlight [color]` | (`ass` subtitles only) Highlight the active word with a color. ⚠️ Use double quotes (`""`) when entering hex codes into your terminal because `#` starts a comment! | `"#048BA8"` | ## Contributing Please feel free to open issues and pull requests as needed and I'll try to get to them as soon as possible. ## Sustainability This is all free and open source software. If it has helped you, please consider [sponsoring me on GitHub](https://github.com/sponsors/TejasQ) so I can make more stuff like this and teach about it full-time. ================================================ FILE: actions/burnIn.ts ================================================ import { join } from "path"; import { burnInSubtitles } from "../burnInSubtitles"; import ora from "ora"; import { formatDuration } from "../formatDuration"; export async function burnInAction(video: string, subtitles: string) { const started = Date.now(); const spinner = ora().start("Burning in subtitles..."); await burnInSubtitles(join(process.cwd(), video), join(process.cwd(), subtitles)) spinner.succeed(`Subtitles burned in, took ${formatDuration(Date.now() - started)}.`); } ================================================ FILE: actions/embed.ts ================================================ import { join } from "path"; import ora from "ora"; import { embedSubtitles } from "../embedSubtitles"; import { formatDuration } from "../formatDuration"; export async function embedAction(video: string, subtitles: string) { const started = Date.now(); const spinner = ora().start("Embedding subtitles..."); await embedSubtitles(join(process.cwd(), video), join(process.cwd(), subtitles)) spinner.succeed(`Subtitles embedded, took ${formatDuration(Date.now() - started)}.`); } ================================================ FILE: actions/for.ts ================================================ import { join } from "path"; import { lstat, readdir, writeFile } from "fs/promises"; import { mkdirp } from "mkdirp"; import ora from "ora"; import inquirer from "inquirer"; import { splitFilePath } from "../splitFilePath"; import { formatSize, getModelDir, models, videoExtensions, workingDir } from "../util"; import { extractAudio } from "../extractAudio"; import { processAudio } from "../processAudio"; import { createTextFromAudioFile } from "../createTextFromAudioFile"; import { createSrtFromRecognitionResults } from "../createSrtFromRecognitionResults"; import { burnInSubtitles } from "../burnInSubtitles"; import { embedSubtitles } from "../embedSubtitles"; import { downloadFile, unzipFile } from "../downloadAndUnzip"; import { createAssFromRecognitionResults } from "../createAssfromRecognitionResults"; type Options = { outDir?: string; burnIn?: boolean; embed?: boolean; format?: "srt" | "ass" highlight?: string }; export async function forAction(relativeTarget: string, options: Options) { const target = relativeTarget.startsWith('/') ? relativeTarget : join(process.cwd(), relativeTarget); const { pathWithoutExtension, fileName } = splitFilePath(target); const format = (options.format ?? "srt").toLowerCase() const getOutputFile = (extension: string) => options.outDir ? join(options.outDir, `${fileName}.${extension}`) : `${pathWithoutExtension}.${extension}`; const spinner = ora(); spinner.start("Checking file..."); if (format !== 'ass' && options.highlight) { spinner.fail("The `highlight` option can only be used with `ass` format subtitles. Please use `-f ass` to set a highlight color."); process.exit(1); } if (!target) { spinner.fail("Please specify a file path."); process.exit(1); } try { await lstat(target); } catch { spinner.fail(`File does not exist at ${target}.`); process.exit(1); } const extension = target.split(".").pop(); if (!extension) { spinner.fail(`File does not have an extension.`); process.exit(1); } const isVideo = videoExtensions.includes(extension) spinner.text = `Extension is ${extension}. Processing...`; let audioFilePath: string; if (!isVideo && options.burnIn) { spinner.fail(`You're trying to burn-in subtitles to a non-video file. Please only use -b with videos.`); process.exit(1); } if (isVideo) { spinner.text = "Creating workspace..."; await mkdirp(join(workingDir, "from-video")); spinner.text = "Converting to audio..."; audioFilePath = await extractAudio(target); } else { audioFilePath = target; } spinner.text = "Processing audio..."; const processedAudioFilePath = await processAudio(audioFilePath); spinner.text = "Checking available models..." const availableModels = (await readdir(await getModelDir())).filter(dir => models.map(m => m.name).includes(dir)); let model; spinner.stop(); if (availableModels.length > 1) { const { selectedModel } = await inquirer.prompt([ { type: "list", name: "selectedModel", message: "Please choose a model. To download more models, please run `models`.", choices: models.filter(m => availableModels.includes(m.name)).map(m => ({ name: `(${formatSize(m.size)}, ${m.language}) ${m.notes}`, value: m.name })), }, ]); model = selectedModel; } if (availableModels.length === 1) { model = availableModels[0]; } if (availableModels.length === 0) { const { shouldDownloadModel } = await inquirer.prompt([ { type: "confirm", name: "shouldDownloadModel", message: "You don't seem to have any models downloaded. Would you like to download a basic one? You can run `models` to see all available models and add more.", }, ]); if (shouldDownloadModel) { const modelPath = join(workingDir, "models", models[0].name); const zipFile = await downloadFile(models[0].url, modelPath, models[0].notes); spinner.start("Unzipping model..."); await unzipFile(zipFile, await getModelDir()); spinner.succeed("Model downloaded."); model = models[0].name; } } spinner.start("Loading model..."); const results = await createTextFromAudioFile( spinner, processedAudioFilePath, model ); spinner.text = "Creating subtitles..."; let subs; try { if (format === "srt") { subs = await createSrtFromRecognitionResults(results); } else { subs = createAssFromRecognitionResults(results, options.highlight); } } catch (e: any) { spinner.fail(e.message); process.exit(1); } spinner.succeed("Transcribed audio."); await writeFile(getOutputFile(format), subs); spinner.succeed(`Subtitles created at ${getOutputFile(format)}`); if (options.embed) { spinner.start("Embedding subtitles into media..."); const result = await embedSubtitles(target, getOutputFile(format)); spinner.succeed("File with embedded subtitles available at " + result) } if (!isVideo) { process.exit(0); } if (options.burnIn) { spinner.start("Burning-in subtitles to video..."); const result = await burnInSubtitles(target, getOutputFile(format)); spinner.succeed("File with burn-in subtitles available at " + result) } spinner.succeed("Done."); } ================================================ FILE: actions/models.ts ================================================ import inquirer from "inquirer"; import ora from "ora"; import { rimraf } from "rimraf" import { lstat } from "fs/promises"; import { join } from "path"; import { formatSize, getModelDir, isModelDownloaded, models, workingDir } from "../util"; import { downloadFile, unzipFile } from "../downloadAndUnzip"; export async function modelsAction() { const languages = new Set(models.map((model) => model.language)); const choices = await Promise.all(models.map(async (model) => ({ name: `(${formatSize(model.size)}) ${model.notes}`, value: model.name, checked: await isModelDownloaded(model.name), language: model.language, notes: model.notes, url: model.url, }))); const { language } = await inquirer.prompt([ { type: "list", name: "language", message: "Please choose a language", choices: Array.from(languages), } ]); const scopedChoices = choices.filter(c => c.language === language); const { desiredModels } = await inquirer.prompt([{ type: "checkbox", name: "desiredModels", message: "Here are your models", choices: scopedChoices, }]); const spinner = ora().start("Processing models..."); for (const choice of scopedChoices) { if (!desiredModels.includes(choice.value)) { try { await rimraf(join(workingDir, "models", choice.value)); } catch (e) { } continue; } const doesModelExist = await lstat(join(workingDir, "models", choice.value)).then(() => true).catch(() => false); if (!doesModelExist) { spinner.stop(); const zipFilePath = await downloadFile(choice.url, join(workingDir, "models", choice.value), choice.notes); spinner.start(`Unzipping model ${choice.value}...`); await unzipFile(zipFilePath, await getModelDir()); } } spinner.succeed("Models updated."); } ================================================ FILE: actions/modelsLs.ts ================================================ import { readdir } from "fs/promises"; import ora from "ora"; import { formatSize, getModelDir, models } from "../util"; export async function modelsLs() { const spinner = ora().start("Getting models..."); try { const modelsOnDisk = await readdir(await getModelDir()) const filteredModels = modelsOnDisk.filter(model => models.find(m => m.name === model)); if (!filteredModels.length) { spinner.succeed("No models available."); process.exit(0); } spinner.succeed("Models available:"); console.log(filteredModels.map((model) => { const actualModel = models.find(m => m.name === model)!; return `- (${formatSize(actualModel.size)}, ${actualModel.language}) ${actualModel.notes}` }).join("\n")); process.exit(0); } catch { spinner.succeed("No models available."); process.exit(0); } } ================================================ FILE: actions/modelsPurge.ts ================================================ import ora from "ora"; import inquirer from "inquirer"; import { rimraf } from "rimraf"; import { getModelDir } from "../util"; export async function modelsPurgeAction() { const confirm = await inquirer.prompt([{ type: "confirm", name: "confirm", message: "Are you sure you want to purge all models?", }]); if (!confirm.confirm) { process.exit(0); } const spinner = ora().start("Deleting models..."); await rimraf(await getModelDir()); spinner.succeed("Models purged."); } ================================================ FILE: burnInSubtitles.ts ================================================ import ffmpeg from "fluent-ffmpeg"; import ffmpegInstaller from "@ffmpeg-installer/ffmpeg"; import { splitFilePath } from "./splitFilePath"; import { join } from "path"; ffmpeg.setFfmpegPath(ffmpegInstaller.path); export function burnInSubtitles(filePath: string, subPath: string) { const { pathWithoutExtension } = splitFilePath(subPath); const { extension } = splitFilePath(filePath); const outFile = `${pathWithoutExtension}-with-burned-subs${extension}`; return new Promise((resolve, reject) => { ffmpeg(filePath) .videoFilter("subtitles=" + subPath) .on("error", function (err) { reject(err); }) .save(outFile) .on("end", function () { resolve(outFile); }); }); } ================================================ FILE: cli.ts ================================================ #!/usr/bin/env node import { program } from "commander"; import pkg from "./package.json"; import { forAction } from "./actions/for"; import { modelsAction } from "./actions/models"; import { modelsPurgeAction } from "./actions/modelsPurge"; import { modelsLs } from "./actions/modelsLs"; import { burnInAction } from "./actions/burnIn"; import { embedAction } from "./actions/embed"; program.name(pkg.name).description(pkg.description).version(pkg.version); program .command("for ") .description("Generate subtitles for a given video or audio file.") .option( "-m, --model [modelName]", "The name of the machine learning model you'd like to use to generate subtitles.", "vosk-model-small-en-us-0.15", ) .option( "-b, --burn-in", "Whether to layer subtitles atop the video (burn them in).", false, ) .option( "-e, --embed", "Whether to embed subtitles in the video's metadata.", false, ) .option( "-o, --out-dir [path]", "Where to output the subtitles file.", process.cwd(), ) .option( "-f, --format [format]", "Choose between `srt` or `ass` formats. (Default `srt`)", "srt", ) .option( '-h --highlight [color]', "(`ass` subtitles only) Highlight the active word with a color. (Default `#048BA8`)", ) .action(forAction); const models = program .command("models") .description("Manage models") .action(modelsAction); models.command("purge") .action(modelsPurgeAction) .description("Delete all downloaded models.") models.command("ls") .description( "Show a list of all models downloaded to the system.", ) .action(modelsLs); program .command("burn-in