feat(voice): per-account choice between Whisper and the browser; STT_ALLOW_BROWSER
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m40s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 15:19:20 +01:00
1 parent 55112b940c
commit a927a94a95
21 files changed
+288 -15

No files matched your search

@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const getAuthStatus = vi.fn();
vi.mock('../api', () => ({ api: { getAuthStatus: (...a: any[]) => getAuthStatus(...a) } }));
import { loadVoiceConfig, resetVoiceConfig, setVoicePreference, voiceChoiceAvailable } from '../voice';
// Recording and recognition as far as jsdom needs them to count as available
(window as any).MediaRecorder = function () {};
Object.defineProperty(navigator, 'mediaDevices', { value: { getUserMedia: vi.fn() }, configurable: true });
(window as any).webkitSpeechRecognition = function () {};
afterEach(() => {
resetVoiceConfig();
getAuthStatus.mockReset();
});
describe('voice input: Whisper or browser per account', () => {
it('uses the browser when the account picked it and the server allows it', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600, browserAllowed: true } });
expect((await loadVoiceConfig()).mode).toBe('server');
expect(voiceChoiceAvailable()).toBe(true);
setVoicePreference('browser');
expect((await loadVoiceConfig()).mode).toBe('browser');
setVoicePreference('');
expect((await loadVoiceConfig()).mode).toBe('server');
});
it('keeps Whisper when the server forbids the browser (STT_ALLOW_BROWSER=false)', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600, browserAllowed: false } });
setVoicePreference('browser');
expect((await loadVoiceConfig()).mode).toBe('server');
expect(voiceChoiceAvailable()).toBe(false);
});
it('offers no choice without a Whisper server', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'browser', maxSeconds: 600, browserAllowed: true } });
await loadVoiceConfig();
expect(voiceChoiceAvailable()).toBe(false);
});
});
+1
View File
@@ -301,6 +301,7 @@ export const api = {
notifyBeforeMinutes?: number;
notificationSettings?: NotificationSettings;
doneRetentionDays?: number;
voiceInput?: '' | 'browser';
}): Promise<User> {
return request<User>('/api/v1/auth/settings', {
method: 'PATCH',
+3 -1
View File
@@ -31,7 +31,7 @@ export interface AuthStatus {
customLinkUrl?: string;
};
// Voice input: own Whisper server, the browser's recognition, or switched off (STT_* environment)
voice?: { mode: VoiceMode; maxSeconds: number };
voice?: { mode: VoiceMode; maxSeconds: number; browserAllowed?: boolean };
}
export type VoiceMode = 'server' | 'browser' | 'off';
@@ -113,6 +113,8 @@ export interface User {
notificationSettings?: NotificationSettings;
// Completed tasks are deleted this many days after ticking them off (0 = never)
doneRetentionDays?: number;
// Voice input: '' = the server's Whisper (default), 'browser' = the browser's recognition
voiceInput?: '' | 'browser';
organizationId?: string;
createdAt: string;
}
+11 -6
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from './api';
import { browserRecognition, currentVoiceConfig, EffectiveVoiceMode, joinSpeechResults, loadVoiceConfig, VoiceRecorder } from './voice';
import { browserRecognition, currentVoiceConfig, EffectiveVoiceMode, joinSpeechResults, loadVoiceConfig, VOICE_CONFIG_EVENT, VoiceRecorder } from './voice';
export type VoiceState = 'idle' | 'listening' | 'transcribing';
@@ -63,12 +63,17 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals
useEffect(() => {
mountedRef.current = true;
void loadVoiceConfig().then((cfg) => {
if (!mountedRef.current) return;
setMode(cfg.mode);
maxSecondsRef.current = cfg.maxSeconds;
});
const load = () =>
void loadVoiceConfig().then((cfg) => {
if (!mountedRef.current) return;
setMode(cfg.mode);
maxSecondsRef.current = cfg.maxSeconds;
});
load();
// The account setting (Whisper or browser) can change while this button is on screen
window.addEventListener(VOICE_CONFIG_EVENT, load);
return () => {
window.removeEventListener(VOICE_CONFIG_EVENT, load);
mountedRef.current = false;
clearAllTimers();
recorderRef.current?.cancel();
+35 -1
View File
@@ -3,6 +3,8 @@
// server record with MediaRecorder, the own Whisper server turns it into text after stopping
// browser the browser's speech recognition (Chrome, Edge, Safari; text appears while speaking)
// off no voice input
// With a Whisper server everyone may pick the browser instead (account setting), unless the server
// forbids it (STT_ALLOW_BROWSER=false).
import { api } from './api';
import type { VoiceMode } from './types';
@@ -15,6 +17,33 @@ export interface VoiceConfig {
let configPromise: Promise<VoiceConfig> | null = null;
let loadedConfig: VoiceConfig | null = null;
let serverMode: VoiceMode | undefined;
let browserAllowed = true;
let preference: '' | 'browser' = '';
/** Fired when the effective mode changes (the account setting was loaded or changed). */
export const VOICE_CONFIG_EVENT = 'mindsift:voice-config';
function applyPreference(mode: VoiceMode | undefined): VoiceMode | undefined {
return mode === 'server' && browserAllowed && preference === 'browser' ? 'browser' : mode;
}
/** The account's choice ('' = Whisper server, 'browser'); set when the user is loaded or changes it. */
export function setVoicePreference(next: '' | 'browser' | undefined): void {
const value = next === 'browser' ? 'browser' : '';
if (value === preference) return;
preference = value;
if (loadedConfig) {
loadedConfig = { ...loadedConfig, mode: effectiveMode(applyPreference(serverMode)) };
configPromise = Promise.resolve(loadedConfig);
}
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent(VOICE_CONFIG_EVENT));
}
/** Whether the account may choose between the Whisper server and the browser. */
export function voiceChoiceAvailable(): boolean {
return serverMode === 'server' && browserAllowed;
}
export function browserRecognition(): any {
if (typeof window === 'undefined') return null;
@@ -38,7 +67,9 @@ export function loadVoiceConfig(): Promise<VoiceConfig> {
configPromise = api
.getAuthStatus()
.then((st) => {
loadedConfig = { mode: effectiveMode(st.voice?.mode), maxSeconds: st.voice?.maxSeconds || 600 };
serverMode = st.voice?.mode;
browserAllowed = st.voice?.browserAllowed !== false;
loadedConfig = { mode: effectiveMode(applyPreference(serverMode)), maxSeconds: st.voice?.maxSeconds || 600 };
return loadedConfig;
})
.catch(() => {
@@ -58,6 +89,9 @@ export function currentVoiceConfig(): VoiceConfig {
export function resetVoiceConfig(): void {
configPromise = null;
loadedConfig = null;
serverMode = undefined;
browserAllowed = true;
preference = '';
}
// Containers in order of preference; Safari only records MP4/AAC