You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-07-16 00:14:34 +02:00
215 lines
6.5 KiB
TypeScript
215 lines
6.5 KiB
TypeScript
import * as React from 'react';
|
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
import { Text, Button } from 'react-native-paper';
|
|
import { _, languageName } from '@joplin/lib/locale';
|
|
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
|
import VoiceTyping, { OnTextCallback, VoiceTypingSession } from '../../services/voiceTyping/VoiceTyping';
|
|
import whisper from '../../services/voiceTyping/whisper';
|
|
import vosk from '../../services/voiceTyping/vosk';
|
|
import { AppState } from '../../utils/types';
|
|
import { connect } from 'react-redux';
|
|
import { RecorderState } from './types';
|
|
import RecordingControls from './RecordingControls';
|
|
import { PrimaryButton } from '../buttons';
|
|
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
|
|
import shim from '@joplin/lib/shim';
|
|
|
|
interface Props {
|
|
locale: string;
|
|
provider: string;
|
|
onDismiss: ()=> void;
|
|
onText: (text: string)=> void;
|
|
}
|
|
|
|
interface UseVoiceTypingProps {
|
|
locale: string;
|
|
provider: string;
|
|
onSetPreview: OnTextCallback;
|
|
onText: OnTextCallback;
|
|
}
|
|
|
|
const useVoiceTyping = ({ locale, provider, onSetPreview, onText }: UseVoiceTypingProps) => {
|
|
const [voiceTyping, setVoiceTyping] = useState<VoiceTypingSession>(null);
|
|
const [error, setError] = useState<Error|null>(null);
|
|
const [mustDownloadModel, setMustDownloadModel] = useState<boolean | null>(null);
|
|
const [stoppingSession, setIsStoppingSession] = useState(false);
|
|
|
|
const onTextRef = useRef(onText);
|
|
onTextRef.current = onText;
|
|
const onSetPreviewRef = useRef(onSetPreview);
|
|
onSetPreviewRef.current = onSetPreview;
|
|
|
|
const voiceTypingRef = useRef(voiceTyping);
|
|
voiceTypingRef.current = voiceTyping;
|
|
|
|
const builder = useMemo(() => {
|
|
return new VoiceTyping(locale, provider?.startsWith('whisper') ? [whisper] : [vosk]);
|
|
}, [locale, provider]);
|
|
|
|
const [redownloadCounter, setRedownloadCounter] = useState(0);
|
|
|
|
useQueuedAsyncEffect(async (event) => {
|
|
try {
|
|
// Reset the error: If starting voice typing again resolves the error, the error
|
|
// should be hidden (and voice typing should start).
|
|
setError(null);
|
|
|
|
await voiceTypingRef.current?.cancel();
|
|
onSetPreviewRef.current?.('');
|
|
|
|
const outdated = await builder.isDownloadedFromOutdatedUrl();
|
|
if (outdated) {
|
|
const allowOutdatedMessage = _('New model available\nA new voice typing model is available. Do you want to download it?');
|
|
const downloadNewModel = await shim.showConfirmationDialog(allowOutdatedMessage);
|
|
if (downloadNewModel) {
|
|
await onRequestRedownload();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!await builder.isDownloaded()) {
|
|
if (event.cancelled) return;
|
|
await builder.download();
|
|
}
|
|
if (event.cancelled) return;
|
|
|
|
const voiceTyping = await builder.build({
|
|
onPreview: (text) => onSetPreviewRef.current(text),
|
|
onFinalize: (text) => onTextRef.current(text),
|
|
});
|
|
if (event.cancelled) return;
|
|
setVoiceTyping(voiceTyping);
|
|
} catch (error) {
|
|
setError(error);
|
|
} finally {
|
|
setMustDownloadModel(false);
|
|
}
|
|
}, [builder, redownloadCounter]);
|
|
|
|
useAsyncEffect(async (_event: AsyncEffectEvent) => {
|
|
setMustDownloadModel(!(await builder.isDownloaded()));
|
|
}, [builder]);
|
|
|
|
useEffect(() => () => {
|
|
void voiceTypingRef.current?.cancel();
|
|
}, []);
|
|
|
|
const onRequestRedownload = useCallback(async () => {
|
|
setIsStoppingSession(true);
|
|
await voiceTypingRef.current?.cancel();
|
|
await builder.clearDownloads();
|
|
setMustDownloadModel(true);
|
|
setIsStoppingSession(false);
|
|
setRedownloadCounter(value => value + 1);
|
|
}, [builder]);
|
|
|
|
return {
|
|
error, mustDownloadModel, stoppingSession, voiceTyping, onRequestRedownload,
|
|
};
|
|
};
|
|
|
|
const SpeechToTextComponent: React.FC<Props> = props => {
|
|
const [recorderState, setRecorderState] = useState<RecorderState>(RecorderState.Loading);
|
|
const [preview, setPreview] = useState<string>('');
|
|
const {
|
|
error: modelError,
|
|
mustDownloadModel,
|
|
voiceTyping,
|
|
stoppingSession,
|
|
onRequestRedownload,
|
|
} = useVoiceTyping({
|
|
locale: props.locale,
|
|
onSetPreview: setPreview,
|
|
onText: props.onText,
|
|
provider: props.provider,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (modelError) {
|
|
setRecorderState(RecorderState.Error);
|
|
} else if (voiceTyping) {
|
|
setRecorderState(RecorderState.Recording);
|
|
}
|
|
}, [voiceTyping, modelError]);
|
|
|
|
useEffect(() => {
|
|
if (mustDownloadModel) {
|
|
setRecorderState(RecorderState.Downloading);
|
|
}
|
|
}, [mustDownloadModel]);
|
|
|
|
useEffect(() => {
|
|
if (stoppingSession) {
|
|
setRecorderState(RecorderState.Processing);
|
|
}
|
|
}, [stoppingSession]);
|
|
|
|
useEffect(() => {
|
|
if (recorderState === RecorderState.Recording) {
|
|
void voiceTyping.start();
|
|
}
|
|
}, [recorderState, voiceTyping, props.onText]);
|
|
|
|
const onDismiss = useCallback(async () => {
|
|
if (voiceTyping) {
|
|
setRecorderState(RecorderState.Processing);
|
|
await voiceTyping.stop();
|
|
setRecorderState(RecorderState.Idle);
|
|
}
|
|
props.onDismiss();
|
|
}, [voiceTyping, props.onDismiss]);
|
|
|
|
const renderContent = () => {
|
|
const components: Record<RecorderState, ()=> string> = {
|
|
[RecorderState.Loading]: () => _('Loading...'),
|
|
[RecorderState.Idle]: () => 'Waiting...', // Not used for now
|
|
[RecorderState.Recording]: () => _('Please record your voice...'),
|
|
[RecorderState.Processing]: () => (
|
|
stoppingSession ? _('Closing session...') : _('Converting speech to text...')
|
|
),
|
|
[RecorderState.Downloading]: () => _('Downloading %s language files...', languageName(props.locale)),
|
|
[RecorderState.Error]: () => _('Error: %s', modelError?.message),
|
|
};
|
|
|
|
return components[recorderState]();
|
|
};
|
|
|
|
const renderPreview = () => {
|
|
if (recorderState !== RecorderState.Recording) {
|
|
return null;
|
|
}
|
|
return <Text variant='labelSmall'>{preview}</Text>;
|
|
};
|
|
|
|
const reDownloadButton = <Button
|
|
// Usually, stoppingSession is true because the re-download button has
|
|
// just been pressed.
|
|
disabled={stoppingSession || recorderState === RecorderState.Downloading}
|
|
onPress={onRequestRedownload}
|
|
>
|
|
{_('Re-download model')}
|
|
</Button>;
|
|
const allowReDownload = recorderState === RecorderState.Error;
|
|
|
|
const actions = <>
|
|
{allowReDownload ? reDownloadButton : null}
|
|
<PrimaryButton
|
|
onPress={onDismiss}
|
|
disabled={recorderState === RecorderState.Processing}
|
|
accessibilityHint={_('Ends voice typing')}
|
|
>{_('Done')}</PrimaryButton>
|
|
</>;
|
|
|
|
return <RecordingControls
|
|
recorderState={recorderState}
|
|
heading={_('Voice typing...')}
|
|
content={renderContent()}
|
|
preview={renderPreview()}
|
|
actions={actions}
|
|
/>;
|
|
};
|
|
|
|
export default connect((state: AppState) => ({
|
|
provider: state.settings['voiceTyping.preferredProvider'],
|
|
}))(SpeechToTextComponent);
|