feat: 28-language interview picker and mid-interview audio switching - #108
Conversation
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>
…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>
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>
…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>
…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>
Multilingual review passReviewed the language feature end to end across this PR and PowerInterviewAI/backend#58. Eleven commits pushed here. Bugs found and fixedNon-Latin questions that open on "OK" were dropped outright ( The merge separator rewrote the whole session's history ( Two overlapping language switches leaked an open socket ( Nothing set a text direction for Arabic or Hebrew (
The exported report was half-translated ( Also Verified, no change needed
Left alone, worth a decision
|
Closes #24.
Languagehad 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.tsSuggestionModeis, plus display metadatasrc/main/store/config.store.tslanguageis typed and resolved on readsrc/main/services/suggestion-{live,action}.service.ts,tools.service.tslanguageon all three request bodiessrc/renderer/services/live-transcription.service.ts?language=on the ASR sockets,setLanguage()to switch them, andsetAudioInputDevice()/setStream()to swap the microphonesrc/renderer/hooks/use-interview-language.tssrc/renderer/hooks/use-audio-input-device.tssrc/renderer/components/custom/control-panel/language-group.tsxsrc/renderer/components/custom/control-panel/audio-group.tsxThe 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.
useInterviewLanguageis 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
onclosepath schedules a backoff reconnect on any unexpected close, and a deliberate close for a language switch looks exactly like one.onclosenow ignores a close from a socket that is no longerthis.ws. That is the tail of a replacement, not a disconnect. (This one is a latent fix on the pre-existing reconnect path too.)setLanguagesets aswitchingflag so the backoff reconnect does not fire for the close it caused itself, and reconnects immediately instead of waiting outWS_RETRY_BASE_DELAY_MS.connectWebSocketrebuilds 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:
setLanguagereportschannelDisconnecteditself rather than leaving it toonclose.new WebSocketassignsthis.wssynchronously, 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.this.ws, not onactive, whichstart()sets only after its first connect returns. In that window a socket already exists on the old language and anactivecheck 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, sincePromise.allleaves 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 theMediaStreamAudioSourceNodewhile 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:
AudioContextis reused. ItssampleRateis fixed at construction andconvertTo16kPcmreads 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.start()createssourcefromthis.streamand only assignsworkletNodeafterawait addModule(), so there is a real window where a context and a source exist and the node does not. Returning early there leavessourcebound to the stream the caller is about to stop, andstart()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_1moves;ch_0is loopback audio from the call and has no device to change.The bug this widening exposed, and the one to read first
classifyInterviewerTurnreturnedSkipfor every interviewer question in a non-Latin script - Japanese, Chinese, Thai, Russian, Korean, Arabic, Hindi, Hebrew.Skipdrops 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.Skipstays 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.mjspins 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, bothtscconfigs,pnpm buildandpnpm test:mainall clean.test/language.test.mjsnow 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.mjspins the acquire-before-release ordering and the reusedAudioContext. 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.
AudioWsStreamneeds a realWebSocket,AudioContextandgetDisplayMedia, none of which the dependency-free main-process harness reaches. Both are worth exercising by hand: