Speakr v0.10.6-alpha
Background work now runs once per installation rather than once per gunicorn worker, large files can be uploaded through a restrictive reverse proxy, and users can opt in to an email when a transcription finishes. Upgrading is routine and no configuration changes are required.
Background work ran once per worker
src/app.py runs its startup code at module scope, so every process that imports the application ran it: the entrypoint's schema check, the admin-user script, and each of the three gunicorn workers the image ships. Each of those started its own copy of the background machinery.
For the job queue this had a visible cost. Orphan recovery resets every job in processing back to queued so that work abandoned by a crash is picked up again, and it had no way to tell such a job from one another process was transcribing at that moment. A worker booting while a sibling was mid-transcription therefore un-claimed live work, another worker legitimately re-claimed it, and the same audio went to the transcription service a second time. Nothing failed and the transcript still arrived, so the only symptom was duplicated work on the ASR side: three workers produced two submissions of one file, eight produced seven. Anyone running a local GPU was paying for it (#384).
This was reported, diagnosed and fixed by @jagd700, whose report included a reproduction, a measurement across worker counts, and a correct account of which part of the queue was and was not at fault.
The same pattern affected everything else started at boot. The webhook dispatcher, the watch-folder monitor, and the retention and recording-session cleanup schedulers each ran per process, guarded only by module-level flags that are per-process and so guarded nothing across them. Duplicate webhook deliveries were partly masked for receivers that follow the documented advice and deduplicate on the delivery id.
One process is now elected to own each of these for its lifetime. The others serve requests and enqueue jobs exactly as before, they simply run no background threads. Leadership ends when the owning process does, including a crash, and the replacement claims it on the next start. The election fails open, so a host where it cannot run behaves as it did before rather than running nothing.
Two practical consequences. Raising gunicorn's --workers for request capacity is now safe. And JOB_QUEUE_WORKERS is unaffected: those are threads inside the process that owns the queue, so transcription concurrency is unchanged.
Uploads larger than the proxy body limit
A disk upload was a single request containing the whole file, so any reverse proxy with a request body limit below the file size rejected it before Speakr saw it. Cloudflare caps bodies at 100 MB on most plans, which is well under a normal two-hour recording, and the only workaround was to route uploads around the proxy.
Files above a threshold are now sent as fixed-size slices through the existing recording-session endpoints and reassembled on the server, which then hands the result to the same ingestion path a single-shot upload uses. The recording that results is identical, and duplicate detection, conversion, storage backends and the size policy all apply unchanged. An interrupted transfer resumes from the slices the server already holds instead of starting again, and a finalize whose response is lost to an edge timeout is picked up rather than reported as a failure. Contributed by @fcatuhe (#381).
Email when a transcription finishes
Each user can turn on "Email me when a transcription finishes" under Account, Preferences. It sends a message as each recording completes, and again if one fails permanently, using the same SMTP configuration as email verification and password resets (#386).
It is off by default, and appears only for accounts Speakr can actually deliver to. Set APP_BASE_URL to your instance's external address so the message can link back to the recording; without it the mail is still sent, just without the link, since a notification is sent from a background worker that has no request to derive a URL from.
Recording system audio
The browser's share dialog decides whether a System Audio or Mic + System recording captures anything, through a checkbox that Chrome leaves unticked on the Entire Screen and Window panes. Miss it and the recording fails silently: the capture starts, no audio track ever arrives, and the only signal is the help modal Speakr opens afterwards.
Speakr now asks the browser to pre-tick that checkbox, which is what it already does for tab shares. You can still untick it, and the existing no-audio-track handling is unchanged. This is a hint the picker is free to ignore, so browsers without support behave exactly as before. Contributed by @fcatuhe (#387).
Notifications
The user menu now carries an unread badge and a panel listing anything that needs attention. The first thing to use it is the voice embedding check below, but the store is general: notices are localized like the rest of the interface, deduplicated so a check that runs at every startup cannot pile up identical rows, and those that describe a condition rather than an event clear themselves when the condition ends.
Detecting a changed voice embedding model
Voice profiles are only comparable with embeddings produced by the model that made them. If the transcription backend starts returning embeddings from a different model, whether because the endpoint was repointed or because the service upgraded itself, existing profiles stop matching. On its own the failure is silent: matching simply returns nothing, with nothing in the logs to explain it.
Speakr now sends a short bundled clip to the backend at startup, which is when an embedding model realistically changes, and compares the embedding against one recorded when voice matching was known to work. A change is written into the startup log as a banner and raised as a notification for every administrator. The original reference is kept, so restoring the previous backend clears both by itself; if the change was intended, rebuild the affected profiles and re-baseline to accept the new model. A backend that is merely unreachable is reported as a failed check rather than a changed model. Set DISABLE_VOICE_EMBEDDING_CHECK=true to turn the check off (#380).
Separately, a stored profile of a different size is no longer treated as a corrupt profile and skipped in silence. It is now reported once per lookup, naming both sizes and what to do about it.
Also fixed
- A floating chat panel remembers its position per recording and restored it without checking that it still fits. Moving to a smaller display could bring the panel back with its header, and so its close button, above the visible area, leaving no way out but clearing browser storage. It is now pulled back into view on restore and on resize (#383).
- The webhook
recording.transcription.completedpayload documentedaudio_duration_secondsandlanguagefields that it never actually sent. Both read properties that do not exist on the recording, so the values were empty and then filtered out of the payload. Both are sent now. The language needed a real fix rather than a corrected attribute name: transcription services already report the language they detected, and Speakr was discarding it after every run, so it is now recorded on the recording and reported as a property of that audio rather than of the user's current preference. It is absent for recordings transcribed before this release and for backends that report no language. - User-supplied text in outgoing email, including recording titles and usernames, is now escaped. Mail clients do not run scripts, so this was not a scripting issue; the risk was content injected into a genuine, correctly addressed Speakr message.
- The email header logo travels with the message instead of being linked, so it renders without the reader having to allow remote images, and on instances that are not reachable from wherever their users read mail.
- The
recording_session.kindindex is created on upgraded databases rather than only on new ones. - A sliced upload could fail after a slice had been accepted but its response lost: the retry drew a 409 that HTTP/2 front ends deliver as a protocol error, so the client re-sent the same slice until its attempt cap. It now checks the session status before retrying, and the server reads the body before answering an out-of-order 409 so the hint is readable. Re-adding the file always recovered such uploads; the in-flight retry now does too.
- Top users by storage in the admin area reported "0 bytes (0 recordings)" for everyone on PostgreSQL. Accounts with no recordings were being selected as though they qualified, and PostgreSQL sorts NULLs first when ordering descending, so those accounts filled the whole list. SQLite sorts them last, which is why this only appeared on PostgreSQL (#393).
- Reprocessing a summary could fail with
Unexpected token '<'. The response was parsed as JSON before checking whether the request had succeeded, so an HTML error page, usually an expired session, surfaced as a parse error instead of something actionable (#388). - The sidebar opened as a full-screen overlay with a grey backdrop on first load below 1024 pixels wide, most visibly on an iPad in portrait, and again when rotating into portrait. It now starts collapsed at those widths, and the stored preference is left alone so desktop behaviour is unchanged. Reported and fixed by @elight (#391, #392).
- A recording waiting to be processed showed the literal text
status.pendinginstead of a status label, in every language. The label had never existed, and a missing translation key renders as the key itself rather than failing, so nothing surfaced it. Speakr now checks at build time that every key the interface refers to resolves in all seven languages.
Also new
Tag-filtered recording lists have shareable URLs: clicking a tag writes /label/<name> into the address bar, and opening that link applies the filter. The name resolves against the tags the viewer can see, so a link shows each person only their own recordings under that name. Selecting a tag is now an exact name match rather than a substring one, so "foo" no longer also returns recordings tagged "foobar". Contributed by @mindspawn (#390).
Upgrading
Pull the new image and start as usual. Database columns migrate automatically and no configuration changes are required. Taking a copy of transcriptions.db before upgrading is sensible practice, as with any release.
Full Changelog: v0.10.5-alpha...v0.10.6-alpha