Skip to content

feat: 28-language interview picker and mid-interview audio switching - #108

Merged
alpha5611331 merged 45 commits into
mainfrom
feat/multilingual-support
Aug 26, 2026
Merged

feat: 28-language interview picker and mid-interview audio switching#108
alpha5611331 merged 45 commits into
mainfrom
feat/multilingual-support

Conversation

@alpha5611331

@alpha5611331 alpha5611331 commented Aug 24, 2026

Copy link
Copy Markdown
Member

Closes #24.

Language had one member and was read by nothing. This makes it a real setting: a picker on the control bar that decides which speech model transcribes the call, what language suggestions are written in, and the language of the exported report - changeable mid-interview, not only before Start.

Since the first review this has grown two things, both driven by the backend moving its ASR to Deepgram Nova-3: the language set went from 6 to 28, and the microphone became switchable mid-interview for the same reason the language is.

Pairs with PowerInterviewAI/backend#58. Neither side needs the other to ship: the backend defaults the field, so this client works against a deployment that predates it (suggestions stay English, and so does the transcription). A code this build knows but an older backend does not is resolved back to English there rather than faked, so a client ahead of its backend degrades one session instead of breaking it.

What changed

src/main/types/language.ts, src/renderer/types/language.ts the enum, mirrored the way SuggestionMode is, plus display metadata
src/main/store/config.store.ts language is typed and resolved on read
src/main/services/suggestion-{live,action}.service.ts, tools.service.ts language on all three request bodies
src/renderer/services/live-transcription.service.ts ?language= on the ASR sockets, setLanguage() to switch them, and setAudioInputDevice() / setStream() to swap the microphone
src/renderer/hooks/use-interview-language.ts the setter that reconciles the two halves
src/renderer/hooks/use-audio-input-device.ts the same shape for the microphone
src/renderer/components/custom/control-panel/language-group.tsx the language control
src/renderer/components/custom/control-panel/audio-group.tsx the microphone control, now live mid-interview

The setting has two halves that move at different speeds

Suggestions are free. Each service reads the config store as it builds its request, so the next suggestion already follows a change - nothing to reconnect, nothing to wait for.

The ASR is not. The provider takes the language as a connection parameter, so switching means tearing both channels down and re-opening them: a second or two of gap, and whatever utterance was mid-flight is orphaned. useInterviewLanguage is where those two are reconciled, and the UI reports the difference rather than papering over it - the trigger spins while the sockets come back, and the menu says what will happen before the user commits, because a two-second hole in the transcript is alarming if it arrives unannounced mid-question.

This is a property of the provider, not a choice. Deepgram has no mid-stream language change at all, so the reconnect machinery below is now load-bearing rather than incidental.

Two guards on the reconnect

Both protect against the same failure: two sockets on one channel, one of them orphaned and still relaying audio into a dead session. The existing onclose path schedules a backoff reconnect on any unexpected close, and a deliberate close for a language switch looks exactly like one.

  • onclose now ignores a close from a socket that is no longer this.ws. That is the tail of a replacement, not a disconnect. (This one is a latent fix on the pre-existing reconnect path too.)
  • setLanguage sets a switching flag so the backoff reconnect does not fire for the close it caused itself, and reconnects immediately instead of waiting out WS_RETRY_BASE_DELAY_MS.

connectWebSocket rebuilds the URL per attempt rather than capturing it, which is what lets a reconnect pick up the new language at all.

The first guard has two consequences worth calling out, both caught on review:

  • setLanguage reports channelDisconnected itself rather than leaving it to onclose. new WebSocket assigns this.ws synchronously, so the old socket's close always arrives after its replacement exists and is correctly ignored - which would have swallowed the report too, leaving the orphaned partial gating live suggestions for the rest of the session.
  • Its no-op check keys on this.ws, not on active, which start() sets only after its first connect returns. In that window a socket already exists on the old language and an active check would skip it.

A switch that cannot reconnect hands the channel back to the ordinary backoff loop rather than leaving it silent until the assistant is restarted - five failed attempts is a network or provider problem, not a permanent one - and the toast says it is still retrying. The two channels are switched with allSettled, since Promise.all leaves the second one's rejection unhandled.

The microphone is now switchable too, and it is cheaper

The audio control used to lock while the assistant ran, which made the one case it exists for unreachable. A headset that dies, is unplugged, or was the wrong device to begin with is noticed exactly when the interviewer says they cannot hear you - and the only fix was stopping the assistant, which clears the transcript and the suggestion history with it.

Nothing reconnects. The device is only what feeds the worklet; it is not a connection parameter. AudioWsStream.setStream() replaces the MediaStreamAudioSourceNode while the socket, the provider session and any utterance in flight all survive, so there is no gap and the dialog promises the opposite of what the language menu warns about.

Three things it has to hold:

  • The replacement stream is acquired before anything is torn down, and the previous one stopped only after the swap succeeds. Releasing first reads as the obvious cleanup order and works every time the new device is present; on the one path that matters - unplugged, in use, permissions refused - it leaves the session with no microphone at all, mid-answer, having been asked only to change one.
  • A stream that finishes opening after the session stopped is released, not left holding the device with its indicator light on, since nothing else keeps a reference to it.
  • The existing AudioContext is reused. Its sampleRate is fixed at construction and convertTo16kPcm reads it, so a fresh context would resample every frame against the wrong rate - quietly, and only for users whose second device runs at a different rate than their first.
  • The bail-out tests the context alone, never also the worklet node. start() creates source from this.stream and only assigns workletNode after await addModule(), so there is a real window where a context and a source exist and the node does not. Returning early there leaves source bound to the stream the caller is about to stop, and start() then wires that dead source into the graph - socket up, channel relaying silence for the rest of the session, nothing reporting it. Caught on review after the first implementation had exactly that guard.

