Synchronized Closed Captions for Channels

I’ve long wanted to get better closed captions support for live TV such as news broadcasts, that is synchronized with the audio instead of delayed by several seconds. I started looking for systems that can produce synchronized captions using ASR like YouTube does. I found that Whisper was generally recommended, so I started with that. After a while, I found Faster Whisper, which worked even better. I put together a shell script prototype on my own Channels server which I have running on a Debian linux box. It actually worked, but it had all kinds of problems with the way that I chose to integrate it with Channels. Then I reimplemented it using Python in MS Code with CoPilot. The AI did all the actual coding, which was awesome, BTW.

I’ve tested and used the linux version the most. It uses Docker and can be installed by simply bringing up the image. The same is true for Windows, but I found a problem with Docker Desktop. It doesn’t provide full GPU support, so I used the Docker engine in WSL instead. The installation will reconfigure WSL to use the engine instead of Docker Desktop, so please be aware of that.

The tricky part is getting the full GPU support in the Docker container. Please see the GitHub docs for a full description of the process.

You’ll also see that the documentation implies that some clients can use the SRT files alone. As far as I can tell, this is true for library files, but not for TV recordings. I’m not 100% sure of that, as the only Apple device I have for testing is an iPhone. The one test I did with that ignored the SRT files that this system created, but it’s possible that the naming convention I used is a problem. I don’t know yet. It would be great if Fancy Bits would add SRT support for recordings, because that would make CPU-only totally workable.

py-captions-for-channels

Automatic closed-caption generation for Channels DVR recordings using Faster Whisper.

Monitors your DVR for completed recordings, transcribes them with Faster Whisper, and writes SRT caption files that Channels clients pick up automatically.

Features

  • Fully Automatic β€” Detects completed recordings via Channels server API polling or ChannelWatch webhooks and queues them for captioning
  • GPU-Accelerated β€” NVIDIA CUDA + NVENC/NVDEC for fast transcription and encoding (7x faster than CPU)
  • Web Dashboard β€” Real-time status, execution history, settings, system/GPU monitoring, and manual reprocessing
  • Smart Optimization β€” Automatically tunes Whisper and ffmpeg parameters based on source type (OTA vs streaming)
  • Show Whitelist β€” Process only the shows you care about; interactive toggle from the web UI
  • Personal Media Libraries β€” Caption VHS transfers, home movies, ripped Blu-rays, and other personal media stored outside the DVR. Auto-discovers mount paths from Channels DVR; supports up to three separate NAS/server mounts
  • Embedded Captions Track β€” Optional MP4 transcoding with an embedded captions track for clients that don't support sidecar SRT files (required for recorded TV shows)
  • Idempotent β€” Tracks processed recordings in a database to avoid duplicates
  • Quarantine System β€” Cleans up orphaned .srt and .orig files after source media is deleted, conserving storage space
  • Dry-Run Mode β€” Test the full pipeline without modifying any files
  • Docker Ready β€” Single docker-compose up with NVIDIA GPU passthrough

Requirements

  • Channels DVR server (with recordings accessible via network/mount)
  • NVIDIA GPU with 6GB+ VRAM (GTX 1660 Super / RTX 2060 or better)
  • NVIDIA driver β‰₯ 520 on the Docker host (supports CUDA 12.2, which the container requires). Check with nvidia-smi β€” the "CUDA Version" shown must be 12.2 or higher. If it's lower, GPU acceleration will silently fall back to CPU; the container logs a warning at startup identifying the mismatch.
  • Docker with NVIDIA Container Toolkit (nvidia-container-toolkit)
  • ChannelWatch (optional, for webhook-based detection instead of polling)

CPU-only operation is possible but significantly slower (~10 min SRT-only, ~10–20 min with Fire TV transcoding, per 1-hour recording).

Timings below are for the default SRT-only mode. TRANSCODE_FOR_FIRETV=true adds encoding time (see GPU Configuration).

Hardware 1-hr OTA Recording 1-hr TVE (Streaming) Daily Capacity
CPU only ~10–15 min ~10 min Very limited
RTX 2080 (11GB) ~3–5 min ~1–2 min 20+ hours
RTX 3060/4060+ ~2–4 min ~1–2 min 24+ hours

Installation

