github debpalash/OmniVoice-Studio v0.3.22
v0.3.22 — OmniVoice Studio

8 hours ago

The dubbing release. Dubbed videos stop sounding like a compromise: the music keeps its stereo width and full frequency range, short lines no longer leave dead air while the mouth keeps moving, one speaker stays one voice, and the language tabs finally switch the transcript with the audio. Underneath it, the memory fixes that ended the "can't reach the local backend" era on 16 GB machines ship at last — plus a sweep of never-again hardening drawn from an audit of every bug this project has ever closed.

Added

  • A "Voice match" toggle for dubbing — keep one steady voice per speaker. Each dubbed line clones from a snippet of its own original audio, which matches the delivery beautifully but can make the voice itself drift from line to line — most audibly on videos where speaker detection ran in fallback mode ("still 4 segments different in voice", as one report put it). A new control next to the Timing picker chooses: Per line (the default, unchanged) for the best per-line delivery match, or Consistent to clone every line of a speaker from one shared reference — the speaker's pooled sample, or the best single clip when none exists — for a steady identity across the whole dub. Flipping it honestly marks segments as needing regeneration, and the shared reference is encoded once and reused, not re-studied per line. (#1147)

  • A performance guide, at last. docs/performance.md explains where generation and dubbing time actually goes, the three classic causes of "it got slow" (an empty Transcript field on a voice profile chief among them), every tuning knob the backend reads — none of which were documented anywhere — and which settings to leave alone (raising OMNIVOICE_GPU_WORKERS on a small GPU is how you get the crash the default exists to prevent). Includes how to run the built-in profiler so a slowness report can carry numbers instead of vibes.

  • In-app analytics is now wired end to end — and still off until you say yes. The frontend analytics SDK is only ever started after you opt in (Settings → Privacy), never at app launch, so a default install still transmits nothing. Two of the SDK's defaults are explicitly disabled because they would be actively harmful here: autocapture, which sends the text content of whatever you click — in this app, the script you are about to synthesise, your voice names, your file names — and session recording, which records the screen. Events carry metadata only, filtered through the same allowlist as the backend, so no future change can leak your content by adding a field.

  • Opt-in analytics — off by default, and it can't lie to you. OmniVoice still sends nothing out of the box: no accounts, no telemetry, no phone-home, and your text, audio, voices, and projects never leave your machine regardless of what you choose. There is now one toggle in Settings → Privacy → "Help improve OmniVoice", off unless you turn it on. If you do, it sends anonymous usage stats — which engine and language you used, how long a generation took, how many characters the text had (a number, not the text), and the type of any error. It never sends the text you type, your audio, your file names, your voice names, or anything identifying you. That isn't a promise in a policy: an allowlist in the code drops any property that isn't on it, so a future change can't leak content by accident, and crash tracebacks are deliberately not auto-captured (they can carry file paths and tokens). Turning it off stops everything immediately. Builds from source have no analytics destination at all and don't even show the toggle.

  • Settings → Usage: see what you've made, counted entirely on your own machine. Takes generated, audio produced, voices, days used, and a breakdown by mode and language — all computed from the history already in your own database. It collects nothing new, stores nothing new, and transmits nothing anywhere, no matter what you've chosen under Settings → Privacy: this panel is yours, it works with analytics switched off, and it never phones home. If you want to know what you've been making, the answer shouldn't require sending it to anyone.

  • The memory panel now tells the whole truth. Settings → Models (and GET /model/loaded) used to report only the OmniVoice core model — a resident second engine like MLX-Audio, or the warm dictation model, was invisible, so the memory picture looked ~2 GB lighter than reality. It now lists every resident model (in-process engines and the dictation ASR included) and adds a system block with free/total RAM (and free VRAM on a dedicated GPU) plus a low-memory warning. On top of that, a load that starts while memory is already low leaves a breadcrumb in the backend log, so a subsequent out-of-memory kill points at the load that tipped it instead of dying silently. Advisory only — nothing is blocked (the OS can reclaim memory, and refusing a load on an estimate would brick machines that would actually cope). Tune the threshold with OMNIVOICE_LOW_MEMORY_HEADROOM_GB (default 2).