Only ch_1 moves; ch_0 is loopback audio from the call and has no device to change.

The bug this widening exposed, and the one to read first

classifyInterviewerTurn returned Skip for every interviewer question in a non-Latin script - Japanese, Chinese, Thai, Russian, Korean, Arabic, Hindi, Hebrew. Skip drops the turn outright: no request, no card, no error. Roughly a third of the languages this PR adds produced no suggestions at all.

normalize() reduces a turn to ASCII, which is right for matching an English backchannel lexicon and wrong as a test for whether anything was said - those scripts reduce to nothing. Empty then hit the branch meant for [laugh] and (inaudible) and was read as non-speech.

The bug is as old as the classifier. Going from six Latin-script languages to 28 is what made it reachable, and it is exactly the failure the classifier's documented asymmetry exists to prevent: a question misread as filler produces nothing at all, mid-interview. It could not be seen from the English side, where that branch is correct.

The two cases are now told apart by whether any letter in any script survived the non-speech markers. If one did, the verdict is Uncertain, which defers to the backend gate - the one stage that can actually read the language, and which fails open. Skip stays for turns that really were non-speech. Non-ASCII question marks (, ؟) fold to ? so a finished question in those scripts answers immediately rather than waiting out the settle timer; Greek's ; is deliberately left alone, since it is an ordinary semicolon everywhere else and reading it as terminal would answer English fragments.

Every English case is unchanged. test/interviewer-turn.test.mjs pins nine scripts that must not be skipped, the markers that must still be skipped, and the question marks that should answer outright.

Decisions worth arguing with

English sends no parameter at all, not language=en. The backend treats an absent language as English, so a session that never touches the picker produces byte-identical traffic to what shipped before this existed.

The setting is persisted before the switch and never rolled back on failure, for both controls. A failed reconnect that reverted the setting would leave the user with no route to the language they picked; leaving it set means stopping and starting the assistant recovers, and the toast says so. Same for the microphone, where a failed swap leaves the audio running on the previous device.

Resolution happens on the way out of the config store, not on the way in. The disk holds whatever some build wrote - a code a later release dropped, or one an older release never knew - and every consumer reads through getConfig(), so that is the single point where an unknown code can be stopped before it reaches the ASR URL and three request bodies.

28 languages, which is exactly what the backend's Deepgram Nova-3 ASR streams. It was six under the previous ASR. The rule did not change - offering one the ASR cannot hear would not degrade gracefully, it would answer a question that was never asked - only the ceiling did.

Endonym first in the menu, English name second. Someone whose interview has just switched language finds "Deutsch" faster than "German"; the English column is there for the reverse lookup. The trigger shows the code (EN, ES) next to the icon - a globe alone is only useful to someone who already knows what it is set to, which is the one question this control has to answer at a glance.

Two things the widening broke that six never exercised, both fixed here: the menu opens upward from the bottom-most control into an overflow-hidden main, so an uncapped 28-item list ran off the top of the window rather than flipping (it is now capped and scrolls); and the Arabic and Hebrew endonyms rendered into the menu's LTR flow, putting punctuation on the wrong side of the word (dir="auto").

Explicitly not in scope

Localising the app's own chrome - buttons, labels, dialogs, toasts. Separate feature, separate cost (~45 components plus every main-process error string), and not what makes the product unusable in Spanish today: an English button on a Spanish interview is an inconvenience, an English transcript of Spanish speech is a wrong answer read out loud.

Testing

pnpm lint, both tsc configs, pnpm build and pnpm test:main all clean.

test/language.test.mjs now also pins the two mirrors staying in step, which is the failure the widening made likely and which is silent in both directions: an enum member with no picker entry renders a blank trigger, and a picker entry with no enum member resolves straight back to English when picked.

test/audio-device-switch.test.mjs pins the acquire-before-release ordering and the reused AudioContext. These are source-level checks, unusually for this directory - every other test loads a built main-process module, and this is renderer code with no runtime harness. They are worth the awkwardness because the ordering is exactly what a later tidy-up breaks, with no symptom a type checker or linter can see.

Not covered: the reconnect and the swap themselves. AudioWsStream needs a real WebSocket, AudioContext and getDisplayMedia, none of which the dependency-free main-process harness reaches. Both are worth exercising by hand:

  • Change language during a running session; confirm the transcript resumes on the new language, exactly one socket per channel survives, and the card that was in flight is not left pending.
  • Change microphone during a running session; confirm the transcript does not gap, and that picking a device that is unplugged mid-dialog leaves the interview running on the previous one.

alpha5611331 and others added 6 commits August 24, 2026 14:29
It had one member and was read by nothing. Six now, mirrored across the two
processes the way SuggestionMode is, plus the display metadata the picker
needs: endonym first, since someone whose interview is in the wrong language
recognises "Deutsch" before "German".

Six because that is what AssemblyAI's universal-streaming-multilingual model
transcribes. Offering one the ASR cannot hear would not degrade gracefully,
it would answer a question that was never asked.

configStore.getConfig resolves the value on the way out rather than on the
way in. The disk holds whatever some build wrote, and every consumer reads
through getConfig, so that is the one place an unknown code can be stopped
before it reaches the ASR URL and three request bodies.

Refs #24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Live, action and summarize, off one field on the shared LLMRequest base so
the three cannot drift. Optional on the wire because the backend defaults
it, which keeps the client working against a deployment that predates it.

Each service already reads the config store as it builds its request, so
this is also what makes the setting changeable mid-interview for free: the
next suggestion follows without anything being reconnected.

The exported report goes with them. A Spanish interview summarised in
English is a document the candidate cannot hand to anyone who was in it.

Refs #24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…and switch them mid-session

