chore(integration): snapshot local main worktree
This commit is contained in:
239
src/hooks/use-works-voice-input.ts
Normal file
239
src/hooks/use-works-voice-input.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { blobToBase64, convertAudioBlobTo16kMonoWav } from '@/lib/voice-recording';
|
||||
import { transcribeWorksSpeech, WorksSquareApiError } from '@/lib/works-square';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { getProfileAccountKey } from '@/stores/user-profile';
|
||||
|
||||
export type WorksVoiceInputState = 'idle' | 'recording' | 'transcribing';
|
||||
|
||||
interface UseWorksVoiceInputOptions {
|
||||
enabled: boolean;
|
||||
scopeKey: string | null;
|
||||
onTranscript(text: string): void;
|
||||
}
|
||||
|
||||
function isUnauthorizedWorksError(error: unknown): boolean {
|
||||
if (error instanceof WorksSquareApiError && error.statusCode === 401) return true;
|
||||
if (!(error instanceof Error)) return false;
|
||||
const record = error as Error & { code?: unknown; details?: { status?: unknown } };
|
||||
const message = error.message.toLowerCase();
|
||||
return record.details?.status === 401
|
||||
|| record.code === 'AUTH_INVALID'
|
||||
|| message.includes('401')
|
||||
|| message.includes('unauthorized')
|
||||
|| message.includes('invalid access token');
|
||||
}
|
||||
|
||||
function currentAuthAccountKey(): string | null {
|
||||
const user = useAuthStore.getState().user;
|
||||
return getProfileAccountKey(user?.userId ?? user?.username);
|
||||
}
|
||||
|
||||
export function useWorksVoiceInput({
|
||||
enabled,
|
||||
scopeKey,
|
||||
onTranscript,
|
||||
}: UseWorksVoiceInputOptions) {
|
||||
const accountKey = useAuthStore((state) => (
|
||||
getProfileAccountKey(state.user?.userId ?? state.user?.username)
|
||||
));
|
||||
const accessToken = useAuthStore((state) => state.accessToken);
|
||||
const getValidAccessToken = useAuthStore((state) => state.getValidAccessToken);
|
||||
const refreshSession = useAuthStore((state) => state.refreshSession);
|
||||
const [state, setState] = useState<WorksVoiceInputState>('idle');
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const operationRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
const accountKeyRef = useRef(accountKey);
|
||||
const scopeKeyRef = useRef(scopeKey);
|
||||
const enabledRef = useRef(enabled);
|
||||
const onTranscriptRef = useRef(onTranscript);
|
||||
|
||||
accountKeyRef.current = accountKey;
|
||||
scopeKeyRef.current = scopeKey;
|
||||
enabledRef.current = enabled;
|
||||
onTranscriptRef.current = onTranscript;
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
for (const track of streamRef.current?.getTracks() ?? []) track.stop();
|
||||
streamRef.current = null;
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
operationRef.current += 1;
|
||||
const recorder = recorderRef.current;
|
||||
if (recorder) {
|
||||
recorder.ondataavailable = null;
|
||||
recorder.onstop = null;
|
||||
if (recorder.state !== 'inactive') recorder.stop();
|
||||
recorderRef.current = null;
|
||||
}
|
||||
chunksRef.current = [];
|
||||
stopStream();
|
||||
if (mountedRef.current) setState('idle');
|
||||
}, [stopStream]);
|
||||
|
||||
useEffect(() => () => {
|
||||
mountedRef.current = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
cancel();
|
||||
}, [accountKey, cancel, enabled, scopeKey]);
|
||||
|
||||
const finishTranscription = useCallback(async (
|
||||
mimeType: string,
|
||||
operationId: number,
|
||||
requestedAccountKey: string,
|
||||
requestedScopeKey: string,
|
||||
) => {
|
||||
const isActive = () => mountedRef.current
|
||||
&& operationRef.current === operationId
|
||||
&& enabledRef.current
|
||||
&& accountKeyRef.current === requestedAccountKey
|
||||
&& scopeKeyRef.current === requestedScopeKey
|
||||
&& currentAuthAccountKey() === requestedAccountKey;
|
||||
const chunks = chunksRef.current;
|
||||
chunksRef.current = [];
|
||||
stopStream();
|
||||
|
||||
if (chunks.length === 0) {
|
||||
if (isActive()) {
|
||||
setState('idle');
|
||||
toast.warning('没有录到声音');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActive()) setState('transcribing');
|
||||
try {
|
||||
if (!isActive()) return;
|
||||
const validAccessToken = await getValidAccessToken();
|
||||
if (!isActive()) return;
|
||||
if (!validAccessToken) {
|
||||
toast.error('请先登录后再使用语音输入');
|
||||
return;
|
||||
}
|
||||
const audioBlob = new Blob(chunks, { type: mimeType || chunks[0]?.type || 'audio/webm' });
|
||||
const wavBlob = await convertAudioBlobTo16kMonoWav(audioBlob);
|
||||
if (!isActive()) return;
|
||||
const audioBase64 = await blobToBase64(wavBlob);
|
||||
if (!isActive()) return;
|
||||
const transcribeWithToken = (token: string) => transcribeWorksSpeech({
|
||||
accessToken: token,
|
||||
audioBase64,
|
||||
fileName: 'voice.wav',
|
||||
mimeType: 'audio/wav',
|
||||
language: 'zh',
|
||||
});
|
||||
let transcription;
|
||||
try {
|
||||
transcription = await transcribeWithToken(validAccessToken);
|
||||
if (!isActive()) return;
|
||||
} catch (error) {
|
||||
if (!isUnauthorizedWorksError(error)) throw error;
|
||||
if (!isActive()) return;
|
||||
const refreshedAccessToken = await refreshSession();
|
||||
if (!isActive()) return;
|
||||
if (!refreshedAccessToken) throw error;
|
||||
transcription = await transcribeWithToken(refreshedAccessToken);
|
||||
if (!isActive()) return;
|
||||
}
|
||||
const transcript = transcription.text.trim();
|
||||
if (transcript) onTranscriptRef.current(transcript);
|
||||
} catch (error) {
|
||||
if (isActive()) {
|
||||
toast.error('语音识别失败', {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (isActive()) {
|
||||
recorderRef.current = null;
|
||||
setState('idle');
|
||||
}
|
||||
}
|
||||
}, [getValidAccessToken, refreshSession, stopStream]);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (state === 'recording') {
|
||||
const recorder = recorderRef.current;
|
||||
if (!recorder || recorder.state === 'inactive') {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
setState('transcribing');
|
||||
recorder.stop();
|
||||
return;
|
||||
}
|
||||
if (!enabled || !scopeKey || state !== 'idle') return;
|
||||
if (!accountKey || !accessToken) {
|
||||
toast.error('请先登录后再使用语音输入');
|
||||
return;
|
||||
}
|
||||
if (!navigator.mediaDevices?.getUserMedia || typeof window.MediaRecorder === 'undefined') {
|
||||
toast.error('当前环境不支持语音输入');
|
||||
return;
|
||||
}
|
||||
|
||||
const operationId = operationRef.current + 1;
|
||||
operationRef.current = operationId;
|
||||
const requestedAccountKey = accountKey;
|
||||
const requestedScopeKey = scopeKey;
|
||||
const isActive = () => mountedRef.current
|
||||
&& operationRef.current === operationId
|
||||
&& enabledRef.current
|
||||
&& accountKeyRef.current === requestedAccountKey
|
||||
&& scopeKeyRef.current === requestedScopeKey
|
||||
&& currentAuthAccountKey() === requestedAccountKey;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (!isActive()) {
|
||||
for (const track of stream.getTracks()) track.stop();
|
||||
return;
|
||||
}
|
||||
streamRef.current = stream;
|
||||
const preferredMimeType = window.MediaRecorder.isTypeSupported?.('audio/webm')
|
||||
? 'audio/webm'
|
||||
: '';
|
||||
const recorder = new window.MediaRecorder(
|
||||
stream,
|
||||
preferredMimeType ? { mimeType: preferredMimeType } : undefined,
|
||||
);
|
||||
chunksRef.current = [];
|
||||
recorderRef.current = recorder;
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (isActive() && event.data.size > 0) chunksRef.current.push(event.data);
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
void finishTranscription(
|
||||
recorder.mimeType || preferredMimeType || 'audio/webm',
|
||||
operationId,
|
||||
requestedAccountKey,
|
||||
requestedScopeKey,
|
||||
);
|
||||
};
|
||||
recorder.start();
|
||||
setState('recording');
|
||||
} catch (error) {
|
||||
stopStream();
|
||||
recorderRef.current = null;
|
||||
chunksRef.current = [];
|
||||
if (isActive()) {
|
||||
setState('idle');
|
||||
toast.error('无法开始录音', {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [accessToken, accountKey, cancel, enabled, finishTranscription, scopeKey, state, stopStream]);
|
||||
|
||||
const supported = typeof window.MediaRecorder !== 'undefined'
|
||||
&& Boolean(navigator.mediaDevices?.getUserMedia);
|
||||
const canToggle = state === 'recording' || (enabled && Boolean(scopeKey) && state === 'idle');
|
||||
|
||||
return { canToggle, state, supported, toggle };
|
||||
}
|
||||
Reference in New Issue
Block a user