Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

OpenInterviewer is an open-source platform for adaptive, AI-assisted qualitative interviews. Researchers configure a study, share an opaque participant link, and review transcripts and synthesis in a dashboard. An AI interviewer conducts each interview, and the transcript is saved before any analysis runs.

The latest release is 5.4.1, which fixes how Chinese and Japanese dashes display. Version 5.4.0 added on-device voice input and analysis files in the study export. Read the [5.4.1](docs/releases/v5.4.1.md) and [5.4.0](docs/releases/v5.4.0.md) release notes, or [all release notes](docs/releases/).
The latest release is 5.4.2, which makes voice input show what it is doing and never fail silently. Version 5.4.1 fixed how Chinese and Japanese dashes display, and 5.4.0 added on-device voice input and analysis files in the study export. Read the [5.4.2](docs/releases/v5.4.2.md), [5.4.1](docs/releases/v5.4.1.md) and [5.4.0](docs/releases/v5.4.0.md) release notes, or [all release notes](docs/releases/).

The project's own instance is [open-interview.linxule.com](https://open-interview.linxule.com). It is a standalone installation on Cloudflare, with an EU-jurisdiction workspace and provider calls through its own Cloudflare AI Gateway. The former address, `openinterviewer.vercel.app`, redirects there.

Expand Down Expand Up @@ -133,7 +133,7 @@ For contributors:

- [Development and verification](docs/development.md): checks, test lanes, live-provider smoke test, releases, provider API and model contract, and project structure
- [Contributing guide](CONTRIBUTING.md) and [agent and architecture guide](AGENTS.md)
- [Release notes](docs/releases/), latest [5.4.1](docs/releases/v5.4.1.md)
- [Release notes](docs/releases/), latest [5.4.2](docs/releases/v5.4.2.md)
- [Translation review, October 2026](docs/translations/REVIEW-2026-10.md)

## Acknowledgments
Expand Down
33 changes: 33 additions & 0 deletions docs/releases/v5.4.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# OpenInterviewer 5.4.2

Voice input now tells participants what is happening and never leaves them
talking into silence. This mainly affects on-device voice input (added in
5.4.0), where Chrome's recognizer could record without returning any text or
error.

## What changed

- **Ready.** After Chrome downloads its speech pack, the participant sees
"Voice input is ready. Press the microphone to speak." The microphone still
never opens on its own.
- **Starting.** "Starting the microphone…" shows until the browser is actually
capturing sound. "Listening…" then means it really is.
- **Live text.** Words appear under the answer box as they are recognized. They
stay on the participant's screen and are not saved or sent.
- **Unfinished speech is kept.** If the browser recognizes speech but never
marks it finished, that text goes into the answer box when the participant
stops. As before, nothing is sent until the participant presses Send.
- **No more silent failures.** If no text was recognized, or the browser stopped
listening unexpectedly, the participant is told and can try again or type.
A stuck recognizer is released after a few seconds, so Send never stays
disabled.
- **Chinese and Japanese.** Dictated text is no longer joined with spaces.
- The new messages are in all six interview languages.

On-device voice input still runs only in the browser on the participant's
computer. Nothing about where speech is processed has changed.

## Upgrade and rollback

A code-only release, with no schema, configuration or binding changes. Rolling
back to 5.4.1 or 5.4.0 is safe.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "openinterviewer",
"version": "5.4.1",
"version": "5.4.2",
"description": "Open-source platform for adaptive, AI-assisted qualitative interviews.",
"private": true,
"license": "MIT",
Expand Down
25 changes: 16 additions & 9 deletions src/components/InterviewChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Button, Turn } from '@/components/ui';
import NoSessionNotice from '@/components/NoSessionNotice';
import { useParticipantLanguage } from '@/lib/i18n/useParticipantLanguage';
import { hasLanguageSetting } from '@/lib/i18n/languages';
import { useVoiceInput } from '@/lib/voice/useVoiceInput';
import { speechJoiner, useVoiceInput } from '@/lib/voice/useVoiceInput';