?language= on the streaming URL, and English sends no parameter at all
rather than language=en - the backend treats an absent language as English
and builds the URL it has always built, so a session that never touches the
picker produces exactly the traffic it produced before.

The language is a connection parameter, so changing it mid-interview means
tearing both sockets down and re-opening them. The URL is therefore rebuilt
per attempt rather than captured, and two guards keep that from leaving two
sockets on one channel, one of them orphaned and still relaying audio into a
dead session:

- onclose ignores a close from a socket that is no longer this.ws. That is
  the tail of a replacement, not a disconnect.
- setLanguage sets a switching flag so the ordinary backoff reconnect does
  not fire for the close it caused itself; it reconnects immediately instead
  of waiting out WS_RETRY_BASE_DELAY_MS.

The in-flight utterance is still reported as disconnected, because a switch
orphans it exactly the way a dropped connection does.

Refs #24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sits with Audio and Model, because it is an input as much as an output: it
picks the speech model before it picks the answer's language.

Unlike those two it stays live while the assistant runs. An interview that
switches language is the case this control exists for and not one the
candidate can prepare for by restarting, so it locks only through the
transient Starting and Stopping states.

The switch is not instant and the button says so. Suggestions follow at
once; the ASR reconnects, so the trigger spins and the menu warns that the
sentence being spoken may be cut short - a two-second hole in the transcript
is alarming if it arrives unannounced mid-question.

The setting is persisted before the reconnect and never rolled back on
failure: reverting it would leave the user with no route to the language
they picked, while leaving it set means stop-and-start recovers.

The trigger carries the code next to the icon. A globe alone is only useful
to someone who already knows what it is set to, which is the one question
this control has to answer at a glance.

Refs #24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The failure this guards is silent: an English speech model does not report a
language it cannot handle, it returns confident English words for speech
that was never English. So what matters is that a chosen language survives
the round trip, and that one this build does not know dies at getConfig
rather than on the ASR URL.

Refs #24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gitar-bot

gitar-bot Bot commented Aug 24, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

alpha5611331 and others added 19 commits August 24, 2026 14:41
…over a failed one

Three defects in the switch path, all of them silent.

channelDisconnected was left to onclose, which the new "ignore a close from
a socket that is no longer this.ws" guard correctly swallows: `new WebSocket`
assigns this.ws synchronously, so the old socket's close always arrives after
its replacement exists. The partial in flight would have stayed open and
gated live suggestions for the rest of the session. setLanguage reports it
directly now.

A failed switch left the channel silent until the assistant was stopped and
started. Five failed attempts is a network or provider problem, not a
permanent one, so it hands the channel back to the ordinary backoff loop and
the toast says it is still retrying rather than telling the user to restart.

The no-op check keyed on `active`, which start() sets only after its first
connect returns. In that window a socket exists on the old language and
would never have been reconnected. Keyed on the socket instead.

Also: allSettled across the two channels, since Promise.all leaves the
second channel's rejection unhandled, and a swallow of the stop-mid-switch
case, which is a shutdown rather than a failure.

Refs #24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The default-device pick ran in the render body and called updateConfig, which
writes to the Zustand store synchronously before its IPC call resolves. That
re-enters React mid-commit, and the failure mode is worse than the warning:
updateConfig rolls the optimistic value back when the write fails, so the
condition that triggered the pick is true again on the very next render. A
store that cannot be written produced one attempt per frame, each one an
unhandled rejection, for as long as the panel stayed mounted.

The rejection is handled now, and a latch keeps the pick to once per mount, so
clearing the selection or unplugging the chosen device mid-session does not
silently reassign it underneath the user.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An empty device list means two different things - enumerateDevices() has not
answered yet, and this machine has none - and the control panel treated both
as the second. useMediaDevices starts empty, so every launch put a destructive
badge on the mic button for the first frames, and a Start pressed quickly was
refused with a message naming a device that was there all along.

An unset audioInputDeviceName was a third state read the same way, so a fresh
install was told its microphone `""` could not be found while AudioGroup was
in the middle of choosing one.

useMediaDevices now reports `ready` alongside the list, and the two conditions
are separated: a machine with no audio input at all gets a message that says
so, rather than one naming a device the user never chose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every control on the bar is disabled while runningState is Stopping, Stop
included. stopAssistant set Idle only on its success path, so any throw on the
way out froze the app mid-teardown with no reachable control and no way back
short of restarting it - during an interview, which is the only time it runs.
Idle is now set in a finally.

The teardown itself used Promise.all over four services, which rejects on the
first failure and abandons the other three: one service that refused to close
left the rest running. allSettled closes all four whatever happens to any of
them. A partial failure is now a toast rather than a throw, because the
session is over either way and the store's `error` field is rendered nowhere.

doStart no longer calls stopAssistant after a failed start. startAssistant's
own catch has already torn both services down and returned to Idle, so the
second call only walked the button through three seconds of "Stopping" for a
session that never started - and it sat in the catch block, so its own failure
became an unhandled rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The recompute effect listed the whole appState object in its dependencies
alongside the runningState field it actually cares about. appState is a fresh
object on every push from main, which during an interview is every ASR partial
and every streamed suggestion token - several times a second. Each run does
three getBoundingClientRect() calls, so this forced a synchronous layout on the
renderer for every token that arrived, while nothing being measured had changed
size at all. Every other input to the layout has an effect of its own.

Two timers are cleared on unmount while here: the deferred first measurement,
which could otherwise measure a tree being torn down by a navigation, and the
login redirect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both suggestion panels carried their own copy, and neither honoured its own
bound: three characters were reserved for the separator and thirteen were
spliced in, so every truncated question came back ten characters over the
limit the caller asked for. Harmless on one line, wrong for anything that
reuses the helper to size something.

Now one helper in lib/suggestions.ts, counted against the budget, splitting
what is left so the tail survives - a question's actual ask is usually at the
end of it, which is the whole reason this truncates the middle rather than the
end. Degenerate budgets return a string that still fits rather than a longer
one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eleven inputs across login, signup and the reset wizard had a label with no
htmlFor, so clicking the label did nothing and a screen reader announced the
field with no name at all. Remember-me was the one control already wired
correctly, which is what made the omission easy to miss. Fields now carry
autoComplete as well, so a password manager can fill them.

The password reveal button was an icon with no accessible name - announced as
"button", on a field whose value is deliberately unreadable - and said nothing
about which of its two states it was in. It also sat in the tab order between
the password field and submit, where it is a stop nobody wants.

Form errors used a hardcoded text-red-600 in nine places, inconsistent with
the text-destructive token used everywhere else and low-contrast against the
dark theme's card. None carried role=alert, so submitting a login and being
told "Incorrect email or password" announced nothing: the message is the only
thing that changes on screen.

Radix Select triggers are buttons, so htmlFor does not reach them; the LLM
provider, model and microphone pickers are associated by aria-labelledby. The
microphone picker's config write was also unhandled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Profile and Context are capped at 128,000 characters by maxLength, which
truncates a paste silently. A CV or a job description pasted over the limit
arrives shorter than the one the user copied and nothing on screen says so -
and these two fields are the whole basis of every suggestion the app writes.

A counter appears at 90% of the budget and turns destructive at the cap, where
it says what happened rather than counting. Hidden below the threshold because
a counter over an empty box is noise and most sessions never approach it.

The three fields are also label-associated, and the values are trimmed on the
way out: the Save button is gated on the trimmed name being non-empty, so a
name of pure whitespace could never be saved, but a name with a trailing space
could - and it is the string the prompts address the candidate by.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things that read as tidy code until they are not: that runningState has
to reach a terminal value on every path, because Starting and Stopping disable
the control that would recover it; that the four teardown calls are settled
rather than raced, because one refusing to close must not leave the other
three running; and that an empty device list is two different states, only one
of which is a missing microphone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The export button is live whenever the assistant is idle, which includes every
launch before the first session. Pressed then, it sent an empty transcript to
the summarize endpoint - a billed model call whose only possible output is
invented - and wrote the result into a document the candidate is told is a
record of their interview.

Guarded in the service, which is what stops the request, and again in the
panel, which is what produces the sentence the user reads: an error raised out
of an ipcMain handler reaches the renderer wrapped in Electron's "Error
invoking remote method" prefix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The transcript toggle, Clear, Export, the three buttons on the export toast,
the microphone button and the payment page's back arrow were all icon-only
with no accessible name. A Radix tooltip supplies a description, not a name,
so each was announced as "button" with a description appended.

The microphone button carries one more thing. Its warning state is a badge
drawn over the corner - colour and position and nothing else - so it is folded
into the button's name, and the badge itself is hidden from the tree rather
than announced as a stray "!". That warning is the only condition this control
reports, and it was reachable only by looking at it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backend loop retried every second for as long as the app stayed open, with
no ceiling. A laptop left overnight on a dropped connection made tens of
thousands of failing requests, and at the end of a real outage every installed
client came back at that same rate at the same moment.

Both loops now back off geometrically to a 30 second ceiling and reset the
moment a check succeeds, so a single blip does not leave the app checking
slowly for the rest of the session and recovery is still noticed inside the
window the reconnect notice is waiting on.

The signed-out branch of the client loop keeps its one-second cadence
deliberately: it makes no request, so it costs a timer wake-up rather than
traffic, and it is what decides how soon after a sign-in the credits and role
reach the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing stopped the renderer navigating away or opening a window, and the
panels render Markdown that came from a language model - remark-gfm autolinks
bare URLs, so an anchor here is not necessarily one a person wrote.

Two routes followed from that, both silent. A target="_blank" anchor asks
Electron for a new window, and with no setWindowOpenHandler installed the
default is to make one: a chromeless BrowserWindow, no address bar, showing a
page the user did not choose. And an anchor with no target navigates the frame
it is in, which is the app - preload runs on whatever document loads next, so
a remote page would inherit window.electronAPI and with it the session token
through config.get() and the candidate's CV through account.get().

Every new window is now denied and a web URL handed to the real browser
instead, which is what the user expected from a link anyway; will-navigate
pins the window to the app's own document.

external:open shares the same openExternally(), which allows http, https and
mailto only. It called shell.openExternal on any string, and openExternal
delegates to the OS protocol handler - file: launches whatever the path points
at, and a registered custom scheme runs whatever claimed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
createWindow() runs again when the single-instance lock recovers a destroyed
window, and web-contents-created is an app-level event, so the second call
stacked a duplicate will-navigate listener onto every web contents from then
on. Harmless in effect - both listeners call preventDefault - but the comment
claimed the window is created only once per process, and it is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mirrors the backend, where the ceiling moved from AssemblyAI's
universal-streaming-multilingual to what Deepgram Nova-3 streams. Adds
Russian, Polish, Korean, Thai, Ukrainian, Czech and the rest that no
AssemblyAI streaming model reaches at all.

A backend still configured for AssemblyAI resolves a language it cannot
hear back to English rather than faking it, so a client ahead of its
backend degrades one session instead of breaking it.

Two things the widening broke that the six never exercised:

The picker opens upward from the bottom-most control in an
overflow-hidden main, so an uncapped 28-item menu does not just grow -
it runs off the top of the window and the first entries become
unreachable. The list is now capped and scrolls inside the menu, with
the label and the running-state notice staying put outside it.

