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
9 changes: 9 additions & 0 deletions ACKNOWLEDGMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ OpenInterviewer grows through contributions, working forks and feedback from
people conducting real research. Thank you for sharing what works, what is
missing and how you have adapted the project.

## Version 5.4.0 (draft): device voice

Device voice continues the participant-microphone idea inspired by Yinghua
([@8888oukaouka-spec](https://github.com/8888oukaouka-spec)) and her
[openinterviewerver02](https://github.com/8888oukaouka-spec/openinterviewerver02)
fork, credited under 5.1.0 below. The local-only mode is a new implementation
against OpenInterviewer’s configuration and consent contracts, not code authored
by her or cherry-picked from the fork.

## Version 5.3.0: projects

Thank you to [@8888oukaouka-spec](https://github.com/8888oukaouka-spec) for the
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ Hosted study create/delete is a durable cross-database operation. Preserve the o
- Server-recorded consent: `src/lib/participantConsent.ts`
- Consent coverage of the provider transport (Cloudflare; disclosed transport vs the current route): `src/lib/transportDisclosure.ts`
- The consent notice's provider commitment (`fixed` or `may-change`) and the retry check that keeps it: `src/lib/providerCommitment.ts`
- Participant voice input: `src/lib/voice/` (browser recording to 16 kHz WAV, browser dictation) and `src/lib/transcription/workersAi.ts` with `src/app/api/transcribe/route.ts` (Workers AI)
- Participant voice input: `src/lib/voice/` (browser recording to 16 kHz WAV, browser dictation, local-only device dictation) and `src/lib/transcription/workersAi.ts` with `src/app/api/transcribe/route.ts` (Workers AI)
- Interview languages and participant text: `src/lib/i18n/` (`languages.ts` is pure and shared with the Durable Object; `messages/en.ts` is the source every translation must match)
- Canonical study loading: `src/lib/canonicalStudy.ts`
- Save validation and deferred analysis: `src/lib/interviewSubmission.ts`, `src/lib/interviewAnalysis.ts`, `src/lib/analysisState.ts`
Expand All @@ -113,6 +113,7 @@ On Cloudflare the participant sequence is the same, with analysis queued by the
- The non-secret participant session selector must accompany participant API calls so parallel tabs remain isolated.
- Consent is a server record bound to participant session, study revision, and consent hash. Client Zustand state alone is not consent authority.
- Voice transcription runs only for a study whose `voiceInput` is `installation` (the consent notice names Cloudflare), after the same consent verification and participant admission as an interview turn (`transcribe` operation). It calls Workers AI once, never through AI Gateway, and never stores or logs the audio or text.
- Voice modes are `off`, `installation`, `browser` and `device`. Device needs no server capability and follows browser mode’s hosted rules. Its mic is gated by local pack availability; every recognition uses `processLocally = true`, never a remote fallback. Terminal device failures hide the mic for the participant session. `SPEECH_TAGS` in `src/lib/voice/useVoiceInput.ts` is the single speech-tag table (Chinese needs manual Chrome verification).
- In a study with interview languages, the consent hash is of the chosen language's consent text. Greeting, interview and save name that language and are verified against the same hash, so a switched language fails consent; the saved record's `interviewLanguage` must match it (rechecked by the Durable Object completion). A study without the setting sends and records no language.
- Researcher preview may call the real provider but must not persist or increment study results.
- Study revision, link status, ownership, consent, rate limits, and storage uncertainty fail closed.
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,9 +438,12 @@ Studies saved before this setting existed keep their old notice, and their inter

- **Transcribed by this installation**: the browser records up to one minute, converts it to 16 kHz WAV and sends it to `/api/transcribe`, which passes it to Cloudflare Workers AI (`@cf/openai/whisper-large-v3-turbo`) once. OpenInterviewer does not store or log the recording or its text. Cloudflare's Workers AI terms say customer content is not used to train models; they do not state a retention period, so the consent line promises only that the study does not keep the recording. Works in current Chrome, Edge, Firefox and Safari, on phones and computers. On Cloudflare the installation's `AI` binding is used directly, never through AI Gateway; on Node set `CLOUDFLARE_WORKERS_AI_ACCOUNT_ID` and `CLOUDFLARE_WORKERS_AI_TOKEN` (an API token with Workers AI permissions). Cloudflare charges about $0.0005 per audio minute after its free daily allowance of 10,000 Neurons (roughly 200 audio minutes). Each request is admitted like an interview turn (40 clips per session per hour; 1,500 per study per day). The model's voice-activity filter drops silence and steady noise, but like any speech model it can occasionally turn a non-speech sound into a stock phrase (in staging tests, once, the Japanese for “thanks for watching”). The participant's check before sending is the safeguard, so the text is never sent on its own.
- **The browser's own dictation**: no setup. Chrome sends the audio to Google and Safari to Apple, under their terms; Firefox does not support it. Use it only where your ethics approval allows these processors.
- **On the participant’s computer (desktop Chrome only)** (`device`): the browser turns speech into text locally with `processLocally = true`, never falling back to a speech service or installation transcription. Available in desktop Chrome 139+ where the selected language pack is supported; phones, Safari, Firefox and unsupported devices show no microphone and participants type instead. The browser may first download a speech pack (about 60 MB). Participants can keep typing and sending during preparation, then press the mic again when ready. This is the browser’s local-processing promise, not something the page can independently verify. No server credentials, bindings or readiness capability are needed.
- **Off** (the default).

Transcription by the installation is not offered on the hosted service, which refuses to save it; browser dictation works there. On-device transcription is being evaluated; the open models available today do not cover all six interview languages.
Transcription by the installation is not offered on the hosted service, which refuses to save it; browser dictation and device voice work there. The six speech tags are centralized in `SPEECH_TAGS` in `src/lib/voice/useVoiceInput.ts`; desktop Chrome 155 accepts all six (including `zh-CN`) for local processing. Its translations were reviewed like the others (`docs/translations/REVIEW-2026-10.md`). See the [draft v5.4.0 notes](docs/releases/v5.4.0.md).

A 5.3 build rejects saved `voiceInput: device` configurations, rather than silently changing modes: participant access and link creation are refused (409); canonical-study validation also fails closed (503). Unchanged saves return 400 `Invalid voice input setting`. Before rollback, stop collection and explicitly change affected studies to **Off** on 5.4, which advances their revision; distribute new links before resuming. Saved interviews retain their original device-mode collection configuration, which 5.3 does not fully understand—prefer rolling forward if those records need processing or export.

Changing study configuration advances its revision and invalidates links and participant sessions issued for the previous revision. Generate and distribute a new link after a consequential edit. Pausing/resuming collection and unchanged saves do not advance the revision.

Expand Down
21 changes: 21 additions & 0 deletions docs/operations/cloudflare-migration/RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,27 @@ A lost response is resolved by reading `status`, never by repeating a transition

**Unknown outcomes.** A maintenance transition, activation, restore, import chunk or import finalize may have committed when the Worker answers `OUTCOME_UNKNOWN` or a 5xx other than a definite refusal, when a 2xx reply lacks the expected result (for example a body that is not JSON), or when no complete reply arrives: the connection fails or is reset, the body is cut off, or nothing arrives within 120 s (`no reply: <code>`, with `detail.network`). The CLI then exits 1 with `detail.outcome: "unknown"`. It does not resend the request, except an import chunk or finalize, which is idempotent and is sent up to three times in all. It reads `status` once and puts it in `detail.status`. If that read fails too, `detail.status.unavailable` gives the reason and the message says `status could not be read either`. In that case run `status` before anything else, and repeat it until it answers. Compare it with what you asked for: a changed state or version, or a changed activated epoch, means the request ran. For an import, re-run the same command: accepted chunks come back as duplicates, and a finalized import answers `finalized` again.

### Voice input modes and rollback

A study offers `off`, `installation`, `browser` or `device`. Only `installation`
uses the Workers AI binding. `browser` uses the browser's speech service;
`device` requires desktop Chrome's local recognition and no server capability,
credential or gateway. An absent local API or unavailable pack hides the mic;
participants type. A first-use pack download (about 60 MB) does not block typing.
If preparation or local recognition is unavailable, there is no remote fallback.

Before reverting 5.4 to 5.3, stop collection and explicitly save affected studies
with voice input **Off** while still on 5.4. That changes the revision and
invalidates prior participant authority; issue new links before resuming.
A 5.3 validator rejects `device` with `Invalid voice input setting`: unchanged
saves return 400, participant context and link generation return 409, and the
canonical loader's independent check returns 503. It does not silently disable
voice or send audio elsewhere. Existing data is not deleted. Saved interview
collection configurations can still contain `device`; old exports cannot describe
that mode and operations validating those configurations can refuse them. Prefer
rolling forward to 5.4 if these records need processing. This does not alter any
existing schema rollback restrictions. See the [draft release notes](../../releases/v5.4.0.md).

### Sign-in lockout

Sign-in is limited on Cloudflare: 10 failed attempts per client per 15 minutes, and 200 across all clients per hour. Each client is its IPv4 address, or its /64 for IPv6, so a lockout covers every address in that /64. Each window opens with its first counted attempt and is never extended. Over a limit, `/api/auth` answers 429 with `Retry-After`, and the CLI exits 1 with "retry after N seconds". A client-level lockout clears within 15 minutes. A global lockout means many failures from many clients; it blocks every sign-in, including the correct password, for up to one hour. Sign-in works in every maintenance state and under epoch, identity and bootstrap holds. Only an unsupported schema or unavailable workspace storage refuses it (503).
Expand Down
77 changes: 77 additions & 0 deletions docs/releases/v5.4.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# OpenInterviewer 5.4.0 — draft

## Device voice input

A fourth study voice mode, **On the participant’s computer (desktop Chrome
only)** (`device`), lets the participant's browser turn speech into text locally.
Like the other voice modes, text appears in the answer box for review and is
never sent automatically. Inspired by Yinghua's
([@8888oukaouka-spec](https://github.com/8888oukaouka-spec)) participant-microphone
idea in her fork, credited in [5.1.0](v5.1.0.md) and
[ACKNOWLEDGMENTS.md](../../ACKNOWLEDGMENTS.md); this local-only implementation is
not authored by her or imported from the fork.

- Desktop Chrome 139+ exposes local speech recognition. The microphone appears
only if the selected language's local pack is available, downloadable or
downloading. Phones, Safari, Firefox stable and unsupported devices show no
microphone; participants type instead.
- The first press can ask the browser to install a speech pack (about 60 MB).
A localized preparation note appears; typing and Send remain available. Once
ready, press the microphone again to start (no surprise recording after a
slow download).
- Every recognizer started in device mode has `processLocally = true`. There is
no fallback to browser cloud dictation or installation transcription.
`language-not-supported` and `service-not-allowed` disable the mic for that
participant session, with a short localized note.
- Consent presents locality as the browser's promise, not ours: the browser
turns speech into text on this computer and says the recording stays there;
the study receives only the text the participant sends. OpenInterviewer cannot
verify what the browser does internally. The submitted **text** still
follows the study's usual AI interview and storage terms.
- Works with standalone and hosted studies without server transcription
credentials, capability or readiness changes. `/api/transcribe` still accepts
only `installation`. Changing voice mode advances the study revision and
invalidates older participant authority. Markdown exports describe the mode
offered at collection, not whether the microphone was used.

## Upgrade and rollback

No dependency, database schema, environment variable or binding changes.

**Do not roll back a collecting device-mode study to 5.3.** The actual 5.3
validator at `f3eb006` was exercised against `voiceInput: 'device'`: canonical
validation, creation and an unchanged update return `Invalid voice input
setting`. An explicit update to `off` is accepted. The older code paths map
these failures to:

- 400 on creation or an unchanged configuration save.
- 409 from participant-context resolution (review/resave required) and link
generation (`STUDY_REQUIRES_RESAVE`).
- 503 if the canonical-study loader independently encounters the invalid config.

The refusal occurs before participant provider use or persistence. Nothing is
silently converted to cloud dictation or deleted. Before rollback, stop
collection and save affected studies as **Off** on 5.4; this advances revision,
so issue new links before resuming. Historical interviews retain `device` in
`collectionConfig`; 5.3's Markdown export omits the device voice line, and
operations that validate historical configurations can refuse those records.
Rolling forward is preferable if they need processing or accurate export.
Existing schema downgrade restrictions still apply.

## Verification still required before release

Unit mocks verify the local-only flag, feature detection, preparation, failures,
language mapping, consent and server/config boundaries. They cannot prove real
browser locality or language-pack support.

- Manually verify all six languages in desktop Chrome, including a fresh pack
install, offline recognition with an installed pack, denied microphone access,
and continued typing during download. Check unavailable browsers and a 375px
setup/participant layout.
- Chinese tag: desktop Chrome 155 reports `zh-CN` (and the five other tags) as
downloadable for local processing; `SPEECH_TAGS` keeps `zh-CN`.
- The five new translations were reviewed by Gemini, Kimi and Grok; decisions are in
`docs/translations/REVIEW-2026-10.md`.
- Run the workers/Redis shared revision contract and browser lanes listed in
`CHANGES-device-voice.md`. No deployment or real browser verification was done
in the restricted implementation sandbox.
18 changes: 18 additions & 0 deletions docs/translations/REVIEW-2026-10.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,21 @@ Before release, the installation notice was also corrected in every language aft
## Process for later changes

Change `en.ts` first. Any change to `consent.transport`, `consent.commitment` or `consent.controller` needs every translation updated and reviewed again, and a new entry in this folder. `tests/unit/interviewLanguages.test.ts` checks that every language has every message, with no empty or placeholder text, and that each notice names the provider, model and Cloudflare exactly as given.

## On-device voice strings, 5.4.0 (2026-10-10)

New strings: `consent.voice.device` (part of the consent notice), `interview.voice.preparing` and `interview.voice.deviceUnavailable`. They were drafted in zh, fr, ja, ko and es with AI assistance (Codex, then Claude rewrote the consent line so that locality is the browser's claim, not the study's). Kimi K3 checked the Spanish register in code review. Then the same three reviewers as above (Gemini 3.8 Flash, Kimi K3, Grok 4.6, through cc-fleet) each got the English source, all five translations, the neighbouring voice strings for register, and the same legal-text instructions.

No reviewer found a promise added, removed, weakened or strengthened. All five keep the locality claim with the browser (浏览器…并表示, indique que, としています, -다고 안내합니다, indica que).

| Reviewer | Language | Key | Severity | Finding | Decision |
| --- | --- | --- | --- | --- | --- |
| Kimi K3 (code review) | es | `consent.voice.device`, `interview.voice.deviceUnavailable` | minor | Formal usted; the rest of the file uses tú. | Accepted. |
| Gemini, Kimi, Grok | ja | `consent.voice.device` | major | この調査 (survey) instead of the established この研究; ブラウザー vs ブラウザ; あなた unlike the surrounding subject-less strings; マイクを使う場合 vs the established マイクを使用すると. | Accepted (Gemini's wording). |
| Gemini, Kimi | zh | `interview.voice.deviceUnavailable` | minor | 请键入 differs from the existing 请直接输入 for the same English sentence. | Accepted: 请直接输入您的回答。 |
| Grok | zh | `interview.voice.preparing` | minor | 为这台设备准备 reads as preparing *for* the device, losing "on this device". | Accepted: 正在这台设备上准备语音输入… |
| Gemini | ko | `consent.voice.device` | minor | The chooser of "the text you choose to send" is left implicit; the UI addresses the participant as 참여자님. | Accepted: 참여자님이 보내기로 선택한 텍스트 |
| Gemini, Grok (Kimi preferred the opposite direction) | ko | `interview.voice.deviceUnavailable` | minor | 답변 vs the existing line's 응답 for the same English. | Accepted: 응답, matching the existing string. |
| Kimi | es | `consent.unavailable` | blocker | Reported missing. | Rejected: present (es.ts line 50); the reviewer's excerpt omitted it. |

fr: no issues from any reviewer. As above, these are AI-reviewed translations, not certified ones.
2 changes: 1 addition & 1 deletion src/components/Consent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ const Consent: React.FC = () => {
<span className="font-mono">
{providerDisclosure}
{providerCommitmentNotice ? <>{' '}{providerCommitmentNotice}</> : null}
{studyConfig.voiceInput === 'installation' || studyConfig.voiceInput === 'browser'
{studyConfig.voiceInput === 'installation' || studyConfig.voiceInput === 'browser' || studyConfig.voiceInput === 'device'
? <>{' '}{m.voice[studyConfig.voiceInput]}</>
: null}
</span>{' '}
Expand Down
3 changes: 2 additions & 1 deletion src/components/InterviewChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ const InterviewChat: React.FC = () => {
type="button"
variant="quiet"
onClick={voice.toggle}
disabled={isAiThinking || voice.state.kind === 'transcribing'}
disabled={isAiThinking || voice.state.kind === 'transcribing' || voice.state.kind === 'preparing'}
aria-pressed={voice.state.kind === 'recording' || voice.state.kind === 'listening'}
className="min-h-11 w-full sm:w-auto"
>
Expand All @@ -467,6 +467,7 @@ const InterviewChat: React.FC = () => {
? 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 === 'transcribing' ? m.voice.transcribing
: voice.state.kind === 'preparing' ? m.voice.preparing
: m.voice[voice.state.reason]}
</p>
)}
Expand Down
Loading
Loading