// Primitive props keep completed turns from reparsing Markdown when the
// composer changes or another message arrives.
Expand Down Expand Up @@ -67,9 +67,11 @@ const InterviewChat: React.FC = () => {
researcherPreview: viewMode === 'preview',
participantSessionHandle,
// Text is added to the answer box for the participant to check; never sent.
onText: (text) => setInput((current) => (current.trimEnd() ? `${current.trimEnd()} ${text}` : text)),
onText: (text) => setInput((current) => (current.trimEnd() ? `${current.trimEnd()}${speechJoiner(shownLanguage)}${text}` : text)),
});
const voiceBusy = voice.state.kind === 'recording' || voice.state.kind === 'listening' || voice.state.kind === 'transcribing';
const voiceListening = voice.state.kind === 'starting' || voice.state.kind === 'listening';
const voicePreviewVisible = voiceListening || voice.state.kind === 'stopping';
const voiceBusy = voice.state.kind === 'recording' || voicePreviewVisible || voice.state.kind === 'transcribing';

const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
Expand Down Expand Up @@ -268,7 +270,7 @@ const InterviewChat: React.FC = () => {
// Cmd/Ctrl+Enter sends; the Send button is the other way to send.
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (!isAiThinking && input.trim()) {
if (!isAiThinking && !voiceBusy && input.trim()) {
void handleSend();
}
}
Expand Down Expand Up @@ -451,11 +453,11 @@ const InterviewChat: React.FC = () => {
type="button"
variant="quiet"
onClick={voice.toggle}
disabled={isAiThinking || voice.state.kind === 'transcribing' || voice.state.kind === 'preparing'}
aria-pressed={voice.state.kind === 'recording' || voice.state.kind === 'listening'}
disabled={isAiThinking || voice.state.kind === 'transcribing' || voice.state.kind === 'preparing' || voice.state.kind === 'stopping'}
aria-pressed={voice.state.kind === 'recording' || voicePreviewVisible}
className="min-h-11 w-full sm:w-auto"
>
{voice.state.kind === 'recording' || voice.state.kind === 'listening' ? m.voice.stop : m.voice.start}
{voice.state.kind === 'recording' || voicePreviewVisible ? m.voice.stop : m.voice.start}
</Button>
{sendButton}
</div>
Expand All @@ -465,13 +467,18 @@ const InterviewChat: React.FC = () => {
<p role={voice.state.kind === 'error' ? 'alert' : 'status'} className="text-[13px] leading-[20px] text-ink-700">
{voice.state.kind === 'recording'
? m.voice.recording(`${Math.floor(voice.state.seconds / 60)}:${String(voice.state.seconds % 60).padStart(2, '0')}`)
: voice.state.kind === 'listening' ? m.voice.listening
: voice.state.kind === 'ready' ? m.voice.ready
: voice.state.kind === 'starting' ? m.voice.starting
: voice.state.kind === 'listening' || voice.state.kind === 'stopping' ? m.voice.listening
: voice.state.kind === 'transcribing' ? m.voice.transcribing
: voice.state.kind === 'preparing' ? m.voice.preparing
: m.voice[voice.state.reason]}
</p>
)}
{voice.enabled && voice.state.kind === 'idle' && input.trim() !== '' && (
{voice.enabled && voicePreviewVisible && voice.preview && (
<p lang={voice.speechTag} className="text-[13px] leading-[20px] text-ink-500">{voice.preview}</p>
)}
{voice.enabled && (voice.state.kind === 'idle' || voice.state.kind === 'ready') && input.trim() !== '' && (
<p className="text-[13px] leading-[20px] text-ink-500">{m.voice.review}</p>
)}
<p className="text-[13px] leading-[20px] text-ink-500 pointer-coarse:hidden">{m.sendShortcut}</p>
Expand Down
8 changes: 8 additions & 0 deletions src/lib/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export interface Messages {
recording: (elapsed: string) => string;
listening: string;
transcribing: string;
ready: string;
starting: string;
noText: string;
interrupted: string;
preparing: string;
deviceUnavailable: string;
review: string;
Expand Down Expand Up @@ -218,6 +222,10 @@ export const en: Messages = {
send: 'Send',
sendShortcut: '⌘/Ctrl + Enter to send',
voice: {
ready: 'Voice input is ready. Press the microphone to speak.',
starting: 'Starting the microphone…',
noText: 'No text was captured. Please try again or type your answer.',
interrupted: 'Voice input stopped unexpectedly. Please try again or type your answer.',
preparing: 'Preparing speech on this device…',
deviceUnavailable: 'Voice input is unavailable on this device. Please type your answer.',
start: 'Start voice input',
Expand Down
4 changes: 4 additions & 0 deletions src/lib/i18n/messages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ export const es: Messages = {
send: 'Enviar',
sendShortcut: '⌘/Ctrl + Intro para enviar',
voice: {
ready: 'La entrada de voz está lista. Pulsa el micrófono para hablar.',
starting: 'Activando el micrófono…',
noText: 'No se ha reconocido ningún texto. Inténtalo de nuevo o escribe tu respuesta.',
interrupted: 'La entrada de voz se ha detenido de forma inesperada. Inténtalo de nuevo o escribe tu respuesta.',
preparing: 'Preparando la entrada de voz en este dispositivo…',
deviceUnavailable: 'La entrada de voz no está disponible en este dispositivo. Escribe tu respuesta.',
start: 'Iniciar la entrada de voz',
Expand Down
4 changes: 4 additions & 0 deletions src/lib/i18n/messages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ export const fr: Messages = {
send: 'Envoyer',
sendShortcut: '⌘/Ctrl + Entrée pour envoyer',
voice: {
ready: 'La saisie vocale est prête. Appuyez sur le micro pour parler.',
starting: 'Démarrage du micro…',
noText: 'Aucun texte n’a été reconnu. Veuillez réessayer ou saisir votre réponse.',
interrupted: 'La saisie vocale s’est arrêtée de manière inattendue. Veuillez réessayer ou saisir votre réponse.',
preparing: 'Préparation de la saisie vocale sur cet appareil…',
deviceUnavailable: 'La saisie vocale n’est pas disponible sur cet appareil. Veuillez saisir votre réponse.',
start: 'Démarrer la saisie vocale',
Expand Down
4 changes: 4 additions & 0 deletions src/lib/i18n/messages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ export const ja: Messages = {
send: '送信',
sendShortcut: '⌘/Ctrl + Enter で送信',
voice: {
ready: '音声入力の準備ができました。マイクボタンを押して話してください。',
starting: 'マイクを起動しています…',
noText: '文字を認識できませんでした。もう一度お試しいただくか、回答を入力してください。',
interrupted: '音声入力が予期せず停止しました。もう一度お試しいただくか、回答を入力してください。',
preparing: 'このデバイスで音声入力を準備しています…',
deviceUnavailable: 'このデバイスでは音声入力を利用できません。回答を入力してください。',
start: '音声入力を開始',
Expand Down
4 changes: 4 additions & 0 deletions src/lib/i18n/messages/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ export const ko: Messages = {
send: '보내기',
sendShortcut: '⌘/Ctrl + Enter로 보내기',
voice: {
ready: '음성 입력이 준비되었습니다. 마이크 버튼을 누르고 말씀해 주세요.',
starting: '마이크를 켜는 중…',
noText: '인식된 텍스트가 없습니다. 다시 시도하시거나 응답을 입력해 주세요.',
interrupted: '음성 입력이 예기치 않게 중지되었습니다. 다시 시도하시거나 응답을 입력해 주세요.',
preparing: '이 기기에서 음성 입력을 준비하고 있습니다…',
deviceUnavailable: '이 기기에서는 음성 입력을 사용할 수 없습니다. 응답을 입력해 주세요.',
start: '음성 입력 시작',
Expand Down
6 changes: 5 additions & 1 deletion src/lib/i18n/messages/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,11 @@ export const zh: Messages = {
send: '发送',
sendShortcut: '按 ⌘/Ctrl + Enter 发送',
voice: {
preparing: '正在这台设备上准备语音输入…',
ready: '语音输入已准备好。请按麦克风按钮开始说话。',
starting: '正在启动麦克风……',
noText: '没有识别到文字。请重试,或直接输入您的回答。',
interrupted: '语音输入意外中断。请重试,或直接输入您的回答。',
preparing: '正在这台设备上准备语音输入……',
deviceUnavailable: '这台设备无法使用语音输入。请直接输入您的回答。',
start: '开始语音输入',
stop: '停止录音',
Expand Down
Loading
Loading