Arabic and Hebrew endonyms rendered into the menu's LTR flow, which
puts punctuation on the wrong side of the word. dir="auto" on the
endonym column lets each entry pick its own direction.

The set check in test/language.test.mjs was a literal six and is now
the rule it stood for, plus the mirror sync it never covered: main and
renderer drifting is silent in both directions - an enum member with no
picker entry renders a blank trigger, and a picker entry with no enum
member resolves straight back to English when picked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The control locked while the assistant ran, which made the one case it
exists for unreachable. A headset that dies, is unplugged, or was the
wrong device to begin with is noticed exactly when the interviewer says
they cannot hear you - and the only fix was stopping the assistant,
which drops the transcript and the suggestion history with it.

Cheaper than the language switch, and for a reason worth keeping: the
device is only what feeds the worklet, not a connection parameter. The
socket, the provider session and any utterance in flight all survive,
so the AudioContext and the worklet stay up and only the
MediaStreamAudioSourceNode is replaced. There is no gap in the
transcript, which is why this carries no warning about one.

The ordering is the part that matters. The replacement stream is
acquired before anything is torn down, and the previous one is stopped
only once the swap has succeeded, so a device that is unplugged, held
by another app, or refused by permissions leaves the interview running
on the microphone it already had. Releasing first reads as the obvious
cleanup order and works every time the new device is present; on the
one path that matters it leaves the session with no microphone at all,
mid-answer, having been asked only to change one.

A stream that finishes opening after the session stopped is released
rather than left holding the device with its indicator light on.

The setting is persisted before the swap and never rolled back, the
same as the language picker: a failed swap leaves the audio running, so
reverting would only remove the user's route to the device they picked.

Only ch_1 moves. ch_0 is loopback audio from the call and has no device
to change.

Tests are source-level, unusually: this is renderer code and every
other test here loads a built main-process module. They pin the
ordering and the reused AudioContext, both of which a later tidy-up
would break with no symptom a type checker or linter can see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Six languages, because that is what universal-streaming-multilingual
transcribes" and "unlike Audio and Model" were both true and are not
any more. The new section covers why the device swap is cheaper than
the language switch - the device is not a connection parameter - and
the acquire-before-release ordering that a later tidy-up would break
silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The supported-language line was a sentence joining every endonym, which
worked at six and does not at 28: the only question a reader opens that
section with is whether their own language is there, and a run-on
paragraph cannot be scanned for it. It is now a count and a
middot-separated list, still derived from LANGUAGES so it cannot drift,
and dir="auto" so the Arabic and Hebrew entries do not render backwards
into the surrounding text.

Adds a microphone section. The feature is only useful if the person
whose headset just died knows the control is still live - otherwise
they do the thing the feature exists to avoid and stop the assistant,
losing the transcript and the suggestions.

Also drops two comments naming AssemblyAI models as the thing that
decides behaviour, which is now the provider's business rather than the
client's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chmm195 chmm195 changed the title feat: interview language picker, switchable mid-interview feat: 28-language interview picker and mid-interview audio switching Aug 25, 2026
alpha5611331 and others added 2 commits August 25, 2026 20:06
setStream bailed out when either the AudioContext or the worklet node
was missing, and those two do not appear at the same time. start()
creates `source` from `this.stream`, then awaits addModule() before
assigning `workletNode`, so there is a real window - a blob compile
long - where a context and a source exist and the node does not.

A swap landing in that window took the early return, recorded the new
stream and left `source` bound to the old one. The caller then stopped
the old device's tracks, and start() went on to wire that dead source
into the graph. The socket stays up, the channel relays silence for the
rest of the session, and nothing anywhere reports it.

The bail-out now tests the context alone. The worklet connect is
guarded instead, because start() has its own source.connect(workletNode)
and reads `this.source` - which is the replacement by then.

The UI made this hard to hit: the control is disabled through
RunningState.Starting. That is a guard in a different layer from the
bug, though, and this file already keys its other checks on the field
that actually moves rather than on `active`.

The source-level checks needed a comment stripper to say this. The
comments here explain the very patterns the checks forbid, so a
substring search was finding the prose and failing the fixed code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backend rejoins the segments of one Deepgram utterance with no
separator for Japanese, Chinese and Thai, because a space is a word
boundary in English and a visible defect mid-sentence there. This is
the other half of the same merge: transcripts that arrived as separate
finals and land within TRANSCRIPT_INTER_TRANSCRIPT_GAP_MS of each other
are concatenated here, and that concatenation was hard-coded to a
space - putting back at every merge exactly what the backend avoided.

It does not stop at the panel. `cleaned` is the text the suggestion
request carries, so the model was being asked to answer a question with
breaks nobody spoke.

Latent until this release, since none of the three languages was
offered while the backend was AssemblyAI-only.

The separator lives in its own util so `test/language.test.mjs` can pin
it, including that every language it names is one the picker actually
offers - a rule that would otherwise rot silently if the set moved
again.

The classifier's own join is left alone: its lexicon is English
backchannel, so it never matches CJK either way, and its input is not
what anybody reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha5611331 and others added 5 commits August 25, 2026 20:14
…mment

The hook reports an unset microphone as '' where the component
previously read a possibly-undefined config field. Radix reserves the
empty string for clearing a selection, so passing it straight through
hands Select a value it treats specially rather than the "nothing
chosen yet" meant here. `|| undefined` restores the original binding.

The comment above liveTranscriptionService.start() said the language
was the one thing that could change after the session opened. The
microphone can too now, by a different mechanism - the sockets
reconnect for a language change, the stream is swapped in place for a
device change - and a reader would otherwise take the device as fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backend no longer has an AssemblyAI path, so "it was six while the
backend was AssemblyAI-only" and "a backend still configured for
AssemblyAI" describe a configuration that cannot exist. The property
they were protecting survives and is restated without the provider
name: a code this build knows but an older backend does not is resolved
back to English there rather than faked.