Linux β€” one command (run on the server; installs Docker, GPU toolkit, and the container):

bash <(curl -fsSL https://raw.githubusercontent.com/jay3702/py-captions-for-channels/main/scripts/setup-linux.sh)

Windows β€” one command (run in an Administrator PowerShell window; installs WSL2, Docker, GPU toolkit, and the container):

Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass

irm https://raw.githubusercontent.com/jay3702/py-captions-for-channels/main/setup-windows.ps1 | iex

You can also clone the GitHub repository and run the setup-linux or setup-windows scripts in the scripts folder.

I plan on adding support for Intel GPU’s, but I don’t have a way to test AMD GPU’s at this time.

2 Likes

I added a simple player for Channels content to the main web page. It allows you to play items in the Recordings dialog, and it enables browsing and playing all of the Channels DVR content.

I also created a stand-alone Windows player for Channels DVR: WinChannels

Here's a screencap of it with recently completed processes. This is on my media server, an I-7700k with an RTX 2080 running Ubuntu 24.04. The server is also hosting the Channels server, CC4C, as well as a few other Docker images. As you can see, it is taking about 5 minutes per hour of recorded programming to process.

You should be able produce similar results with an i5-8000 class SFF computer with a low profile NVIDIA GPU such as the GTX 1660 Super. That combo is about $250 - $300 on Ebay.

The free tier is limited to 60 minutes per month (for 1 year) for both Google and AWS. The cost of my typical monthly rate (about 317 hours/month) would be between $100 and $200 per month using their batch rates. It's higher for streaming. So, buying the hardware would pay for itself an just a few months.

That's interesting. In addition to being more efficient and faster than Whisper, it is also reported to be more accurate. It's mostly English only, and there is no Intel GPU support (naturally), but it's worth a look - thanks!

Groq is definitely cheaper. If I'm reading the pricing and rules correctly, my workload would end up costing only $3/month, provided I stay within their hourly and daily limits. It would cost $12 - $35 without the free tier. It's hard to imagine that they will keep that level of free service once they feel that they are established enough. 8 hours a days is a lot!. They also limit the number of hours of transcribed audio to 2 per hour, so throttling would be needed, or just paying extra. This is definitely a possibility for lighter workloads.

I can't find any reference to a product or service from cerebras.ai for speech to text.

Ha - Never! But, it's too good to be true forever. I might try it...

BTW - this is already implemented as a Docker image using the NVIDIA Container Toolkit. Intel is not supported in this build because Faster Whisper uses CTranslate2 which has no Intel support. Neither does Parakeet. The cloud method would fix that though...

2 Likes

Thanks for that insight! Turns out that Claude was wrong about Intel support. I probably needed to push back harder! Anyway, it looks like whisper.cpp can be used with Intel. Cohere is impressive, but it has a fatal flaw for this project, which is that it does not provide timestamps. To use it, I'd need to run a post process synchronization pass which would likely nullify it's performance benefit, not to mention yet another way the the pipeline could misbehave. I have some benchmarks that are interesting. These are results for a 15 minute broadcast TV audio file:

Engine Backend Elapsed time (900s clip) Real-time factor Status
Groq (whisper-large-v3-turbo) cloud API 2.7s ~0.003x reliable, verified correct
niu production (35 real jobs, matched) RTX 2080 Ti, medium (faster-whisper) 40.7s (scaled) ~0.045x reliable β€” transcription-only, n=35 sample
Parakeet TDT CPU only (XPS 15, i7-12700H) 86.1s ~0.096x reliable, verified correct
whisper.cpp medium.en Iris Xe / Vulkan (XPS 15) 268.8s (4m29s) ~0.30x reliable, verified correct
Parakeet TDT Iris Xe / Vulkan (XPS 15) β€” β€” broken β€” crashes or produces zero output
Parakeet TDT RTX 3050 Ti / Vulkan (XPS 15) β€” β€” broken β€” fixed ~6GB buffer exceeds 4GB VRAM
  • The niu row is this project's real aggregate production average scaled to a 900s clip, not a direct test of this same file
  • Both Parakeet GPU rows are left blank rather than given a number, since every GPU run today either crashed outright or completed with zero usable transcript text despite real decode activity in the timing counters β€” there's no trustworthy speed figure to report for GPU Parakeet on this build.

The numbers from niu are from my media server.

The surprise is the CPU-only run for Parakeet. It looks like the GPU drivers are not ready for production yet (or I haven't figured out how to use them properly yet).

The Groq result is encouraging. The trick with that one will be scheduling when and if more than two jobs are needed per hour. I haven't scrutinized the billing rules yet, so I don't know what actually happens when you exceed the hourly or daily limits. I noticed that Groq is offering free keys, but not development keys. TBD...

Groq and Parakeet are now implemented, however, I have not tested Parakeet yet. I want to see how it performs on a small system like an N100 or N95 based server. If you choose to use Groq, it will keep track of your usage and fall back on whatever local processing is available on your system. Here are my results for the past 24 hours:

Groq usage β€” rolling 24-hour window

Window: 2026-08-13 19:50 β†’ 2026-08-14 19:50 Pacific (right now, this is the actual quota-enforcement window β€” not calendar day)

Job time (local) Chunks Audio minutes
08-13 20:17 7 61.0 min
08-13 20:33 3 31.0 min
08-13 23:01 6 60.0 min
08-13 23:07 4 31.0 min
08-14 08:02 3 26.5 min
08-14 09:02 6 60.0 min
08-14 10:03 6 60.0 min
08-14 11:03 6 60.0 min
08-14 11:32 3 31.0 min
08-14 17:07 3 31.0 min
Total 47 requests, 10 jobs 451.4 min

Free-tier ASD cap: 480 min/day (28,800 audio-seconds), rolling β€” currently 94.0% used, which is why fallbacks to local transcription have been happening throughout the day.

Pipeline throughput, normalized per audio-hour

(same window: since Groq went live, ~24h ago)

Engine Jobs Avg Median Min Max
Groq 8 122.2 s/hr 125.6 s/hr 83.1 s/hr 152.6 s/hr
Local (faster-whisper) 20 309.8 s/hr 326.5 s/hr 76.8 s/hr 411.4 s/hr
  • Median is the fairer comparison β€” Groq runs the pipeline ~2.6x faster per hour of audio (125.6 vs 326.5 s/hr).
  • Min-to-min, they do overlap: local's fastest job (76.8 s/hr, CNN News Central) edges out Groq's fastest (83.1 s/hr) and sits inside Groq's overall range β€” but that's an artifact of per-channel OPTIMIZATION_MODE tuning on a specific low-complexity show, not typical local performance. Every other local job in the sample is 2–5x slower than Groq's median.

That's comparing Groq transcription to Faster-Whisper on my I770k/RTX2080 system which is reasonably quick.

Of the total workload today, 30% β€” 6 of the 20 completed recordings today used Groq; the other 14 fell back to local (faster-whisper) transcription, mostly due to the rolling-24h ASD quota tightening as the day went on.

I still need to test this on a system with an Intel GPU because the pipeline uses ffmpeg for various parts of the process and those also benefit from acceleration.

Stay tuned...

I also explored that general idea at one point as a proof of concept. I was thinking that if the client could use the .srt file as it was being written, maybe it could be done quickly enough to only be a few seconds behind real time. That ended up in the probably too hard or troublesome bucket since it would rely on the specific Channels client being able to use the file at all, as well as while it was open for writing. The Channels devs would need to actively support that.

But implementing it in a hardware pipeline, like the CNN machine I just built might actually work...

Claude agrees with that assessment, and also pointed out this:

Worth knowing before you or mackid rebuild anything: someone has already shipped essentially this prototype. There's a tool (Live-Caption-Encoder / cc_injector) that injects CEA-608 into a live MPEG-TS in real time, taking caption text over UDP from a Whisper audio pipeline and embedding it as A/53 CC via GA94 SEI using libx264 with a53cc=1. It's 608-only, no 708 yet, but for CNN roll-up news captions 608 is honestly fine. That's a reference implementation to fork rather than reinvent. GitHub GitHub

This isn't something I want to pursue right now, but it's interesting. Maybe something could be built out of that.

Not too sure whats going on here. Just fired it up for the first time and it crashes with this error

/usr/bin/python: No module named py_captions_for_channels.main; 'py_captions_for_channels' is a package and cannot be directly executed

Which installation process did you use?

1 Like

Docker/Portainer/Intel GPU

what operating system and version?

Lubuntu and I'm running the latest version

Found it β€” thanks for reporting this, it was a real bug. Not your setup.

The short version: the container's docker-compose file had a leftover developer setting that replaces the app's code inside the container with whatever's in the folder next to your compose file. For a normal git clone install that's harmless, but with Portainer that folder doesn't actually contain the source β€” so it wiped out the container's entry point and it crashed on startup with that error. Nothing you did wrong.

I just pushed a fix. Once the new image finishes building (check back in an hour or so), here's what to do:

  1. Pull the new image and redeploy the stack in Portainer (or docker compose pull && docker compose up -d if you're on the CLI).
  2. If you're sticking with Portainer, there's now a dedicated Docker / Portainer section in INSTALL.md β€” worth following since it also covers a couple of Portainer-specific gotchas around the recordings folder path that would've bitten you next (they need to be set as stack environment variables, not just in .env).

Let me know if it still doesn't come up after redeploying and I'll dig further.

After it successfully installs, the next challenge will be to get it to work. I've tested CPU-only with Intel, but I have not fully tested parakeet or groq on an Intel GPU system yet. So, you're in beta territory. Here's the instructions for switching to parakeet after running the installation wizard (The wizard doesn't know about parakeet or groq yet):

Two equivalent ways β€” no container restart needed either way, since the transcription step runs as a fresh subprocess (python -m py_captions_for_channels.embed_captions) per job that re-reads .env from scratch. Whatever's set takes effect on the next job, not before.

Via the web UI: :gear: Settings β†’ find WHISPER_LOCAL_ENGINE β†’ switch dropdown from faster-whisper to parakeet β†’ Save. PARAKEET_DEVICE should already default to cpu (the only supported value right now).

Via .env directly (or the equivalent Portainer stack var if you went that route):

WHISPER_ENGINE=local
WHISPER_LOCAL_ENGINE=parakeet
PARAKEET_DEVICE=cpu

This only takes effect if WHISPER_ENGINE=local (or if it's on groq and it falls back to local on a failed cloud call)

Ok, thats a bit confusing

All of the environmental variables should be in the docker-compose file then. Having another .env file on top of the environmental variables that already has to be set is confusing AF and unnecessary. I think thats causing all the bugs here

Fixed and tested end-to-end β€” please give it another try. Redeploy from the current main/latest image; the crash you hit is gone. There's now a dedicated pre-flight script in INSTALL.md that validates your DVR URL and recordings path before deploying, so you shouldn't hit a repeat of the original issue. After it's up, run the in-app Setup Wizard (:gear: gear icon) β€” it's a required step, not optional, see below. Let me know how it goes.

Neither of the local transcription engines, faster-whisper nor parakeet support the Intel GPU, so CPU is the only option for now. CTranslate2 (Whisper) only supports NVIDA, and the project doesn't have any plans for Intel support at this time. Parakeet supports it, but it doesn't work yet, so that may change soon.

Thumbnail sketch of the actual process:

  1. Portainer Pre-flight setup-portainer-env.sh β€” validates the DVR is reachable, sets up the recordings mount, writes a config file. Runs before Docker touches anything.
  2. Deploy β€” paste docker-compose.yml into Portainer, load the config file as environment variables, deploy.
  3. Setup Wizard (:gear: gear icon, web dashboard) β€” required, not optional. The pre-flight step deliberately doesn't know the mapping between how the DVR server reports a recording's path internally and where that file actually lives inside the container β€” those two paths are rarely identical. Without running the wizard, the app can start up fine and still fail to find any actual recording β€” it looks like it's working until you try to process something. The wizard queries the DVR API and auto-detects this mapping for you.
  4. Select a recording from the Recordings list. That produces a one-time manual process. Use the whitelist checkbox to record every future instance of that show or use the whitelist interface itself to create more complex processing rules.
  5. Turn off Dry Run in Settings when ready to go live.

If not using Portainer, use setup-linux.sh and continue the process above from step 3. This also works with Portainer but you don't get full Portainer control (no stack created).

You can try Groq after you set up local transcription. Get a free API key from them, put the key into settings and change the transcription source from local to cloud (Groq).

Actually, I might be able to get some Intel transcription support. I'm building and testing it now. Meanwhile, go ahead with the current image.