Fixed

  • Switching preview languages can't leave a mixed-language transcript. Follow-up to the tab/transcript sync: if a track's translations were only partially stored in the browser (older projects, partial regenerations), switching tabs could show German audio with a few rows still in the previous language. Missing rows now hydrate from the app's own per-language store on the backend — and a picked regional dialect is automatically cleared when you switch to a language it doesn't belong to, wherever the switch comes from. (#1149)

  • The Export step's language tabs now switch the transcript too. Clicking Bengali/German/Hindi… above the finished dub swapped the video but left the segment list showing whichever language you generated last — German audio over Bengali text. The tabs now also swap every segment's text to that language (through the same per-language store the language picker uses, so nothing is lost when you switch back); the Original tab keeps your editing language as-is, since each row already shows the original line beneath its translation. (#1148)

  • A "backend crashed" notice can no longer outlive the update that fixed the crash — and the desktop shell's self-repair paths are now pinned by tests that CI actually runs. Crash notices now record which app version wrote them, and a notice left behind by an older version is ignored and cleaned up after you upgrade instead of resurfacing as if the new build had crashed. The Windows blank-window repair (the one-click WebView cache fix after a BSOD) also gets regression tests pinning its safety contract — one attempt per request, never touches anything unasked, never blocks startup on a locked cache — and CI now runs the desktop shell's entire Rust unit-test suite on macOS, Windows, and Linux, which it previously never executed at all. (#1145)

  • The MLX-Audio phonemizer's language model now ships with the app environment instead of being fetched mid-generation. Follow-up to the pip fix: with the installer present, the first English MLX-Audio generation would auto-download a small model straight from GitHub — an outbound request that bypasses the app's mirror system (a problem on restricted networks) and fails offline. The model is now a pinned dependency of the managed environment: it arrives at install/update time through the normal dependency flow, and first generation works fully offline. (#1146)

  • The MLX-Audio engine's first English generation no longer trips over a missing installer. Its phonemizer auto-downloads a small language model on first use by shelling out to pip — which the app's managed Python environment didn't include, so the download always failed (and before the recent containment fix, took the whole backend down with it, #1133). pip now ships as a real dependency of the managed environment, so it survives app updates too — anything installed ad-hoc would have been stripped by the updater's environment sync, quietly re-breaking this after every release. (#1144)

  • A voice engine's helper library can no longer shut down the whole backend. One user's backend died 21 seconds after starting (#1133): the MLX-Audio engine's phonemizer tries to auto-download a language model on first use, the downloader is written as a command-line tool, and on failure it calls "exit the program" — which, running inside the backend, exited the backend. Any engine dependency written that way could do this. Exits are now contained at the engine-dispatch boundary and turned into a normal, explained error ("an engine dependency failed to auto-install something — see the log"), for TTS and transcription alike. The app keeps running; the failed request tells you what actually happened. (#1143)

  • Vietnamese years read like Vietnamese again. A recent release started spelling out numbers before synthesis, and its Vietnamese number library turns out to be wrong for exactly the numbers people say most — years ("2024" became "hai nghìn lẻ hai mươi bốn", which no Vietnamese speaker says). The voice model has always pronounced Vietnamese digits correctly on its own, so Vietnamese text now keeps its digits — the same conservative rule that already protected Vietnamese decimals. Also closes the loophole that made this depend on spelling: picking "Vietnamese" from the language list behaved differently from the code "vi". (#1139)

  • A voice profile's pinned seed now pins Audiobook renders too. Locking a take (or a designed voice) stores a seed so the voice performs reproducibly — and the Voice page honors it, but Audiobook/Stories renders quietly ignored it and rolled fresh randomness for every segment. Book renders with a pinned-seed profile are now deterministic end to end, matching the Voice page. And the audiobook renderer's higher generation quality (32 decoding steps — the model's own quality preset, vs. the Voice page's fast default of 16) is now pinned explicitly in code rather than inherited by accident, so it can't silently change; that steps gap is also why Audiobook sounds steadier than Voice at default settings — move the Voice page's Steps slider to 32 for the same quality. (#1139)

  • A finished audiobook's Download button stops vanishing. The player and Download link for a completed book lived only in the page's temporary state — switch tabs once and they were gone, which read as "no way to export at all" (the file was still on disk, and in Projects → Audiobooks). The last finished render now survives tab switches and reloads, right where the book was made. (#1139)

  • Six recurrence guards from a full audit of the project's issue history — aimed at "this bug can never come back, even after an update or reinstall." (1) Before loading the voice model on a memory-tight machine, the app now first releases things it already reclaims on idle (the warm dictation model, allocator caches) — the missing half of the 16 GB OOM-kill fix; roomy machines pay nothing. (2) When the operating system force-kills the backend for running out of RAM, the crash notice now says exactly that instead of blaming "VRAM" on machines that have none. (3) Saving a cloned voice with free-form text in its delivery field can no longer persist a profile that errors on every future generation — the server now sanitizes all profile kinds, closing a hole that had been re-exploited three times through different clients. (4) A reinstall that inherits an old settings file pointing at an unplugged drive or deleted folder no longer sends downloads into the void — dead paths are ignored for the run with a clear log line. (5) Locally-saved UI state is now schema-checked as a whole on restore, so one corrupted field can't silently discard everything after it (the general form of the "app got empty" fix). (6) File moves across drives (Windows D:-drive installs) get a dedicated safe-move helper, so the next code path that renames across devices degrades gracefully instead of failing with [Errno 18]. Long texts also get a generation time budget that scales with their length instead of a fixed five minutes. (#1141)

  • Dubbed videos get their stereo back — and the music's full frequency range. A/B-measuring a dub against its original showed the dubbed audio was mono in a stereo container (channel correlation 1.000 vs the original's 0.754) — the entire stereo image of the music, gone. Two causes, both fixed: the separation step was being fed the 16 kHz mono file extracted for transcription — so the music bed inherited mono and an 8 kHz ceiling at the source — and the mixer then let the mono voice drag the whole mix down to mono. Ingest now makes a second, full-quality stereo extraction (44.1 kHz) just for separation, transcription keeps its mono file, and the mixer pins both sides to stereo with the voice dead-center where dubbed dialogue belongs. Loudness already matched the original (−17.2 vs −17.8 LUFS, measured); now the width and brightness do too. (#1138)

  • Dubbed lines that finish early no longer leave dead air — they now speak at the pace of the scene. Translations routinely come out shorter than the original delivery, and the dub used to just stop early: measured on a real dub, 8.8 of 18.7 seconds of speech time had no voice at all — the mouth kept moving on screen over the thin residue the vocal separation leaves behind, which reads as silence and as "the music got quiet". Short lines are now gently slowed toward their time slot (pitch preserved, never below 0.85× — comfortably natural), so speech covers the speaking time the way the original did. This also does most of the work people expect from "lip sync": the voice now starts and ends with the mouth. Near-full lines are left untouched, the per-segment badge shows the applied rate, and OMNIVOICE_UNDERRUN_MIN_RATE=1.0 turns the fill off. (#1137)

  • The dub's background music no longer comes out quiet and muffled. Every dub export mixes your synthesized voice over the video's separated music/ambience bed — and that mix had two fidelity bugs stacked on top of each other. The mixer normalizes its inputs, so the weights meant to gently favor dialogue actually played the music at ~57% of its original level (measured); and because the voice track is synthesized at 24 kHz, the mixer silently pulled the 44.1 kHz music down to 24 kHz — deleting everything above 12 kHz: cymbals, brightness, air. The batch pipeline was harsher still, pinning the bed near 8%. All six mix sites now share one filter that resamples both sides up to 48 kHz, cancels the normalization so the music plays at 90% of its true level (a hair of headroom keeps dialogue legible), and adds a transparent peak limiter. Measured on a real dub: bed level 57% → 90%, bandwidth 12 kHz → 24 kHz. (#1136)

  • A rate-limited translation polish pass no longer sabotages the dub — or lies about it. The Cinematic quality mode runs an optional critique-and-rewrite pass after translating. When that pass hit a rate limit (free-tier LLM endpoints throttle hard), three bad things happened at once: the app reported "N/N segment(s) failed" in red over a translate that had actually succeeded; the affected segments were silently skipped by the speech-rate fit pass and duration planner — so overlong lines went to synthesis unfitted and came out audibly time-compressed; and the two-second "retry shortly" hint the provider sent was ignored. All three are fixed: a rate-limited call now waits out the provider's own Retry-After (bounded, once) and usually just succeeds; a segment that still misses the polish keeps its plain translation, stays in every downstream fitting pass, and is reported honestly — "translated, polish skipped" as a warning with the reason, not a failure. Rows that really failed still say so. (#1135)

  • Dubbing kept re-studying the same speaker's voice, hundreds of times per video. Each dubbed line clones from a clip of its own source audio (that's what makes deliveries match), and lines too short to clone from fall back to a per-speaker sample. But the app's memory for already-studied voices only holds 8 — and a long dub streams hundreds of one-shot per-line clips through it, each pushing out the per-speaker samples that every other line needs. Result: the speaker sample was re-studied (~0.4 s, measured) over and over. One-shot clips are now studied without displacing anything, so the per-speaker samples stay warm for the whole dub. Nothing about the audio changes — same clips, same voices, less repeated work. (#1132)

  • Clicking "Install" on an engine right after opening Settings could silently do nothing. When the Engines page opens, it quietly checks each installable engine for an in-flight install to re-attach to. If you clicked Install while that check was still running, your click's status update was thrown away to keep requests orderly — so no progress panel, no error, no retry, just nothing (the install itself did start in the background; the UI simply never showed it). Fast machines usually won the race, which is why this mostly showed up as a once-in-a-while CI test failure. The Install click's update can no longer be dropped — it politely waits out the startup check instead. (#1131)

  • Cloning re-listened to your reference clip for every chunk of text — now it listens once. Before OmniVoice can speak in a cloned voice it has to encode the reference clip you gave it. That encode was being redone on every single piece of the job: long text is split into chunks, and each chunk re-encoded the same reference from scratch; so did each [pause] span, and each chapter segment of an audiobook. A cache to prevent exactly this was written a while back — and then quietly bypassed on the path the Generate button actually takes, so for several releases it only ever helped the API. It's now wired into every path. Measured on an M2, one encode costs 0.4 seconds, so this gives back roughly 3–4 seconds on a long paragraph and about a minute on a 166-segment audiobook — the same voice, the same audio out, just without listening to your reference clip 166 times. As a bonus, preprocess_prompt on the OpenAI-compatible endpoint now actually does something; it was being accepted and silently discarded. (#1130)

  • Dubbing loaded the 3 GB voice model, threw it away, and loaded it again. Before transcribing, a dub pulled the entire voice model into memory to read a single setting off it — one that is empty unless you've turned on an off-by-default flag. So it loaded ~3 GB, found nothing, released it a moment later (on Apple Silicon that's a full unload), and then had to load the very same model again from cold when it was time to actually speak. Every dub paid for that round trip — roughly 8 seconds, plus the memory churn on exactly the 16 GB machines where memory pressure is the problem. It now only loads the model when there's genuinely something to read. (#1130)

  • The backend stopped holding the voice model hostage while it loads the transcription model — the 16 GB dub crash. Before transcribing a dub, OmniVoice makes room by setting the TTS model aside. On an NVIDIA GPU it did. On Apple Silicon it did nothing at all — the code bailed out with "unified memory doesn't benefit from offloading". That was half right and wholly wrong: on unified memory, moving a model to "CPU" frees nothing (it's the same RAM), but the answer is to release it, not to skip the step. So a 16 GB Mac went into a dub holding the ~3 GB voice model, then loaded a ~3 GB transcription model on top of it — measured here: 4.1 GB free before, and large-v3 needs 3 — and the operating system killed the backend mid-transcription. That's the dub that "dropped before emitting any segments". The voice model is now genuinely released when memory is tight (and left alone when it isn't, so a roomy machine pays nothing); it reloads by itself on your next generation. (#1119)

  • Dubbing on a Mac was transcribing on the CPU — with the GPU sitting idle. OmniVoice picked its transcription engine without ever looking at your hardware: WhisperX won every time, and WhisperX (like faster-whisper) is built on CTranslate2, which has no Metal backend at all. So on Apple Silicon it ran whisper-large-v3 on the processor. Measured on an M2, one 30-second chunk: 90 seconds on the CPU versus 20 on the GPU — slower than realtime, which turned a 16-minute video into a ~48-minute transcribe that looked exactly like a hang. Worse, the slowest chunks blew past the 2-minute per-chunk timeout and were abandoned entirely, so the transcript came back with pieces missing and the app blamed a "VRAM-starved GPU" — on a machine that has no VRAM. Apple Silicon now uses MLX, which runs the same whisper-large-v3 on the GPU, roughly 4x faster. Word timing is unchanged: the wav2vec2 forced alignment that lip-sync depends on (±10-30 ms, versus Whisper's own ±100-300 ms) is layered on top exactly as before. Same model, same alignment, four times the speed. Nothing changes on NVIDIA or Linux, where WhisperX already used the GPU. (#1127)

  • The transcribe screen invented its ETA, and the number was a fiction. It assumed transcription runs at ~20x realtime — true on a fast GPU — and predicted from the video's length alone. For a 16-minute video it promised 56 seconds. Once reality overran the guess it pinned itself at "~0s remaining" with the bar frozen at 95%, and sat there for the next three quarters of an hour. It now reports the real fraction of the audio transcribed and extrapolates the time left from the speed it can actually observe — so it is right on a fast machine and a slow one, and says nothing at all until it has something true to say. (#1127)

  • Analytics you switched on would have stayed half-dead. The backend half of the new opt-in analytics read its destination from an environment variable that nothing on your machine ever set — so in a shipped build it could never send anything, silently, no matter what you chose. Only the frontend half worked. The destination is now baked into the desktop shell at build time and handed to the backend when it starts, so "on" means on. Nothing else changes: it stays off until you opt in, builds from source still have no destination at all, and the property allowlist still decides what may leave. (#1123)

  • A dub that dies mid-transcription still guessed at the cause. v0.3.20 taught it to check the crash report before blaming the ASR model — but it checked instantly, the moment the stream dropped, and the desktop shell needs about two seconds to notice the backend died and write that report. So it kept looking too early, finding nothing, and falling back to the same old guess ("Likely ASR backend failed to load") even when the backend had in fact just crashed. It now waits for the shell to catch up, so you get the real cause — exit code and error output — instead of a guess. (#1119)

Windows x64 artifacts

1c14a49a18efe60e487600b10ec29e15b4cd5ffc65709b2d19324c16cef65cc3 *OmniVoice Studio_0.3.22_x64_en-US.msi
8976ad6c7767bd342882be3ce750dd27bf62db5391befe9cda5a7aaf57870473 *OmniVoice Studio_0.3.22_x64_en-US.msi.sig

Linux x64 artifacts

a375e5d9461c255ca1e02400ae9c2130505ed636f2cfc940c7ffb724718d487c  OmniVoice Studio_0.3.22_amd64.AppImage
4bc45200bf41a0b15ed6497d771e5c1bbf85517ebadb6e3f487d9c297e2719c5  OmniVoice Studio_0.3.22_amd64.AppImage.sig

macOS Intel artifacts

010e88ed715b5a75330e2fb09e2e6a5e7ed71d642fef960c4db64db067399fb9  OmniVoice Studio_0.3.22_x64.dmg
84b804c4c34ffe863b934d85a5045243c23c22b3de019871c620866fb5bd1dca  OmniVoice Studio.app.tar.gz
f9b101a4cdf77d6f9aaabcae6e331d5ba1eb58a1e777f166fd66223318078283  OmniVoice Studio.app.tar.gz.sig

Don't miss a new OmniVoice-Studio release

NewReleases is sending notifications on new releases.