Also records why setStream's bail-out tests the AudioContext alone.
That one reads as an incomplete guard, and adding the worklet node back
to it reintroduces a swap that leaves the channel relaying silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two device changes in flight resolve in completion order, not request
order. The slower `getUserMedia` landed last, so a user who picked A
then B could end up on A while the config store - and the dialog -
named B, with no way to tell which one the audio was actually coming
from short of speaking into one.

A generation token taken before the awaits fixes it: a swap that has
been superseded releases its stream and returns, the same path already
used for a swap that outlived the session.

The picker is disabled while a swap is in flight, so this is hard to
reach from the UI. That is a guard in a different layer from the bug,
though, and this file already keys its other checks on the field that
actually moves rather than on what the UI allows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both live controls persist the setting before applying it and never
roll back on failure, which is right - reverting would leave the user
with no route to the thing they picked. The cost is a state where the
control shows one thing and the session is doing another, and until now
that was announced by a toast and then nothing.

Those are the two states where the interviewer has just said they
cannot hear you, or the transcript is arriving in the wrong language.
A toast is gone in seconds and the control keeps showing the value that
did not take.

Microphone: a failed swap now raises the same badge on the control bar
that a missing device raises, and the dialog names the device that
failed. Nobody opens a dialog spontaneously mid-interview, so the
button has to carry it.

Language: a failed reconnect tints the trigger icon, changes the
tooltip to "Suggestions only", and replaces the menu's reconnect notice
with what actually happened - the checked item names a language only
half the session is in.

Both clear on a later attempt that succeeds, and on leaving Running:
the next start reads the stored value fresh, so the disagreement dies
with the session that produced it and a warning outliving it would be
its own lie.

Two smaller things found while doing it:

The audio dialog's running-state line was two sibling `&&` blocks, and
retrying after a failure satisfied neither - so the line vanished at
exactly the moment the user was waiting to hear whether it had worked.
It is one conditional chain now, and every running state says something.

Language menu items carry an explicit `textValue` of the English name.
Radix runs typeahead on an open menu against a prefix of that, and left
unset it uses the rendered text - the two names run together
("PolskiPolish") - so only the endonym was reachable by typing. At six
entries that hardly mattered; at 28 the endonym is what the eye scans
and the English name is what a user types.

Not covered by tests: this is renderer code and the harness here only
loads built main-process modules. tsc, eslint, build and test:main are
clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha5611331 and others added 13 commits August 26, 2026 13:06
…eaders

The button carries an aria-label, so the glyph beside it is decoration
and an unlabelled one is noise. AudioGroup already did this on both its
icons; this half of the control bar did not, which came up while
reviewing the icons I had just touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
classifyInterviewerTurn returned Skip for Japanese, Chinese, Thai,
Russian, Korean, Arabic, Hindi and Hebrew questions - dropping the turn
outright, with no request, no card and no error. Roughly a third of the
languages the picker offers produced no suggestions at all.

normalize() reduces a turn to ASCII, which is right for matching an
English backchannel lexicon and wrong as a test for whether anything was
said: those scripts reduce to nothing. Empty then hit the branch meant
for "[laugh]" and "(inaudible)" and was treated as non-speech.

The bug is as old as the classifier; widening the language set from six
Latin-script languages to 28 is what made it reachable. It is the exact
failure the classifier's documented asymmetry exists to prevent - a
question misread as filler produces nothing at all, mid-interview - and
it could not be seen from the English side, where the branch is correct.

The two cases are now told apart by whether any letter in any script
survived the non-speech markers. If one did, the verdict is Uncertain,
which defers to the backend gate: the one stage that can actually read
the language. Skip stays for turns that really were non-speech.

Non-ASCII question marks are folded to `?` in normalize, so a finished
question in those scripts answers immediately rather than waiting out
the settle timer. Greek's `;` is deliberately left alone - it is an
ordinary semicolon everywhere else, and reading it as terminal would
answer English fragments.

Every English case is unchanged, and the tests now pin both halves:
nine scripts that must not be skipped, the markers that must still be
skipped, and the question marks that should answer outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The language rides on the ASR socket, so changing it is a reconnect, and
connectWebSocket assigns `this.ws` synchronously per attempt. Two switches
in flight therefore fight over the field: the older loop wakes from its
backoff after the newer one has already opened its socket and overwrites
it. The newer socket is then unreferenced - stop() never closes it, and the
Deepgram session behind it stays open for the life of the app, billing and
transcribing a language nobody selected.

The picker looks like it prevents this, and does not: it disables its
trigger on `switching`, but the hook only sets that after awaiting the
config write, so a second pick lands in the gap.

Give the channel the generation token the microphone path already has.
connectWithRetry captures it at entry, so a superseded loop stops before
building another socket; connectWebSocket re-checks on open, closing a
socket that won its race too late to be the current one; and the reconnect
timer carries its own, since setLanguage can only cancel a timer that has
not fired yet. A superseded switch also leaves the backoff and the toast to
whichever switch replaced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`cleaned` is rebuilt from every stored transcript on each ingest, and the
separator was read once per rebuild from the current config. So the setting
did not apply to new text - it applied to the whole session, retroactively.
Switch an English interview to Japanese an hour in and every block merged so
far loses its spaces at once ("Tell meabout yourKafka work."), on screen and
in the transcript the next suggestion request carries. The reverse switch
does the mirror image, inserting spaces into Japanese that never had them.

Whether two blocks take a space between them is a property of the words, not
of what the picker says now, so `Transcript` carries the language it was
transcribed in and the merge reads it per block. Partials are revised in
place, so their stamp is refreshed with their text.

