[
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: tejasq # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]\npatreon: # Replace with a single Patreon username\nopen_collective: # Replace with a single Open Collective username\nko_fi: # Replace with a single Ko-fi username\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncommunity_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\nliberapay: # Replace with a single Liberapay username\nissuehunt: # Replace with a single IssueHunt username\notechie: # Replace with a single Otechie username\nlfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry\ncustom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']\n"
  },
  {
    "path": ".gitignore",
    "content": "# Created by https://www.toptal.com/developers/gitignore/api/node,macos,next,typescript,react,visualstudiocode,nextjs,solidjs,qwik,mitosis\n# Edit at https://www.toptal.com/developers/gitignore?templates=node,macos,next,typescript,react,visualstudiocode,nextjs,solidjs,qwik,mitosis\n\n### macOS ###\n# General\n.DS_Store\n.AppleDouble\n.LSOverride\n\n# Icon must end with two \\r\nIcon\n\n\n# Thumbnails\n._*\n\n# Files that might appear in the root of a volume\n.DocumentRevisions-V100\n.fseventsd\n.Spotlight-V100\n.TemporaryItems\n.Trashes\n.VolumeIcon.icns\n.com.apple.timemachine.donotpresent\n\n# Directories potentially created on remote AFP share\n.AppleDB\n.AppleDesktop\nNetwork Trash Folder\nTemporary Items\n.apdisk\n\n### macOS Patch ###\n# iCloud generated files\n*.icloud\n\n#!! ERROR: mitosis is undefined. Use list command to see defined gitignore types !!#\n\n#!! ERROR: next is undefined. Use list command to see defined gitignore types !!#\n\n### NextJS ###\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.pnpm-debug.log*\n\n# local env files\n.env*.local\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n\n### Node ###\n# Logs\nlogs\n*.log\nlerna-debug.log*\n\n# Diagnostic reports (https://nodejs.org/api/report.html)\nreport.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n*.lcov\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# Snowpack dependency directory (https://snowpack.dev/)\nweb_modules/\n\n# TypeScript cache\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional stylelint cache\n.stylelintcache\n\n# Microbundle cache\n.rpt2_cache/\n.rts2_cache_cjs/\n.rts2_cache_es/\n.rts2_cache_umd/\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variable files\n.env\n.env.development.local\n.env.test.local\n.env.production.local\n.env.local\n\n# parcel-bundler cache (https://parceljs.org/)\n.cache\n.parcel-cache\n\n# Next.js build output\n.next\nout\n\n# Nuxt.js build / generate output\n.nuxt\ndist\n\n# Gatsby files\n.cache/\n# Comment in the public line in if your project uses Gatsby and not Next.js\n# https://nextjs.org/blog/next-9-1#public-directory-support\n# public\n\n# vuepress build output\n.vuepress/dist\n\n# vuepress v2.x temp and cache directory\n.temp\n\n# Docusaurus cache and generated files\n.docusaurus\n\n# Serverless directories\n.serverless/\n\n# FuseBox cache\n.fusebox/\n\n# DynamoDB Local files\n.dynamodb/\n\n# TernJS port file\n.tern-port\n\n# Stores VSCode versions used for testing VSCode extensions\n.vscode-test\n\n# yarn v2\n.yarn/cache\n.yarn/unplugged\n.yarn/build-state.yml\n.yarn/install-state.gz\n.pnp.*\n\n### Node Patch ###\n# Serverless Webpack directories\n.webpack/\n\n# Optional stylelint cache\n\n# SvelteKit build / generate output\n.svelte-kit\n\n#!! ERROR: qwik is undefined. Use list command to see defined gitignore types !!#\n\n### react ###\n.DS_*\n**/*.backup.*\n**/*.back.*\n\nnode_modules\n\n*.sublime*\n\npsd\nthumb\nsketch\n\n#!! ERROR: solidjs is undefined. Use list command to see defined gitignore types !!#\n\n#!! ERROR: typescript is undefined. Use list command to see defined gitignore types !!#\n\n### VisualStudioCode ###\n.vscode/*\n!.vscode/settings.json\n!.vscode/tasks.json\n!.vscode/launch.json\n!.vscode/extensions.json\n!.vscode/*.code-snippets\n\n# Local History for Visual Studio Code\n.history/\n\n# Built Visual Studio Code Extensions\n*.vsix\n\n### VisualStudioCode Patch ###\n# Ignore all local history of files\n.history\n.ionide\n\n# End of https://www.toptal.com/developers/gitignore/api/node,macos,next,typescript,react,visualstudiocode,nextjs,solidjs,qwik,mitosis\nmodels\n*.mp4\n*.wav\nmodel\n*.mov\npostcss.config.js\npostcss.config.cjs\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"printWidth\": 80\n}\n"
  },
  {
    "path": "README.md",
    "content": "# `gen-subs`\n\nThis project uses on-device machine learning models to generate subtitles for your videos.\n\nhttps://github.com/TejasQ/gen-subs/assets/9947422/bc8df523-b62a-4123-a62d-2df17832e2ac\n\n## Features\n\n- 🔒 **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.\n- 🌐 **Multilingual** - Supports a wide variety of languages. Namely,\n  | Languages | | |\n  | --------- | --------- | --------- |\n  | 🇺🇸 English | 🇮🇳 Indian English | 🇨🇳 Chinese |\n  | 🇷🇺 Russian | 🇫🇷 French | 🇩🇪 German |\n  | 🇪🇸 Spanish | 🇵🇹 Portuguese/Brazilian | 🇬🇷 Greek |\n  | 🇹🇷 Turkish | 🇻🇳 Vietnamese | 🇮🇹 Italian |\n  | 🇳🇱 Dutch | 🇪🇸 Catalan | 🇸🇦 Arabic |\n  | 🇮🇷 Farsi | 🇵🇭 Filipino | 🇺🇦 Ukrainian |\n  | 🇰🇿 Kazakh | 🇸🇪 Swedish | 🇯🇵 Japanese |\n  | 🇪🇸 Esperanto | 🇮🇳 Hindi | 🇨🇿 Czech |\n  | 🇵🇱 Polish | 🇺🇿 Uzbek | 🇰🇷 Korean |\n  | 🇫🇷 Breton | | |\n- 🎨 **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.\n- 🎧 **Multi-modal** - Supports both audio and video files and generates subtitles for each.\n- 📊 **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.\n\n## Usage\n\nYou can generate subtitles for any video using the following command:\n\n```bash\nnpx gen-subs for ./your/video.mp4\n```\n\nIf 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.\n\n### Inaccuracies\n\nPlease 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:\n\n```bash\nnpx gen-subs models\n```\n\nThis 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`.\n\n### Other Languages\n\nYou can install a wide variety of models that can \"hear\" different languages. To generate subs for any language, follow these steps:\n\n1. First, install a model with `npx gen-subs models`. You will be asked to choose a language here.\n2. Then, run `npx gen-subs for ./your/video.mp4` to generate subtitles for your video, You will be asked which model to use.\n3. Enjoy!\n\n## API\n\nThis 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.\n\n| Command                              | Description                                                          |\n| ------------------------------------ | -------------------------------------------------------------------- |\n| `for <mediaFile>`                    | Generate subtitles for a given video or audio file.                  |\n| `models`                             | Manage models                                                        |\n| `models purge`                       | Delete all downloaded models.                                        |\n| `models ls`                          | Show a list of all models downloaded to the system.                  |\n| `burn-in <videoFile> <subtitleFile>` | Burns subtitles into the video and gives you a new video.            |\n| `embed <videoFile> <subtitleFile>`   | Adds subtitles to the video's metadata but does not alter the video. |\n\n### `gen-subs for [media]`\n\n| Option                    | Description                                                                                                                                                         | Default                       |\n| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |\n| `-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` |\n| `-b, --burn-in`           | Whether to layer subtitles atop the video (burn them in).                                                                                                           | None                          |\n| `-e, --embed`             | Whether to embed subtitles in the video's metadata.                                                                                                                 | None                          |\n| `-o, --out-dir [path]`    | Where to output the subtitle and final video files.                                                                                                                 | `process.cwd()`               |\n| `-f, --format [format]`   | Choose between `srt` or `ass` formats. `ass` lets you do more cool stuff like focus words. (Default `srt`)                                                          | `srt`                         |\n| `-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\"`                   |\n\n## Contributing\n\nPlease feel free to open issues and pull requests as needed and I'll try to get to them as soon as possible.\n\n## Sustainability\n\nThis 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.\n"
  },
  {
    "path": "actions/burnIn.ts",
    "content": "import { join } from \"path\";\nimport { burnInSubtitles } from \"../burnInSubtitles\";\nimport ora from \"ora\";\nimport { formatDuration } from \"../formatDuration\";\n\nexport async function burnInAction(video: string, subtitles: string) {\n    const started = Date.now();\n    const spinner = ora().start(\"Burning in subtitles...\");\n    await burnInSubtitles(join(process.cwd(), video), join(process.cwd(), subtitles))\n    spinner.succeed(`Subtitles burned in, took ${formatDuration(Date.now() - started)}.`);\n}"
  },
  {
    "path": "actions/embed.ts",
    "content": "import { join } from \"path\";\nimport ora from \"ora\";\nimport { embedSubtitles } from \"../embedSubtitles\";\nimport { formatDuration } from \"../formatDuration\";\n\nexport async function embedAction(video: string, subtitles: string) {\n    const started = Date.now();\n    const spinner = ora().start(\"Embedding subtitles...\");\n    await embedSubtitles(join(process.cwd(), video), join(process.cwd(), subtitles))\n    spinner.succeed(`Subtitles embedded, took ${formatDuration(Date.now() - started)}.`);\n}"
  },
  {
    "path": "actions/for.ts",
    "content": "import { join } from \"path\";\nimport { lstat, readdir, writeFile } from \"fs/promises\";\nimport { mkdirp } from \"mkdirp\";\nimport ora from \"ora\";\nimport inquirer from \"inquirer\";\n\nimport { splitFilePath } from \"../splitFilePath\";\nimport { formatSize, getModelDir, models, videoExtensions, workingDir } from \"../util\";\nimport { extractAudio } from \"../extractAudio\";\nimport { processAudio } from \"../processAudio\";\nimport { createTextFromAudioFile } from \"../createTextFromAudioFile\";\nimport { createSrtFromRecognitionResults } from \"../createSrtFromRecognitionResults\";\nimport { burnInSubtitles } from \"../burnInSubtitles\";\nimport { embedSubtitles } from \"../embedSubtitles\";\nimport { downloadFile, unzipFile } from \"../downloadAndUnzip\";\nimport { createAssFromRecognitionResults } from \"../createAssfromRecognitionResults\";\n\ntype Options = {\n  outDir?: string;\n  burnIn?: boolean;\n  embed?: boolean;\n  format?: \"srt\" | \"ass\"\n  highlight?: string\n};\n\nexport async function forAction(relativeTarget: string, options: Options) {\n  const target = relativeTarget.startsWith('/') ? relativeTarget : join(process.cwd(), relativeTarget);\n  const { pathWithoutExtension, fileName } = splitFilePath(target);\n  const format = (options.format ?? \"srt\").toLowerCase()\n\n  const getOutputFile = (extension: string) =>\n    options.outDir\n      ? join(options.outDir, `${fileName}.${extension}`)\n      : `${pathWithoutExtension}.${extension}`;\n  const spinner = ora();\n  spinner.start(\"Checking file...\");\n\n  if (format !== 'ass' && options.highlight) {\n    spinner.fail(\"The `highlight` option can only be used with `ass` format subtitles. Please use `-f ass` to set a highlight color.\");\n    process.exit(1);\n  }\n\n  if (!target) {\n    spinner.fail(\"Please specify a file path.\");\n    process.exit(1);\n  }\n\n  try {\n    await lstat(target);\n  } catch {\n    spinner.fail(`File does not exist at ${target}.`);\n    process.exit(1);\n  }\n\n  const extension = target.split(\".\").pop();\n\n  if (!extension) {\n    spinner.fail(`File does not have an extension.`);\n    process.exit(1);\n  }\n\n  const isVideo = videoExtensions.includes(extension)\n  spinner.text = `Extension is ${extension}. Processing...`;\n  let audioFilePath: string;\n\n  if (!isVideo && options.burnIn) {\n    spinner.fail(`You're trying to burn-in subtitles to a non-video file. Please only use -b with videos.`);\n    process.exit(1);\n  }\n\n  if (isVideo) {\n    spinner.text = \"Creating workspace...\";\n    await mkdirp(join(workingDir, \"from-video\"));\n    spinner.text = \"Converting to audio...\";\n    audioFilePath = await extractAudio(target);\n  } else {\n    audioFilePath = target;\n  }\n\n  spinner.text = \"Processing audio...\";\n  const processedAudioFilePath = await processAudio(audioFilePath);\n  spinner.text = \"Checking available models...\"\n  const availableModels = (await readdir(await getModelDir())).filter(dir => models.map(m => m.name).includes(dir));\n  let model;\n\n  spinner.stop();\n  if (availableModels.length > 1) {\n    const { selectedModel } = await inquirer.prompt([\n      {\n        type: \"list\",\n        name: \"selectedModel\",\n        message: \"Please choose a model. To download more models, please run `models`.\",\n        choices: models.filter(m => availableModels.includes(m.name)).map(m => ({ name: `(${formatSize(m.size)}, ${m.language}) ${m.notes}`, value: m.name })),\n      },\n    ]);\n    model = selectedModel;\n  }\n\n  if (availableModels.length === 1) {\n    model = availableModels[0];\n  }\n\n  if (availableModels.length === 0) {\n    const { shouldDownloadModel } = await inquirer.prompt([\n      {\n        type: \"confirm\",\n        name: \"shouldDownloadModel\",\n        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.\",\n      },\n    ]);\n    if (shouldDownloadModel) {\n      const modelPath = join(workingDir, \"models\", models[0].name);\n      const zipFile = await downloadFile(models[0].url, modelPath, models[0].notes);\n      spinner.start(\"Unzipping model...\");\n      await unzipFile(zipFile, await getModelDir());\n      spinner.succeed(\"Model downloaded.\");\n      model = models[0].name;\n    }\n  }\n\n  spinner.start(\"Loading model...\");\n  const results = await createTextFromAudioFile(\n    spinner,\n    processedAudioFilePath,\n    model\n  );\n  spinner.text = \"Creating subtitles...\";\n  let subs;\n\n  try {\n    if (format === \"srt\") {\n      subs = await createSrtFromRecognitionResults(results);\n    } else {\n      subs = createAssFromRecognitionResults(results, options.highlight);\n    }\n  } catch (e: any) {\n    spinner.fail(e.message);\n    process.exit(1);\n  }\n\n  spinner.succeed(\"Transcribed audio.\");\n  await writeFile(getOutputFile(format), subs);\n  spinner.succeed(`Subtitles created at ${getOutputFile(format)}`);\n\n\n  if (options.embed) {\n    spinner.start(\"Embedding subtitles into media...\");\n    const result = await embedSubtitles(target, getOutputFile(format));\n    spinner.succeed(\"File with embedded subtitles available at \" + result)\n  }\n\n  if (!isVideo) {\n    process.exit(0);\n  }\n\n  if (options.burnIn) {\n    spinner.start(\"Burning-in subtitles to video...\");\n    const result = await burnInSubtitles(target, getOutputFile(format));\n    spinner.succeed(\"File with burn-in subtitles available at \" + result)\n  }\n\n  spinner.succeed(\"Done.\");\n}\n"
  },
  {
    "path": "actions/models.ts",
    "content": "import inquirer from \"inquirer\";\nimport ora from \"ora\";\nimport { rimraf } from \"rimraf\"\nimport { lstat } from \"fs/promises\";\nimport { join } from \"path\";\n\nimport { formatSize, getModelDir, isModelDownloaded, models, workingDir } from \"../util\";\nimport { downloadFile, unzipFile } from \"../downloadAndUnzip\";\n\nexport async function modelsAction() {\n    const languages = new Set(models.map((model) => model.language));\n\n    const choices = await Promise.all(models.map(async (model) => ({\n        name: `(${formatSize(model.size)}) ${model.notes}`,\n        value: model.name,\n        checked: await isModelDownloaded(model.name),\n        language: model.language,\n        notes: model.notes,\n        url: model.url,\n    })));\n\n    const { language } = await inquirer.prompt([\n        {\n            type: \"list\",\n            name: \"language\",\n            message: \"Please choose a language\",\n            choices: Array.from(languages),\n        }\n    ]);\n\n    const scopedChoices = choices.filter(c => c.language === language);\n    const { desiredModels } = await inquirer.prompt([{\n        type: \"checkbox\",\n        name: \"desiredModels\",\n        message: \"Here are your models\",\n        choices: scopedChoices,\n    }]);\n\n    const spinner = ora().start(\"Processing models...\");\n    for (const choice of scopedChoices) {\n        if (!desiredModels.includes(choice.value)) {\n            try {\n                await rimraf(join(workingDir, \"models\", choice.value));\n            } catch (e) {\n            }\n            continue;\n        }\n        const doesModelExist = await lstat(join(workingDir, \"models\", choice.value)).then(() => true).catch(() => false);\n        if (!doesModelExist) {\n            spinner.stop();\n            const zipFilePath = await downloadFile(choice.url, join(workingDir, \"models\", choice.value), choice.notes);\n            spinner.start(`Unzipping model ${choice.value}...`);\n            await unzipFile(zipFilePath, await getModelDir());\n        }\n    }\n\n    spinner.succeed(\"Models updated.\");\n}\n"
  },
  {
    "path": "actions/modelsLs.ts",
    "content": "import { readdir } from \"fs/promises\";\nimport ora from \"ora\";\nimport { formatSize, getModelDir, models } from \"../util\";\n\nexport async function modelsLs() {\n    const spinner = ora().start(\"Getting models...\");\n    try {\n        const modelsOnDisk = await readdir(await getModelDir())\n        const filteredModels = modelsOnDisk.filter(model => models.find(m => m.name === model));\n\n        if (!filteredModels.length) {\n            spinner.succeed(\"No models available.\");\n            process.exit(0);\n        }\n\n        spinner.succeed(\"Models available:\");\n        console.log(filteredModels.map((model) => {\n            const actualModel = models.find(m => m.name === model)!;\n            return `- (${formatSize(actualModel.size)}, ${actualModel.language}) ${actualModel.notes}`\n        }).join(\"\\n\"));\n        process.exit(0);\n    } catch {\n        spinner.succeed(\"No models available.\");\n        process.exit(0);\n    }\n}"
  },
  {
    "path": "actions/modelsPurge.ts",
    "content": "import ora from \"ora\";\nimport inquirer from \"inquirer\";\nimport { rimraf } from \"rimraf\";\nimport { getModelDir } from \"../util\";\n\nexport async function modelsPurgeAction() {\n    const confirm = await inquirer.prompt([{\n        type: \"confirm\",\n        name: \"confirm\",\n        message: \"Are you sure you want to purge all models?\",\n    }]);\n\n    if (!confirm.confirm) {\n        process.exit(0);\n    }\n\n    const spinner = ora().start(\"Deleting models...\");\n    await rimraf(await getModelDir());\n    spinner.succeed(\"Models purged.\");\n}"
  },
  {
    "path": "burnInSubtitles.ts",
    "content": "import ffmpeg from \"fluent-ffmpeg\";\nimport ffmpegInstaller from \"@ffmpeg-installer/ffmpeg\";\nimport { splitFilePath } from \"./splitFilePath\";\nimport { join } from \"path\";\n\nffmpeg.setFfmpegPath(ffmpegInstaller.path);\n\nexport function burnInSubtitles(filePath: string, subPath: string) {\n  const { pathWithoutExtension } = splitFilePath(subPath);\n  const { extension } = splitFilePath(filePath);\n  const outFile = `${pathWithoutExtension}-with-burned-subs${extension}`;\n  return new Promise((resolve, reject) => {\n    ffmpeg(filePath)\n      .videoFilter(\"subtitles=\" + subPath)\n      .on(\"error\", function (err) {\n        reject(err);\n      })\n      .save(outFile)\n      .on(\"end\", function () {\n        resolve(outFile);\n      });\n  });\n}\n"
  },
  {
    "path": "cli.ts",
    "content": "#!/usr/bin/env node\n\nimport { program } from \"commander\";\nimport pkg from \"./package.json\";\nimport { forAction } from \"./actions/for\";\nimport { modelsAction } from \"./actions/models\";\nimport { modelsPurgeAction } from \"./actions/modelsPurge\";\nimport { modelsLs } from \"./actions/modelsLs\";\nimport { burnInAction } from \"./actions/burnIn\";\nimport { embedAction } from \"./actions/embed\";\n\nprogram.name(pkg.name).description(pkg.description).version(pkg.version);\n\nprogram\n    .command(\"for <path>\")\n    .description(\"Generate subtitles for a given video or audio file.\")\n    .option(\n        \"-m, --model [modelName]\",\n        \"The name of the machine learning model you'd like to use to generate subtitles.\",\n        \"vosk-model-small-en-us-0.15\",\n    )\n    .option(\n        \"-b, --burn-in\",\n        \"Whether to layer subtitles atop the video (burn them in).\",\n        false,\n    )\n    .option(\n        \"-e, --embed\",\n        \"Whether to embed subtitles in the video's metadata.\",\n        false,\n    )\n    .option(\n        \"-o, --out-dir [path]\",\n        \"Where to output the subtitles file.\",\n        process.cwd(),\n    )\n    .option(\n        \"-f, --format [format]\",\n        \"Choose between `srt` or `ass` formats. (Default `srt`)\",\n        \"srt\",\n    )\n    .option(\n        '-h --highlight [color]',\n        \"(`ass` subtitles only) Highlight the active word with a color. (Default `#048BA8`)\",\n    )\n    .action(forAction);\n\nconst models = program\n    .command(\"models\")\n    .description(\"Manage models\")\n    .action(modelsAction);\n\nmodels.command(\"purge\")\n    .action(modelsPurgeAction)\n    .description(\"Delete all downloaded models.\")\n\nmodels.command(\"ls\")\n    .description(\n        \"Show a list of all models downloaded to the system.\",\n    )\n    .action(modelsLs);\n\nprogram\n    .command(\"burn-in <video> <subtitles>\")\n    .description(\"Burn subtitles into a video. Video is output in the same directiory with a suffix added.\")\n    .action(burnInAction);\n\nprogram\n    .command(\"embed <video> <subtitles>\")\n    .description(\"Embed subtitles to a video. Video is output in the same directiory with a suffix added.\")\n    .action(embedAction);\n\nprogram.parse(process.argv);\n\n"
  },
  {
    "path": "createAssfromRecognitionResults.ts",
    "content": "import { RecognitionResults } from \"vosk\";\n\ntype CssStyle = {\n    color?: string; // Text color\n    backgroundColor?: string; // Background color (simulated with border)\n    box?: boolean; // Toggle for box-like background\n    // ASS doesn't support padding and rounded corners in the CSS sense\n};\n\nfunction formatTime(time: number): string {\n    const hours = Math.floor(time / 3600).toString().padStart(2, '0');\n    const minutes = Math.floor((time % 3600) / 60).toString().padStart(2, '0');\n    const seconds = Math.floor(time % 60).toString().padStart(2, '0');\n    const centiseconds = Math.floor((time % 1) * 100).toString().padStart(2, '0');\n    return `${hours}:${minutes}:${seconds}.${centiseconds}`;\n}\n\nfunction generateAssHeader(): string {\n    return `[Script Info]\nTitle: Generated ASS\nScriptType: v4.00+\nWrapStyle: 0\nScaledBorderAndShadow: yes\nYCbCr Matrix: None\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default, Arial, 20, &H00FFFFFF, &H00FFFFFF, &H00000000, &H00000000, 0, 0, 0, 0, 100, 100, 0, 0, 1, 1, 0, 2, 10, 10, 10, 1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n`;\n}\n\nfunction cssHexColorToAssBgr(hexColor: string): string {\n    const bgr = hexColor.slice(1).match(/.{2}/g)?.reverse().join('');\n    return bgr ? `&H00${bgr}&` : `&H00FFFFFF&`; // Default to white if invalid\n}\n\nfunction generateHighlightedWord(word: string, css: CssStyle): string {\n    const textColor = css.color ? cssHexColorToAssBgr(css.color) : '&H00FFFFFF'; // Default: white\n    const backgroundColor = css.backgroundColor ? cssHexColorToAssBgr(css.backgroundColor) : '&H00000000'; // Default: transparent\n    const boxEffect = css.box ? '{\\\\bord4}{\\\\3c' + backgroundColor + '}' : '{\\\\bord0}';\n    const defaultStyle = '{\\\\bord1}{\\\\shad0}{\\\\3c&H000000&}{\\\\1c&HFFFFFF&}'; // Default style\n\n    // Apply styles to the highlighted word and reset immediately after\n    return `${boxEffect}{\\\\1c${textColor}}${word}${defaultStyle}`;\n}\n\n\nexport function createAssFromRecognitionResults(results: RecognitionResults[], highlightColor = '#048BA8'): string {\n    let dialogues = '';\n\n    if (!results.length) {\n        throw new Error(\"No words identified to create subtitles from.\");\n    }\n\n    let previousEndTime = 0;\n\n    for (let r = 0; r < results.length; r++) {\n        for (let i = 0; i < results[r].result.length; i++) {\n            const word = results[r].result[i];\n            let startTime = Math.max(word.start, previousEndTime); // Start time is either the word start time or the end of the previous subtitle, whichever is later\n            let endTime = word.end;\n\n            // Extend endTime to be at least 1 second, but avoid overlapping with the start of the next word\n            endTime = Math.max(endTime, startTime + 1); // Ensure at least 1 second duration\n            if (i < results[r].result.length - 1) {\n                const nextWordStart = results[r].result[i + 1].start;\n                if (endTime > nextWordStart) {\n                    endTime = nextWordStart; // Adjust to prevent overlap with the next word\n                }\n            }\n\n            previousEndTime = endTime; // Update previousEndTime for the next iteration\n\n            // Format times for the subtitle\n            const formattedStartTime = formatTime(startTime);\n            const formattedEndTime = formatTime(endTime);\n\n            // Prepare text for the subtitle line\n            const textBefore = results[r].result.slice(Math.max(0, i - 5), i).map(w => w.word).join(' ');\n            const highlightedWord = generateHighlightedWord(word.word, {\n                backgroundColor: highlightColor,\n                color: '#ffffff',\n                box: true,\n            });\n            const textAfter = results[r].result.slice(i + 1, i + 6).map(w => w.word).join(' ');\n            const lineText = textBefore + ' ' + highlightedWord + ' ' + textAfter;\n\n            dialogues += `Dialogue: 0,${formattedStartTime},${formattedEndTime},Default,,0,0,0,,${lineText.trim()}\\n`;\n        }\n    }\n    return `${generateAssHeader()}\n${dialogues}`;\n}\n"
  },
  {
    "path": "createCueFromWords.ts",
    "content": "export function createCueFromWords(\n  words: Word[],\n  start: number,\n  end: number,\n): SubtitleCue {\n  const text = words\n    .slice(start, end + 1)\n    .map((w) => w.word)\n    .join(\" \");\n  return {\n    type: \"cue\",\n    data: {\n      start: words[start].start * 1000,\n      end: words[end].end * 1000,\n      text: text,\n    },\n  };\n}\n"
  },
  {
    "path": "createSrtFromRecognitionResults.ts",
    "content": "import { stringifySync } from \"subtitle\";\nimport { createCueFromWords } from \"./createCueFromWords\";\nimport { RecognitionResults } from \"vosk\";\n\nexport async function createSrtFromRecognitionResults(results: RecognitionResults[]) {\n  const WORDS_PER_LINE = 7;\n  const subtitles: SubtitleCue[] = [];\n\n  if (!results.length) {\n    throw new Error(\"No words identified to create subtitles from.\");\n  }\n\n  results.forEach(({ result: words }) => {\n    if (!words) return;\n    for (let start = 0; start < words.length; start += WORDS_PER_LINE) {\n      const end = Math.min(start + WORDS_PER_LINE - 1, words.length - 1);\n      const cue = createCueFromWords(words, start, end);\n      subtitles.push(cue);\n    }\n  });\n\n  return stringifySync(subtitles, { format: \"SRT\" });\n}\n"
  },
  {
    "path": "createTextFromAudioFile.ts",
    "content": "import * as wav from \"wav\";\nimport { Readable } from \"stream\";\nimport { createReadStream } from \"fs\";\nimport vosk, { RecognitionResults } from \"vosk\";\nimport { Ora } from \"ora\";\nimport { loadModel } from \"./loadModel\";\nimport { stat } from \"fs/promises\";\nimport { join } from \"path\";\nimport { __dirname, workingDir } from \"./util\";\n\nexport const createTextFromAudioFile = (\n  spinner: Ora,\n  fileName: string,\n  modelName: string\n): Promise<RecognitionResults[]> =>\n  new Promise(async (resolve, reject) => {\n    const model = await loadModel(join(workingDir, \"models\", modelName));\n    spinner.text = \"Listening...\";\n\n    if (!fileName) {\n      throw new Error(\"Source audio file name is not provided.\");\n    }\n\n    const fileStats = await stat(fileName);\n    const totalSize = fileStats.size;\n    let bytesRead = 0;\n    let lastLoggedPercentage = 0;\n\n    const updateProgressBar = (currentSize: number) => {\n      const percentage = Math.round((currentSize / totalSize) * 100);\n      const totalResults = results.length;\n      const preview = results.map((result) => result.text).join(\" \");\n      if (percentage !== lastLoggedPercentage) {\n        spinner.text = `Listening... (${percentage}%).\\nHeard so far:\\n\\n\\t${totalResults > 1 ? `[...] ${preview.slice(-360)} [...]` : preview}`;\n        lastLoggedPercentage = percentage;\n      }\n    };\n\n    const wfReader = new wav.Reader();\n    const wfReadable = new Readable().wrap(wfReader);\n    let results: RecognitionResults[] = [];\n\n    wfReader.on(\"format\", async (format: AudioFormat) => {\n      if (format.audioFormat !== 1 || format.channels !== 1) {\n        throw new Error(\"Audio file must be WAV format mono PCM.\");\n      }\n      spinner.text = \"Creating recognizer...\";\n      const recognizer = new vosk.Recognizer({\n        model,\n        sampleRate: format.sampleRate,\n      });\n      recognizer.setWords(true);\n\n      spinner.text = \"Listening. Heard so far: \";\n\n      for await (const data of wfReadable) {\n        bytesRead += data.length;\n        updateProgressBar(bytesRead);\n        const endOfSpeech = recognizer.acceptWaveform(data);\n        if (endOfSpeech) {\n          const result = recognizer.result();\n          results.push(result);\n        }\n      }\n      spinner.clear();\n      recognizer.free();\n      model.free();\n      resolve(results);\n    });\n\n    createReadStream(fileName, { highWaterMark: 4096 })\n      .pipe(wfReader)\n      .on(\"error\", reject);\n  });\n"
  },
  {
    "path": "downloadAndUnzip.ts",
    "content": "import https from 'https';\nimport fs from 'fs';\nimport yauzl from \"yauzl\";\nimport { mkdirp } from 'mkdirp';\nimport { dirname, join } from 'path';\nimport cliProgress from 'cli-progress';\n\nimport { workingDir } from './util';\n\nexport async function downloadFile(url: string, dest: string, name: string): Promise<string> {\n    await mkdirp(join(workingDir, 'models'))\n    await mkdirp(dest)\n    const tempModel = join(workingDir, 'models', 'temp.zip');\n    return new Promise((resolve, reject) => {\n        const file = fs.createWriteStream(tempModel);\n        const progressBar = new cliProgress.SingleBar({}, cliProgress.Presets.rect);\n\n        https.get(url, (response) => {\n            if (response.statusCode !== 200) {\n                reject('Failed to download file, status code: ' + response.statusCode);\n                return;\n            }\n\n            const totalSize = parseInt(response.headers['content-length'] ?? \"0\", 10);\n            let receivedBytes = 0;\n            console.log(`Downloading ${name}...`);\n            progressBar.start(totalSize, 0);\n\n            response.on('data', (chunk) => {\n                receivedBytes += chunk.length;\n                progressBar.update(receivedBytes);\n            });\n\n            response.pipe(file);\n\n            file.on('finish', () => {\n                progressBar.stop();\n                file.close(() => resolve(tempModel));\n            });\n        }).on('error', (err) => {\n            fs.unlink(dest, () => reject(err));\n        });\n\n        file.on('error', (err) => {\n            fs.unlink(dest, () => reject(err));\n        });\n    });\n}\n\nexport async function unzipFile(zipFilePath: string, outputPath: string) {\n    return new Promise(async (resolve, reject) => {\n        try {\n            yauzl.open(zipFilePath, { lazyEntries: true }, (err, zipfile) => {\n                if (err) reject(err);\n\n                zipfile.readEntry();\n                zipfile.on(\"entry\", (entry) => {\n                    if (/\\/$/.test(entry.fileName)) {\n                        // Directory file names end with '/'\n                        fs.mkdir(join(outputPath, entry.fileName), { recursive: true }, (err) => {\n                            if (err) reject(err);\n                            zipfile.readEntry();\n                        });\n                    } else {\n                        // File entry\n                        zipfile.openReadStream(entry, (err, readStream) => {\n                            if (err) reject(err);\n                            const filePath = join(outputPath, entry.fileName);\n                            fs.mkdir(dirname(filePath), { recursive: true }, (err) => {\n                                if (err) reject(err);\n                                readStream.pipe(fs.createWriteStream(filePath));\n                                readStream.on(\"end\", () => {\n                                    zipfile.readEntry();\n                                });\n                            });\n                        });\n                    }\n                });\n                zipfile.on(\"end\", () => {\n                    resolve(outputPath)\n                });\n            });\n        } catch (err) {\n            reject(err);\n        }\n    });\n}\n"
  },
  {
    "path": "embedSubtitles.ts",
    "content": "import { splitFilePath } from \"./splitFilePath\";\nimport ffmpeg from \"fluent-ffmpeg\";\nimport ffmpegInstaller from \"@ffmpeg-installer/ffmpeg\";\n\nffmpeg.setFfmpegPath(ffmpegInstaller.path);\n\nexport function embedSubtitles(videoPath: string, subtitlesPath: string) {\n  const { extension } = splitFilePath(videoPath);\n  const { pathWithoutExtension } = splitFilePath(subtitlesPath);\n  const outFile = `${pathWithoutExtension}-with-meta-subtitles${extension}`;\n  return new Promise((resolve, reject) => {\n    ffmpeg(videoPath)\n      .outputOptions(\"-c copy\") // Copy the video and audio streams without re-encoding\n      .outputOptions(\"-c:s mov_text\") // Specify the codec for subtitles (if needed)\n      .outputOptions(`-metadata:s:s:0 language=eng`) // Optional: set subtitle language\n      .addInput(subtitlesPath)\n      .on(\"end\", () => {\n        resolve(outFile);\n      })\n      .on(\"error\", (err) => {\n        reject(err);\n      })\n      .save(outFile);\n  });\n}\n"
  },
  {
    "path": "extractAudio.ts",
    "content": "import ffmpeg from \"fluent-ffmpeg\";\nimport ffmpegInstaller from \"@ffmpeg-installer/ffmpeg\";\nimport { join } from \"path\";\nimport { workingDir } from \"./util\";\n\nffmpeg.setFfmpegPath(ffmpegInstaller.path);\n\nexport function extractAudio(filePath: string): Promise<string> {\n  return new Promise((resolve, reject) => {\n    const fileName = filePath.split(\"/\").pop() ?? \"\";\n    const fileNameWithoutExtension = fileName.split(\".\")[0];\n    const extractedAudioTarget = join(\n      workingDir,\n      \"from-video\",\n      `${Date.now()}-${fileNameWithoutExtension}.wav`,\n    );\n    ffmpeg(filePath)\n      .noVideo()\n      .audioCodec(\"pcm_s16le\")\n      .format(\"wav\")\n      .on(\"end\", () => {\n        resolve(extractedAudioTarget);\n      })\n      .on(\"error\", (err) => {\n        reject(\"Error: \" + err);\n      })\n      .save(extractedAudioTarget);\n  });\n}\n"
  },
  {
    "path": "formatDuration.ts",
    "content": "export function formatDuration(milliseconds: number): string {\n    const seconds = Math.floor(milliseconds / 1000);\n    const minutes = Math.floor(seconds / 60);\n    const hours = Math.floor(minutes / 60);\n\n    const secondsPart = seconds % 60;\n    const minutesPart = minutes % 60;\n\n    if (hours > 0) {\n        return `${hours}h ${minutesPart}m ${secondsPart}s`;\n    } else if (minutes > 0) {\n        return `${minutesPart}m ${secondsPart}s`;\n    } else {\n        return `${secondsPart}s`;\n    }\n}"
  },
  {
    "path": "loadModel.ts",
    "content": "import { lstat } from \"fs/promises\";\nimport vosk, { Model } from \"vosk\";\n\nexport const loadModel = (path: string): Promise<Model> =>\n  new Promise(async (resolve, reject) => {\n    if (!path) {\n      throw new Error(\"Model path is not provided.\");\n    }\n\n    try {\n      await lstat(path);\n    } catch (e) {\n      reject(`You don't seem to have this model downloaded. Please run the \\`models\\` command to download it.`);\n    }\n\n    process.nextTick(() => {\n      vosk.setLogLevel(-1);\n      const model = new vosk.Model(path);\n      resolve(model);\n    });\n  });\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"gen-subs\",\n  \"version\": \"1.0.6\",\n  \"description\": \"A CLI tool to generate subtitles from audio and video files and then either burn them in or add them as a separate track.\",\n  \"main\": \"./dist/cli.js\",\n  \"type\": \"module\",\n  \"bin\": {\n    \"gen-subs\": \"./dist/cli.js\"\n  },\n  \"scripts\": {\n    \"prebuild\": \"rimraf dist\",\n    \"build\": \"tsup-node ./cli.ts --format cjs,esm --out-dir dist\",\n    \"prepublishOnly\": \"npm run build\"\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"keywords\": [\n    \"machine learning\",\n    \"ai\",\n    \"artificial intelligence\",\n    \"subtitles\",\n    \"video subtitles\",\n    \"closed captions\",\n    \"accessibility\"\n  ],\n  \"author\": {\n    \"name\": \"Tejas Kumar\",\n    \"email\": \"tejas@tejas.qa\"\n  },\n  \"license\": \"GPL-3.0-or-later\",\n  \"dependencies\": {\n    \"@ffmpeg-installer/ffmpeg\": \"^1.1.0\",\n    \"cli-progress\": \"^3.12.0\",\n    \"commander\": \"^11.1.0\",\n    \"fluent-ffmpeg\": \"^2.1.2\",\n    \"inquirer\": \"^9.2.12\",\n    \"mkdirp\": \"^3.0.1\",\n    \"ora\": \"^7.0.1\",\n    \"subtitle\": \"^4.2.1\",\n    \"vosk\": \"^0.3.39\",\n    \"wav\": \"^1.0.2\",\n    \"rimraf\": \"^5.0.5\",\n    \"yauzl\": \"^2.10.0\"\n  },\n  \"devDependencies\": {\n    \"@types/cli-progress\": \"^3.11.5\",\n    \"@types/fluent-ffmpeg\": \"^2.1.24\",\n    \"@types/inquirer\": \"^9.0.7\",\n    \"@types/node\": \"^20.9.1\",\n    \"@types/vosk\": \"link:../../DefinitelyTyped/types/vosk\",\n    \"@types/wav\": \"^1.0.4\",\n    \"@types/yauzl\": \"^2.10.3\",\n    \"prettier\": \"^3.1.0\",\n    \"tsup\": \"^8.0.0\"\n  }\n}\n"
  },
  {
    "path": "processAudio.ts",
    "content": "import ffmpeg from \"fluent-ffmpeg\";\nimport ffmpegInstaller from \"@ffmpeg-installer/ffmpeg\";\nimport { join } from \"path\";\nimport { workingDir } from \"./util\";\n\nffmpeg.setFfmpegPath(ffmpegInstaller.path);\n\nexport function processAudio(inputPath: string): Promise<string> {\n  const fileName = inputPath.split(\"/\").pop() ?? \"\";\n  const fileNameWithoutExtension = fileName.split(\".\")[0];\n  const outputPath = join(\n    workingDir, \"from-video\", `${Date.now()}-${fileNameWithoutExtension}.wav`,\n  );\n  return new Promise((resolve, reject) => {\n    ffmpeg(inputPath)\n      .audioChannels(1) // Set to mono\n      .format(\"s16le\") // Set format to s16le (16-bit signed little-endian)\n      .audioFrequency(16000) // Set audio frequency to 16kHz\n      .format(\"wav\")\n      .on(\"end\", () => {\n        resolve(outputPath);\n      })\n      .on(\"error\", (err) => {\n        reject(\"Error: \" + err);\n      })\n      .save(outputPath);\n  });\n}\n"
  },
  {
    "path": "splitFilePath.ts",
    "content": "export function splitFilePath(filePath: string) {\n  // Extract the base name (the last part of the path)\n  const baseName = filePath.split(\"/\").pop();\n\n  if (!baseName) throw new Error(\"Invalid file path\");\n\n  // Handling cases where there is no extension or the file is hidden\n  if (baseName === \"\" || baseName.startsWith(\".\") || !baseName.includes(\".\")) {\n    return {\n      pathWithoutExtension: filePath,\n      extension: \"\",\n      fileName: baseName,\n    };\n  }\n\n  // Finding the last dot to separate the extension\n  const lastDotIndex = baseName.lastIndexOf(\".\");\n\n  // Extracting the path without extension and the extension\n  const pathWithoutExtension =\n    filePath.substring(0, filePath.lastIndexOf(\".\")) ||\n    baseName.substring(0, lastDotIndex);\n  const extension = baseName.substring(lastDotIndex);\n\n  // Extracting just the file name without the extension\n  const fileName = baseName.substring(0, lastDotIndex);\n\n  return { pathWithoutExtension, extension, fileName };\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    /* Visit https://aka.ms/tsconfig to read more about this file */\n\n    /* Projects */\n    // \"incremental\": true,                              /* Save .tsbuildinfo files to allow for incremental compilation of projects. */\n    // \"composite\": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */\n    // \"tsBuildInfoFile\": \"./.tsbuildinfo\",              /* Specify the path to .tsbuildinfo incremental compilation file. */\n    // \"disableSourceOfProjectReferenceRedirect\": true,  /* Disable preferring source files instead of declaration files when referencing composite projects. */\n    // \"disableSolutionSearching\": true,                 /* Opt a project out of multi-project reference checking when editing. */\n    // \"disableReferencedProjectLoad\": true,             /* Reduce the number of projects loaded automatically by TypeScript. */\n\n    /* Language and Environment */\n    \"target\": \"ESNext\" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,\n    // \"lib\": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */\n    // \"jsx\": \"preserve\",                                /* Specify what JSX code is generated. */\n    // \"experimentalDecorators\": true,                   /* Enable experimental support for legacy experimental decorators. */\n    // \"emitDecoratorMetadata\": true,                    /* Emit design-type metadata for decorated declarations in source files. */\n    // \"jsxFactory\": \"\",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */\n    // \"jsxFragmentFactory\": \"\",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */\n    // \"jsxImportSource\": \"\",                            /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */\n    // \"reactNamespace\": \"\",                             /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */\n    // \"noLib\": true,                                    /* Disable including any library files, including the default lib.d.ts. */\n    // \"useDefineForClassFields\": true,                  /* Emit ECMAScript-standard-compliant class fields. */\n    // \"moduleDetection\": \"auto\",                        /* Control what method is used to detect module-format JS files. */\n\n    /* Modules */\n    \"module\": \"ESNext\",\n    // \"rootDir\": \"./\",                                  /* Specify the root folder within your source files. */\n    \"moduleResolution\": \"Bundler\" /* Specify how TypeScript looks up a file from a given module specifier. */,\n    // \"baseUrl\": \"./\",                                  /* Specify the base directory to resolve non-relative module names. */\n    // \"paths\": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */\n    // \"rootDirs\": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */\n    // \"typeRoots\": [],                                  /* Specify multiple folders that act like './node_modules/@types'. */\n    // \"types\": [],                                      /* Specify type package names to be included without being referenced in a source file. */\n    // \"allowUmdGlobalAccess\": true,                     /* Allow accessing UMD globals from modules. */\n    // \"moduleSuffixes\": [],                             /* List of file name suffixes to search when resolving a module. */\n    \"allowImportingTsExtensions\": true /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */,\n    // \"resolvePackageJsonExports\": true,                /* Use the package.json 'exports' field when resolving package imports. */\n    // \"resolvePackageJsonImports\": true,                /* Use the package.json 'imports' field when resolving imports. */\n    // \"customConditions\": [],                           /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */\n    // \"resolveJsonModule\": true,                        /* Enable importing .json files. */\n    // \"allowArbitraryExtensions\": true,                 /* Enable importing files with any extension, provided a declaration file is present. */\n    // \"noResolve\": true,                                /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */\n\n    /* JavaScript Support */\n    // \"allowJs\": true,                                  /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */\n    // \"checkJs\": true,                                  /* Enable error reporting in type-checked JavaScript files. */\n    // \"maxNodeModuleJsDepth\": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */\n\n    /* Emit */\n    \"declaration\": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */,\n    // \"declarationMap\": true,                           /* Create sourcemaps for d.ts files. */\n    \"emitDeclarationOnly\": true /* Only output d.ts files and not JavaScript files. */,\n    // \"sourceMap\": true,                                /* Create source map files for emitted JavaScript files. */\n    // \"inlineSourceMap\": true,                          /* Include sourcemap files inside the emitted JavaScript. */\n    // \"outFile\": \"./\",                                  /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */\n    // \"outDir\": \"./\",                                   /* Specify an output folder for all emitted files. */\n    // \"removeComments\": true,                           /* Disable emitting comments. */\n    // \"noEmit\": true,                                   /* Disable emitting files from a compilation. */\n    // \"importHelpers\": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */\n    // \"importsNotUsedAsValues\": \"remove\",               /* Specify emit/checking behavior for imports that are only used for types. */\n    // \"downlevelIteration\": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */\n    // \"sourceRoot\": \"\",                                 /* Specify the root path for debuggers to find the reference source code. */\n    // \"mapRoot\": \"\",                                    /* Specify the location where debugger should locate map files instead of generated locations. */\n    // \"inlineSources\": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */\n    // \"emitBOM\": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */\n    // \"newLine\": \"crlf\",                                /* Set the newline character for emitting files. */\n    // \"stripInternal\": true,                            /* Disable emitting declarations that have '@internal' in their JSDoc comments. */\n    // \"noEmitHelpers\": true,                            /* Disable generating custom helper functions like '__extends' in compiled output. */\n    // \"noEmitOnError\": true,                            /* Disable emitting files if any type checking errors are reported. */\n    // \"preserveConstEnums\": true,                       /* Disable erasing 'const enum' declarations in generated code. */\n    // \"declarationDir\": \"./\",                           /* Specify the output directory for generated declaration files. */\n    // \"preserveValueImports\": true,                     /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */\n\n    /* Interop Constraints */\n    // \"isolatedModules\": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */\n    // \"verbatimModuleSyntax\": true,                     /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */\n    \"allowSyntheticDefaultImports\": true /* Allow 'import x from y' when a module doesn't have a default export. */,\n    \"esModuleInterop\": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,\n    // \"preserveSymlinks\": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */\n    \"forceConsistentCasingInFileNames\": true /* Ensure that casing is correct in imports. */,\n\n    /* Type Checking */\n    \"strict\": true /* Enable all strict type-checking options. */,\n    // \"noImplicitAny\": true,                            /* Enable error reporting for expressions and declarations with an implied 'any' type. */\n    // \"strictNullChecks\": true,                         /* When type checking, take into account 'null' and 'undefined'. */\n    // \"strictFunctionTypes\": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */\n    // \"strictBindCallApply\": true,                      /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */\n    // \"strictPropertyInitialization\": true,             /* Check for class properties that are declared but not set in the constructor. */\n    // \"noImplicitThis\": true,                           /* Enable error reporting when 'this' is given the type 'any'. */\n    // \"useUnknownInCatchVariables\": true,               /* Default catch clause variables as 'unknown' instead of 'any'. */\n    // \"alwaysStrict\": true,                             /* Ensure 'use strict' is always emitted. */\n    // \"noUnusedLocals\": true,                           /* Enable error reporting when local variables aren't read. */\n    // \"noUnusedParameters\": true,                       /* Raise an error when a function parameter isn't read. */\n    // \"exactOptionalPropertyTypes\": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */\n    // \"noImplicitReturns\": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */\n    // \"noFallthroughCasesInSwitch\": true,               /* Enable error reporting for fallthrough cases in switch statements. */\n    // \"noUncheckedIndexedAccess\": true,                 /* Add 'undefined' to a type when accessed using an index. */\n    // \"noImplicitOverride\": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */\n    // \"noPropertyAccessFromIndexSignature\": true,       /* Enforces using indexed accessors for keys declared using an indexed type. */\n    // \"allowUnusedLabels\": true,                        /* Disable error reporting for unused labels. */\n    // \"allowUnreachableCode\": true,                     /* Disable error reporting for unreachable code. */\n\n    /* Completeness */\n    // \"skipDefaultLibCheck\": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */\n    \"skipLibCheck\": true /* Skip type checking all .d.ts files. */\n  }\n}\n"
  },
  {
    "path": "util.ts",
    "content": "import { fileURLToPath } from 'url';\nimport { dirname, join } from 'path';\nimport { tmpdir } from 'os';\nimport { lstat } from 'fs/promises';\nimport { mkdirp } from 'mkdirp';\n\ntype Model = {\n  \"language\": string\n  \"url\": string\n  \"name\": string\n  \"label\": string\n  \"size\": number\n  \"notes\": string\n  \"type\": string\n}\n\nexport const videoExtensions = [\"mp4\", \"mov\", \"mkv\", \"avi\", \"webm\"];\nexport const audioExtensions = [\n  \"wav\",\n  \"mp3\",\n  \"ogg\",\n  \"flac\",\n  \"aac\",\n  \"wma\",\n  \"m4a\",\n];\n\nexport const __filename = fileURLToPath(import.meta.url);\nexport const __dirname = dirname(__filename);\nexport const workingDir = join(tmpdir(), \"gen-subs\")\nexport const getModelDir = async () => {\n  try {\n    await lstat(join(workingDir, \"models\"))\n    return join(workingDir, \"models\")\n  } catch {\n    await mkdirp(join(workingDir, \"models\"))\n    return join(workingDir, \"models\")\n  }\n}\n\nexport const isModelDownloaded = async (name: string) => {\n  return await lstat(join(workingDir, \"models\", name))\n    .then(() => true)\n    .catch(() => false);\n}\n\nexport const formatSize = (size: number) => {\n  if (size < 1024) {\n    return size + 'MB';\n  } else {\n    // Divide by 1024 and fix to 2 decimal places\n    return (size / 1024).toFixed(2) + 'GB';\n  }\n}\n\nexport const models: Model[] = [\n  {\n    \"language\": \"English\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip\",\n    \"name\": \"vosk-model-small-en-us-0.15\",\n    \"label\": \"small-en-us-0.15\",\n    \"size\": 40,\n    \"notes\": \"Lightweight wideband model for Android and RPi\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"English\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-en-us-0.22.zip\",\n    \"name\": \"vosk-model-en-us-0.22\",\n    \"label\": \"en-us-0.22\",\n    \"size\": 1800,\n    \"notes\": \"Accurate generic US English model\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"English\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-en-us-0.22-lgraph.zip\",\n    \"name\": \"vosk-model-en-us-0.22-lgraph\",\n    \"label\": \"en-us-0.22-lgraph\",\n    \"size\": 128,\n    \"notes\": \"Big US English model with dynamic graph\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"English\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-en-us-0.42-gigaspeech.zip\",\n    \"name\": \"vosk-model-en-us-0.42-gigaspeech\",\n    \"label\": \"en-us-0.42-gigaspeech\",\n    \"size\": 2300,\n    \"notes\": \"Accurate generic US English model trained by Kaldi on Gigaspeech. Mostly for podcasts, not for telephony\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"English\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-en-us-daanzu-20200905.zip\",\n    \"name\": \"vosk-model-en-us-daanzu-20200905\",\n    \"label\": \"en-us-daanzu-20200905\",\n    \"size\": 1000,\n    \"notes\": \"Wideband model for dictation from Kaldi-active-grammar project\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"English\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-en-us-daanzu-20200905-lgraph.zip\",\n    \"name\": \"vosk-model-en-us-daanzu-20200905-lgraph\",\n    \"label\": \"en-us-daanzu-20200905-lgraph\",\n    \"size\": 129,\n    \"notes\": \"Wideband model for dictation from Kaldi-active-grammar project with configurable graph\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"English\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-en-us-librispeech-0.2.zip\",\n    \"name\": \"vosk-model-en-us-librispeech-0.2\",\n    \"label\": \"en-us-librispeech-0.2\",\n    \"size\": 845,\n    \"notes\": \"Repackaged Librispeech model from Kaldi, not very accurate\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"English\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-en-us-zamia-0.5.zip\",\n    \"name\": \"vosk-model-small-en-us-zamia-0.5\",\n    \"label\": \"small-en-us-zamia-0.5\",\n    \"size\": 49,\n    \"notes\": \"Repackaged Zamia model f_250, mainly for research\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"English\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-en-us-aspire-0.2.zip\",\n    \"name\": \"vosk-model-en-us-aspire-0.2\",\n    \"label\": \"en-us-aspire-0.2\",\n    \"size\": 1400,\n    \"notes\": \"Kaldi original ASPIRE model, not very accurate\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"English\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-en-us-0.21.zip\",\n    \"name\": \"vosk-model-en-us-0.21\",\n    \"label\": \"en-us-0.21\",\n    \"size\": 1600,\n    \"notes\": \"Wideband model previous generation\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Indian English\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-en-in-0.5.zip\",\n    \"name\": \"vosk-model-en-in-0.5\",\n    \"label\": \"en-in-0.5\",\n    \"size\": 1000,\n    \"notes\": \"Generic Indian English model for telecom and broadcast\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Indian English\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-en-in-0.4.zip\",\n    \"name\": \"vosk-model-small-en-in-0.4\",\n    \"label\": \"small-en-in-0.4\",\n    \"size\": 36,\n    \"notes\": \"Lightweight Indian English model for mobile applications\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Chinese\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip\",\n    \"name\": \"vosk-model-small-cn-0.22\",\n    \"label\": \"small-cn-0.22\",\n    \"size\": 42,\n    \"notes\": \"Lightweight model for Android and RPi\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Chinese\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-cn-0.22.zip\",\n    \"name\": \"vosk-model-cn-0.22\",\n    \"label\": \"cn-0.22\",\n    \"size\": 1300,\n    \"notes\": \"Big generic Chinese model for server processing\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Chinese\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-cn-kaldi-multicn-0.15.zip\",\n    \"name\": \"vosk-model-cn-kaldi-multicn-0.15\",\n    \"label\": \"cn-kaldi-multicn-0.15\",\n    \"size\": 1500,\n    \"notes\": \"Original Wideband Kaldi multi-cn model from Kaldi with Vosk LM\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Russian\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-ru-0.42.zip\",\n    \"name\": \"vosk-model-ru-0.42\",\n    \"label\": \"ru-0.42\",\n    \"size\": 1800,\n    \"notes\": \"Big mixed band Russian model for servers\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Russian\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip\",\n    \"name\": \"vosk-model-small-ru-0.22\",\n    \"label\": \"small-ru-0.22\",\n    \"size\": 45,\n    \"notes\": \"Lightweight wideband model for Android/iOS and RPi\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Russian\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-ru-0.22.zip\",\n    \"name\": \"vosk-model-ru-0.22\",\n    \"label\": \"ru-0.22\",\n    \"size\": 1500,\n    \"notes\": \"Big mixed band Russian model for servers\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Russian\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-ru-0.10.zip\",\n    \"name\": \"vosk-model-ru-0.10\",\n    \"label\": \"ru-0.10\",\n    \"size\": 2500,\n    \"notes\": \"Big narrowband Russian model for servers\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"French\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-fr-0.22.zip\",\n    \"name\": \"vosk-model-small-fr-0.22\",\n    \"label\": \"small-fr-0.22\",\n    \"size\": 41,\n    \"notes\": \"Lightweight wideband model for Android/iOS and RPi\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"French\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-fr-0.22.zip\",\n    \"name\": \"vosk-model-fr-0.22\",\n    \"label\": \"fr-0.22\",\n    \"size\": 1400,\n    \"notes\": \"Big accurate model for servers\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"French\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-fr-pguyot-0.3.zip\",\n    \"name\": \"vosk-model-small-fr-pguyot-0.3\",\n    \"label\": \"small-fr-pguyot-0.3\",\n    \"size\": 39,\n    \"notes\": \"Lightweight wideband model for Android and RPi trained by Paul Guyot\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"French\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-fr-0.6-linto-2.2.0.zip\",\n    \"name\": \"vosk-model-fr-0.6-linto-2.2.0\",\n    \"label\": \"fr-0.6-linto-2.2.0\",\n    \"size\": 1500,\n    \"notes\": \"Model from LINTO project\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"German\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-de-0.21.zip\",\n    \"name\": \"vosk-model-de-0.21\",\n    \"label\": \"de-0.21\",\n    \"size\": 1900,\n    \"notes\": \"Big German model for telephony and server\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"German\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-de-tuda-0.6-900k.zip\",\n    \"name\": \"vosk-model-de-tuda-0.6-900k\",\n    \"label\": \"de-tuda-0.6-900k\",\n    \"size\": 4400,\n    \"notes\": \"Latest big wideband model from Tuda-DE project\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"German\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-de-zamia-0.3.zip\",\n    \"name\": \"vosk-model-small-de-zamia-0.3\",\n    \"label\": \"small-de-zamia-0.3\",\n    \"size\": 49,\n    \"notes\": \"Zamia f_250 small model repackaged (not recommended)\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"German\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-de-0.15.zip\",\n    \"name\": \"vosk-model-small-de-0.15\",\n    \"label\": \"small-de-0.15\",\n    \"size\": 45,\n    \"notes\": \"Lightweight wideband model for Android and RPi\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Spanish\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-es-0.42.zip\",\n    \"name\": \"vosk-model-small-es-0.42\",\n    \"label\": \"small-es-0.42\",\n    \"size\": 39,\n    \"notes\": \"Lightweight wideband model for Android and RPi\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Spanish\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-es-0.42.zip\",\n    \"name\": \"vosk-model-es-0.42\",\n    \"label\": \"es-0.42\",\n    \"size\": 1400,\n    \"notes\": \"Big model for Spanish\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Portuguese/Brazilian\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-pt-0.3.zip\",\n    \"name\": \"vosk-model-small-pt-0.3\",\n    \"label\": \"small-pt-0.3\",\n    \"size\": 31,\n    \"notes\": \"Lightweight wideband model for Android and RPi\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Portuguese/Brazilian\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-pt-fb-v0.1.1-20220516_2113.zip\",\n    \"name\": \"vosk-model-pt-fb-v0.1.1-20220516_2113\",\n    \"label\": \"pt-fb-v0.1.1-20220516_2113\",\n    \"size\": 1600,\n    \"notes\": \"Big model from FalaBrazil\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Greek\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-el-gr-0.7.zip\",\n    \"name\": \"vosk-model-el-gr-0.7\",\n    \"label\": \"el-gr-0.7\",\n    \"size\": 1100,\n    \"notes\": \"Big narrowband Greek model for server processing, not extremely accurate though\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Turkish\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-tr-0.3.zip\",\n    \"name\": \"vosk-model-small-tr-0.3\",\n    \"label\": \"small-tr-0.3\",\n    \"size\": 35,\n    \"notes\": \"Lightweight wideband model for Android and RPi\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Vietnamese\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-vn-0.4.zip\",\n    \"name\": \"vosk-model-small-vn-0.4\",\n    \"label\": \"small-vn-0.4\",\n    \"size\": 32,\n    \"notes\": \"Lightweight Vietnamese model\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Vietnamese\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-vn-0.4.zip\",\n    \"name\": \"vosk-model-vn-0.4\",\n    \"label\": \"vn-0.4\",\n    \"size\": 78,\n    \"notes\": \"Bigger Vietnamese model for server\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Italian\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-it-0.22.zip\",\n    \"name\": \"vosk-model-small-it-0.22\",\n    \"label\": \"small-it-0.22\",\n    \"size\": 48,\n    \"notes\": \"Lightweight model for Android and RPi\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Italian\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-it-0.22.zip\",\n    \"name\": \"vosk-model-it-0.22\",\n    \"label\": \"it-0.22\",\n    \"size\": 1200,\n    \"notes\": \"Big generic Italian model for servers\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Dutch\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-nl-0.22.zip\",\n    \"name\": \"vosk-model-small-nl-0.22\",\n    \"label\": \"small-nl-0.22\",\n    \"size\": 39,\n    \"notes\": \"Lightweight model for Dutch\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Dutch\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-nl-spraakherkenning-0.6.zip\",\n    \"name\": \"vosk-model-nl-spraakherkenning-0.6\",\n    \"label\": \"nl-spraakherkenning-0.6\",\n    \"size\": 860,\n    \"notes\": \"Medium Dutch model from Kaldi_NL\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Dutch\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-nl-spraakherkenning-0.6-lgraph.zip\",\n    \"name\": \"vosk-model-nl-spraakherkenning-0.6-lgraph\",\n    \"label\": \"nl-spraakherkenning-0.6-lgraph\",\n    \"size\": 100,\n    \"notes\": \"Smaller model with dynamic graph\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Catalan\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-ca-0.4.zip\",\n    \"name\": \"vosk-model-small-ca-0.4\",\n    \"label\": \"small-ca-0.4\",\n    \"size\": 42,\n    \"notes\": \"Lightweight wideband model for Android and RPi for Catalan\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Arabic\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-ar-mgb2-0.4.zip\",\n    \"name\": \"vosk-model-ar-mgb2-0.4\",\n    \"label\": \"ar-mgb2-0.4\",\n    \"size\": 318,\n    \"notes\": \"Repackaged Arabic model trained on MGB2 dataset from Kaldi\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Arabic\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-ar-0.22-linto-1.1.0.zip\",\n    \"name\": \"vosk-model-ar-0.22-linto-1.1.0\",\n    \"label\": \"ar-0.22-linto-1.1.0\",\n    \"size\": 1300,\n    \"notes\": \"Big model from LINTO project\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Farsi\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-fa-0.4.zip\",\n    \"name\": \"vosk-model-small-fa-0.4\",\n    \"label\": \"small-fa-0.4\",\n    \"size\": 47,\n    \"notes\": \"Lightweight wideband model for Android and RPi for Farsi (Persian)\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Farsi\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-fa-0.5.zip\",\n    \"name\": \"vosk-model-fa-0.5\",\n    \"label\": \"fa-0.5\",\n    \"size\": 1000,\n    \"notes\": \"Model with large vocabulary, not yet accurate but better than before (Persian)\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Farsi\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-fa-0.5.zip\",\n    \"name\": \"vosk-model-small-fa-0.5\",\n    \"label\": \"small-fa-0.5\",\n    \"size\": 60,\n    \"notes\": \"Bigger small model for desktop application (Persian)\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Filipino\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-tl-ph-generic-0.6.zip\",\n    \"name\": \"vosk-model-tl-ph-generic-0.6\",\n    \"label\": \"tl-ph-generic-0.6\",\n    \"size\": 320,\n    \"notes\": \"Medium wideband model for Filipino (Tagalog) by feddybear\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Ukrainian\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-uk-v3-nano.zip\",\n    \"name\": \"vosk-model-small-uk-v3-nano\",\n    \"label\": \"small-uk-v3-nano\",\n    \"size\": 73,\n    \"notes\": \"Nano model from Speech Recognition for Ukrainian\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Ukrainian\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-uk-v3-small.zip\",\n    \"name\": \"vosk-model-small-uk-v3-small\",\n    \"label\": \"small-uk-v3-small\",\n    \"size\": 133,\n    \"notes\": \"Small model from Speech Recognition for Ukrainian\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Ukrainian\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-uk-v3.zip\",\n    \"name\": \"vosk-model-uk-v3\",\n    \"label\": \"uk-v3\",\n    \"size\": 343,\n    \"notes\": \"Bigger model from Speech Recognition for Ukrainian\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Ukrainian\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-uk-v3-lgraph.zip\",\n    \"name\": \"vosk-model-uk-v3-lgraph\",\n    \"label\": \"uk-v3-lgraph\",\n    \"size\": 325,\n    \"notes\": \"Big dynamic model from Speech Recognition for Ukrainian\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Kazakh\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-kz-0.15.zip\",\n    \"name\": \"vosk-model-small-kz-0.15\",\n    \"label\": \"small-kz-0.15\",\n    \"size\": 42,\n    \"notes\": \"Small mobile model from SAIDA_Kazakh\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Kazakh\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-kz-0.15.zip\",\n    \"name\": \"vosk-model-kz-0.15\",\n    \"label\": \"kz-0.15\",\n    \"size\": 378,\n    \"notes\": \"Bigger wideband model SAIDA_Kazakh\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Swedish\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-sv-rhasspy-0.15.zip\",\n    \"name\": \"vosk-model-small-sv-rhasspy-0.15\",\n    \"label\": \"small-sv-rhasspy-0.15\",\n    \"size\": 289,\n    \"notes\": \"Repackaged model from Rhasspy project\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Japanese\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-ja-0.22.zip\",\n    \"name\": \"vosk-model-small-ja-0.22\",\n    \"label\": \"small-ja-0.22\",\n    \"size\": 48,\n    \"notes\": \"Lightweight wideband model for Japanese\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Japanese\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-ja-0.22.zip\",\n    \"name\": \"vosk-model-ja-0.22\",\n    \"label\": \"ja-0.22\",\n    \"size\": 1024,\n    \"notes\": \"Big model for Japanese\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Esperanto\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-eo-0.42.zip\",\n    \"name\": \"vosk-model-small-eo-0.42\",\n    \"label\": \"small-eo-0.42\",\n    \"size\": 42,\n    \"notes\": \"Lightweight model for Esperanto\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Hindi\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-hi-0.22.zip\",\n    \"name\": \"vosk-model-small-hi-0.22\",\n    \"label\": \"small-hi-0.22\",\n    \"size\": 42,\n    \"notes\": \"Lightweight model for Hindi\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Hindi\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-hi-0.22.zip\",\n    \"name\": \"vosk-model-hi-0.22\",\n    \"label\": \"hi-0.22\",\n    \"size\": 1536,\n    \"notes\": \"Big accurate model for servers\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Czech\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-cs-0.4-rhasspy.zip\",\n    \"name\": \"vosk-model-small-cs-0.4-rhasspy\",\n    \"label\": \"small-cs-0.4-rhasspy\",\n    \"size\": 44,\n    \"notes\": \"Lightweight model for Czech from Rhasspy project\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Polish\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-pl-0.22.zip\",\n    \"name\": \"vosk-model-small-pl-0.22\",\n    \"label\": \"small-pl-0.22\",\n    \"size\": 50,\n    \"notes\": \"Lightweight model for Polish\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Uzbek\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-uz-0.22.zip\",\n    \"name\": \"vosk-model-small-uz-0.22\",\n    \"label\": \"small-uz-0.22\",\n    \"size\": 49,\n    \"notes\": \"Lightweight model for Uzbek\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Korean\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-small-ko-0.22.zip\",\n    \"name\": \"vosk-model-small-ko-0.22\",\n    \"label\": \"small-ko-0.22\",\n    \"size\": 82,\n    \"notes\": \"Lightweight model for Korean\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Breton\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-br-0.8.zip\",\n    \"name\": \"vosk-model-br-0.8\",\n    \"label\": \"br-0.8\",\n    \"size\": 70,\n    \"notes\": \"Breton model from vosk-br project\",\n    \"type\": \"local\"\n  },\n  {\n    \"language\": \"Speaker identification model\",\n    \"url\": \"https://alphacephei.com/vosk/models/vosk-model-spk-0.4.zip\",\n    \"name\": \"vosk-model-spk-0.4\",\n    \"label\": \"spk-0.4\",\n    \"size\": 13,\n    \"notes\": \"Model for speaker identification, should work for all languages\",\n    \"type\": \"local\"\n  }\n];"
  }
]