Extract the merge as `mergeAdjacentTranscripts`, for the reason
`selectTrailingOtherTurn` is already exported: it is testable on a plain
array, where driving it through ingest() means real wall-clock gaps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Arabic and Hebrew are two of the 28 interview languages, and nothing that
displays a session set a direction: the picker's endonyms did, the
transcript, the question line and the answers did not.

The panels are laid out left-to-right, so an RTL run inside them still reads
right-to-left and the defect does not show up in a screenshot unless you
read the language. What breaks is the neutrals. Sentence-final punctuation
takes the paragraph's direction rather than the run's, so the question mark
lands at the wrong end of the line, and a technical answer reorders around
every switch of script - which is every answer, since the prompts
deliberately keep product names and code in Latin.

`dir="auto"` on each block resolves from its own first strong character.
Per block, not once on a wrapper: `auto` reads the first strong character it
contains, so a single wrapper would let an answer opening on "React" set the
direction for every paragraph beneath it.

Block code is the exception and is pinned to `ltr`. Code is left-to-right in
every language - the backend prompts say so - and one RTL comment or string
literal in a fence flips the block and reorders the brackets around it.

No-op for every language that shipped before the picker: `auto` on Latin
text resolves to the `ltr` these blocks already inherited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The summarize prompt goes further than the live and action ones and asks
for the section headings to be translated too, on the stated grounds that a
report is a document a person reads rather than a format anything parses,
so a half-translated one just looks unfinished.

That is what the export produced. The summary came back in Spanish and the
client wrapped it in `# **Transcripts**`, `# **Suggestions**` and a
`| Interviewer` on every turn - the exact document the prompt is written to
avoid, and the one artifact here that leaves the machine.

Five nouns per language in `export-labels.ts`, and no general localisation
layer: the app's chrome stays English on purpose, because an English button
costs one person one session while the report is handed to someone who was
not there and may not read English at all.

The candidate is still named rather than labelled, timestamps still follow
the machine's locale, and an unknown code falls back to English instead of
throwing - the store already resolves it, and an English heading beats an
export that fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also correct a comment that outlived its provider: the completeness check
in `normalize` credited `format_turns` for the punctuation it keys on,
which is an AssemblyAI parameter the ASR migration left behind. The
behaviour still holds - the Deepgram session sends `punctuate` and
`smart_format` - so the comment names what actually produces it now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eplaced it

The service now abandons a switch that a later one has taken over, which
resolves it in the hook as a *success* - and the hook then reports on it.
Pick Spanish, pick French, and the Spanish call can return up to a backoff
delay after the French one has already failed: it clears the "transcription
did not switch" warning the user needs, and drops the spinner while French
is still reconnecting.

A generation ref, the same shape as the two in the service. It is taken
before the config write rather than after, because that await is the gap
the second pick lands in - `switching` is what disables the trigger, and it
is not set until the write returns.

`useAudioInputDevice` is the same hook with a different verb and had the
same hole, so it takes the same guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The supported-language list is joined into one string, and Arabic and
Hebrew sit next to each other at the end of it. That leaves two
right-to-left runs with a single neutral separator between them, which the
bidi algorithm resolves right-to-left as well and then lays out as one run
- printing those two names in the opposite order to every other pair in the
list, in the one place a reader goes to find their own language.

A `dir="auto"` span around the whole list cannot fix this: it sets the
paragraph direction, and the reordering happens inside it. Each name is a
`<bdi>` instead, which is what the element is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mirror check counted rows and measured the short label's width, which
catches a missing language and a three-character label. It does not catch
the failure a 28-row hand-written table actually produces: a row copied from
the one above it and only half edited.

A short label left behind that way is still two characters and still unique
to the file, and it is what the control bar shows - so the trigger names a
language the session is not running in, at a glance, which is the single
question that control exists to answer.

Parse whole rows instead of columns, and pin the rule the code states rather
than its consequence: `short` is the uppercased code of *that* row, no two
rows share one, every row carries both names, and the order is the enum's
(alphabetical differs per naming column, so sorting by one leaves the other
reading as scrambled).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The non-Latin fix covered turns that normalize to *nothing*. A turn is not
required to be in one script, and the same failure survives one branch
further in.

"OK、では次の質問です。" does not normalize to nothing. It normalizes to
exactly "ok": `normalize` blanks the Japanese and leaves the loanword the
interviewer opened on, which Deepgram transcribes in Latin script. The
backchannel lexicon eats "ok", the core comes back empty, and the question
is dropped by the `core.length === 0` branch - no request, no card, no
error - without the empty-normalized branch ever running.

This is not an edge case. Opening a turn on "OK" or "Yes" is ordinary in
Japanese, Korean, Chinese, Russian, Greek, Arabic, Hebrew, Thai and Hindi,
and it is exactly the failure the classifier's documented asymmetry exists
to prevent: a filler that slips through costs one request, a question read
as filler produces nothing at all, mid-interview.

So `Skip` there additionally requires that no letter from a script the
lexicon cannot read survived the non-speech markers. The test is on script
rather than on the codepoint being non-ASCII, and that distinction is the
point: an accented Latin letter belongs to a word the lexicon does read, so
blanking the umlaut in "Ähm" and matching "hm" stays a correct consumption
instead of becoming a gate call on every filler in seventeen languages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sentinel is the one string the backend prompts single out as failing
hardest if it is not matched exactly: an unmatched one is not a suppressed
card, it is `NO_SUGGESTION_NEEDED` rendered on screen as the answer, to a
question the backend had just decided needed none.

The fallback already forgave Markdown emphasis. It did not forgive a
right-to-left mark, and models writing Arabic or Hebrew routinely open a
response with one. U+200F is a format character, not whitespace, so `\s`
leaves it in place and `bare` starts on a character the sentinel does not.

Strip `\p{Cf}` with the emphasis, which also covers the zero-width joiners,
the isolates and a stray byte order mark. It cannot swallow a real answer:
the only way one collides is by being a genuine prefix of the sentinel,
which is the streaming case this function is built around - a real Hebrew
answer opening on the same mark is pinned as still an answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lves

The characters these cases are about are invisible, so pasted into the
source they leave a test whose inputs a reviewer cannot see and cannot tell
apart from each other - and which a stray editor normalisation could delete
without any diff worth noticing. The backend's ruff has a rule for exactly
this (PLE2502); this side has no equivalent, so it is a convention rather
than a check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alpha5611331

Copy link
Copy Markdown
Member Author

Multilingual review pass

Reviewed the language feature end to end across this PR and PowerInterviewAI/backend#58. Eleven commits pushed here. eslint, both tsc configs, vite build and pnpm test:main are green.

Bugs found and fixed

Non-Latin questions that open on "OK" were dropped outright (780ca6e) - the worst of these. The earlier fix covered turns that normalize to nothing; a turn is not required to be in one script. OK、では次の質問です。 normalizes to exactly ok, the backchannel lexicon eats it, the core comes back empty and the question is skipped by the core.length === 0 branch without the empty-normalized branch ever running. Opening a turn on "OK" or "Yes" is ordinary in Japanese, Korean, Chinese, Russian, Greek, Arabic, Hebrew, Thai and Hindi, and Deepgram transcribes those loanwords in Latin script. Ten new cases fail without the guard.

The merge separator rewrote the whole session's history (969815a) - cleaned is rebuilt from every stored transcript on each ingest and the separator was read once per rebuild from the current config, so switching an English interview to Japanese an hour in stripped the spaces out of every block merged so far, on screen and in the transcript the next request carries. Transcript now carries the language it was transcribed in.

Two overlapping language switches leaked an open socket (d041578, e75e06c) - both switches run a connectWithRetry loop that assigns this.ws synchronously per attempt, so the older loop waking from its backoff overwrites the newer one's socket, and the newer one is then unreferenced: stop() never closes it and the Deepgram session behind it stays open for the life of the app. The picker looks like it prevents this and does not - switching is only set after the config write is awaited, so a second pick lands in the gap. Gave the channel the generation token the microphone path already had, and the same guard to both hooks.

Nothing set a text direction for Arabic or Hebrew (4d11e7a, c2aeb75) - the picker's endonyms did; the transcript, the question line and the answers did not. The visible failure is the neutrals, not the run: sentence-final punctuation takes the paragraph's direction, so the question mark lands at the wrong end, and any answer reorders at each script switch (which is every answer, since the prompts keep product names and code in Latin). Block code is pinned ltr. Separately, the docs dialog joined all 28 endonyms into one string, so Arabic and Hebrew merged into a single RTL run and printed in the opposite order to every other pair.

NO_SUGGESTION_NEEDED did not match behind a directional mark (cb8badc) - the string the backend prompts single out as failing hardest. U+200F is a format character, not whitespace, so \s left it in place and the sentinel rendered on screen as the answer. Models writing RTL routinely open a response with one.

The exported report was half-translated (ae3f321) - the summarize prompt translates the headings it writes, on the stated grounds that a half-English report looks unfinished; the client then wrapped that Spanish summary in # **Transcripts** and an | Interviewer on every turn. Five nouns per language in export-labels.ts. The report is the one artifact that leaves the machine, which is why it is the documented exception to the app chrome staying English.

Also 5fc70ac (the mirror test counted rows and measured label width, but not that each row's short is its own code - a half-edited copied row shows the wrong language on the control bar and reads as correct) and a stale format_turns comment left behind by the AssemblyAI migration.

Verified, no change needed

  • All 28 codes are valid Nova-3 streaming languages per Deepgram's docs, including no and id.
  • Deepgram parameter and message names all match the reference.
  • {ja, zh, th} agree between transcript-join.ts and the backend's _UNSPACED_LANGUAGES.
  • configStore.getConfig() resolves on the way out and both IPC handlers return through it.
  • Greek's ; in FOREIGN_QUESTION_MARKS is U+037E, not ASCII - code and CLAUDE.md agree.
  • The classifier cannot false-Skip in the 17 Latin-script languages: a question keeps its ? glued to its last token, which blocks full consumption.

Left alone, worth a decision

  1. Nothing detects backend/client language drift. A code the client offers and the backend does not resolves silently to English - transcription and answers both, with the picker showing a check beside the language the user asked for. This is the "confident answer to a question that was never asked" failure the design is built around, and no in-repo test can catch it. The cheap fix is a protocol echo: have the ASR socket send the resolved language as its first frame, and raise the existing half-applied warning when it differs from what was requested.
  2. The last frozen-but-unended words are lost on every stop and every switch. The backend's close() flush cannot reach a client that closed the socket first, which is what both paths do (see backend#58 for the corrected docstring). The stop control frame already exists server-side and breaks the relay loop with the socket still open; nothing sends it. The menu already warns for switches, but Stop drops the tail right before an export.
  3. The credit timer restarts on every ASR connect, and the picker now lets a user reconnect on demand. At a 12-second interval the leak is small, but it is newly reachable from a button.
  4. hero has no multilingual copy at all. Nothing there contradicts the feature today, so this is a follow-up rather than a fix - and adding public docs for an unmerged feature needs its own branch.

@alpha5611331
alpha5611331 merged commit 94bac97 into main Aug 26, 2026
1 check passed
@alpha5611331
alpha5611331 deleted the feat/multilingual-support branch August 26, 2026 21:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Multilingual Support

1 participant