mirror of
https://github.com/langgenius/dify.git
synced 2026-07-23 20:18:40 +08:00
266 lines
9.4 KiB
TypeScript
266 lines
9.4 KiB
TypeScript
import type { Ref } from 'react'
|
|
import type { VoiceRecorder } from './recorder'
|
|
import type { SpeechToTextTarget } from './types'
|
|
import { cn } from '@langgenius/dify-ui/cn'
|
|
import { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { transcribeAudio } from './api'
|
|
import s from './index.module.css'
|
|
import { startVoiceRecorder } from './recorder'
|
|
|
|
const MAX_RECORDING_DURATION = 600
|
|
|
|
type VoiceInputStatus = 'starting' | 'recording' | 'converting'
|
|
|
|
type VoiceInputProps = {
|
|
ref?: Ref<HTMLDivElement>
|
|
onConverted: (text: string) => void
|
|
onCancel: () => void
|
|
onBeforeTranscribe?: () => Promise<unknown>
|
|
onError?: () => void
|
|
onStartError?: (error: unknown) => void
|
|
target: SpeechToTextTarget
|
|
}
|
|
|
|
function VoiceInput({
|
|
ref,
|
|
onCancel,
|
|
onBeforeTranscribe,
|
|
onConverted,
|
|
onError,
|
|
onStartError,
|
|
target,
|
|
}: VoiceInputProps) {
|
|
const { t } = useTranslation()
|
|
const recorderRef = useRef<VoiceRecorder | null>(null)
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
|
const canvasContextRef = useRef<CanvasRenderingContext2D | null>(null)
|
|
const canvasSizeRef = useRef({ height: 0, width: 0 })
|
|
const drawRecordIdRef = useRef<number | null>(null)
|
|
const mountedRef = useRef(true)
|
|
const stopRequestedRef = useRef(false)
|
|
const cancelledRef = useRef(false)
|
|
const setupAbortControllerRef = useRef<AbortController | null>(null)
|
|
const transcriptionAbortControllerRef = useRef<AbortController | null>(null)
|
|
const [duration, setDuration] = useState(0)
|
|
const [status, setStatus] = useState<VoiceInputStatus>('starting')
|
|
const handleRecorderStartError = useEffectEvent((error: unknown) => {
|
|
onStartError?.(error)
|
|
onCancel()
|
|
})
|
|
|
|
const stopDrawing = useCallback(() => {
|
|
if (drawRecordIdRef.current !== null) cancelAnimationFrame(drawRecordIdRef.current)
|
|
drawRecordIdRef.current = null
|
|
const { height, width } = canvasSizeRef.current
|
|
canvasContextRef.current?.clearRect(0, 0, width, height)
|
|
}, [])
|
|
|
|
const drawRecord = useCallback(() => {
|
|
const recorder = recorderRef.current
|
|
const context = canvasContextRef.current
|
|
const { height, width } = canvasSizeRef.current
|
|
if (!recorder || !context || !height || !width) return
|
|
|
|
const frequencyData = new Uint8Array(recorder.analyser.frequencyBinCount)
|
|
recorder.analyser.getByteFrequencyData(frequencyData)
|
|
const lineCount = Math.max(1, Math.floor(width / 3))
|
|
const sampleStep = Math.max(1, Math.floor(frequencyData.length / lineCount))
|
|
|
|
context.clearRect(0, 0, width, height)
|
|
context.beginPath()
|
|
for (let index = 0; index < lineCount; index++) {
|
|
const amplitude = frequencyData[index * sampleStep] ?? 0
|
|
const lineHeight = Math.max(1, (amplitude / 255) * height)
|
|
const x = index * 3
|
|
if (context.roundRect) context.roundRect(x, height - lineHeight, 2, lineHeight, [1, 1, 0, 0])
|
|
else context.rect(x, height - lineHeight, 2, lineHeight)
|
|
}
|
|
context.fill()
|
|
context.closePath()
|
|
drawRecordIdRef.current = requestAnimationFrame(drawRecord)
|
|
}, [])
|
|
|
|
const handleStopRecorder = useCallback(async () => {
|
|
const recorder = recorderRef.current
|
|
if (!recorder || status !== 'recording' || stopRequestedRef.current) return
|
|
|
|
stopRequestedRef.current = true
|
|
setStatus('converting')
|
|
stopDrawing()
|
|
let mp3Blob: Blob
|
|
try {
|
|
mp3Blob = await recorder.stop()
|
|
} catch {
|
|
if (mountedRef.current && !cancelledRef.current) {
|
|
onError?.()
|
|
onCancel()
|
|
}
|
|
return
|
|
}
|
|
if (cancelledRef.current) return
|
|
|
|
try {
|
|
await onBeforeTranscribe?.()
|
|
} catch {
|
|
if (mountedRef.current && !cancelledRef.current) onCancel()
|
|
return
|
|
}
|
|
if (cancelledRef.current) return
|
|
|
|
const file = new File([mp3Blob], 'temp.mp3', { type: 'audio/mp3' })
|
|
const abortController = new AbortController()
|
|
transcriptionAbortControllerRef.current = abortController
|
|
try {
|
|
const audioResponse = await transcribeAudio(target, file, abortController.signal)
|
|
if (mountedRef.current && !cancelledRef.current) onConverted(audioResponse.text)
|
|
} catch {
|
|
if (mountedRef.current && !cancelledRef.current) onError?.()
|
|
} finally {
|
|
transcriptionAbortControllerRef.current = null
|
|
if (mountedRef.current && !cancelledRef.current) onCancel()
|
|
}
|
|
}, [onBeforeTranscribe, onCancel, onConverted, onError, status, stopDrawing, target])
|
|
|
|
const handleCancel = useCallback(() => {
|
|
cancelledRef.current = true
|
|
setupAbortControllerRef.current?.abort()
|
|
transcriptionAbortControllerRef.current?.abort()
|
|
void recorderRef.current?.cancel()
|
|
onCancel()
|
|
}, [onCancel])
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
const devicePixelRatio = window.devicePixelRatio || 1
|
|
const { height, width } = canvas.getBoundingClientRect()
|
|
canvas.width = devicePixelRatio * width
|
|
canvas.height = devicePixelRatio * height
|
|
canvasSizeRef.current = { height, width }
|
|
const context = canvas.getContext('2d')
|
|
if (!context) return
|
|
context.scale(devicePixelRatio, devicePixelRatio)
|
|
context.fillStyle = 'rgba(209, 224, 255, 1)'
|
|
canvasContextRef.current = context
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true
|
|
cancelledRef.current = false
|
|
let effectCancelled = false
|
|
const abortController = new AbortController()
|
|
setupAbortControllerRef.current = abortController
|
|
void startVoiceRecorder(abortController.signal)
|
|
.then((recorder) => {
|
|
if (setupAbortControllerRef.current === abortController)
|
|
setupAbortControllerRef.current = null
|
|
if (effectCancelled) {
|
|
void recorder.cancel()
|
|
return
|
|
}
|
|
recorderRef.current = recorder
|
|
setStatus('recording')
|
|
drawRecord()
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (setupAbortControllerRef.current === abortController)
|
|
setupAbortControllerRef.current = null
|
|
if (effectCancelled || abortController.signal.aborted) return
|
|
handleRecorderStartError(error)
|
|
})
|
|
|
|
return () => {
|
|
effectCancelled = true
|
|
mountedRef.current = false
|
|
cancelledRef.current = true
|
|
abortController.abort()
|
|
transcriptionAbortControllerRef.current?.abort()
|
|
stopDrawing()
|
|
void recorderRef.current?.cancel()
|
|
}
|
|
}, [drawRecord, stopDrawing])
|
|
|
|
useEffect(() => {
|
|
if (status !== 'recording') return
|
|
const intervalId = window.setInterval(() => {
|
|
setDuration((currentDuration) => currentDuration + 1)
|
|
}, 1000)
|
|
return () => window.clearInterval(intervalId)
|
|
}, [status])
|
|
|
|
useEffect(() => {
|
|
if (duration >= MAX_RECORDING_DURATION && status === 'recording') void handleStopRecorder()
|
|
}, [duration, handleStopRecorder, status])
|
|
|
|
const minutes = Math.floor(duration / 60)
|
|
.toString()
|
|
.padStart(2, '0')
|
|
const seconds = (duration % 60).toString().padStart(2, '0')
|
|
const isStarting = status === 'starting'
|
|
const isRecording = status === 'recording'
|
|
const isConverting = status === 'converting'
|
|
|
|
return (
|
|
<div ref={ref} className={cn(s.wrapper, 'absolute inset-0 rounded-xl')}>
|
|
<div className="absolute inset-[1.5px] flex items-center overflow-hidden rounded-[10.5px] bg-primary-25 py-[14px] pr-[6.5px] pl-[14.5px]">
|
|
<canvas ref={canvasRef} className="absolute bottom-0 left-0 h-4 w-full" />
|
|
{(isStarting || isConverting) && (
|
|
<div
|
|
className="mr-2 i-ri-loader-2-line size-4 animate-spin text-primary-700"
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
<div className="grow">
|
|
{isStarting && (
|
|
<div className="text-sm text-gray-500" role="status">
|
|
{t(($) => $['voiceInput.starting'], { ns: 'common' })}
|
|
</div>
|
|
)}
|
|
{isRecording && (
|
|
<div className="text-sm text-gray-500">
|
|
{t(($) => $['voiceInput.speaking'], { ns: 'common' })}
|
|
</div>
|
|
)}
|
|
{isConverting && (
|
|
<div className={cn(s.convert, 'text-sm')} role="status">
|
|
{t(($) => $['voiceInput.converting'], { ns: 'common' })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isRecording && (
|
|
<button
|
|
type="button"
|
|
className="mr-1 flex size-8 items-center justify-center rounded-lg outline-hidden hover:bg-primary-100 focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
|
aria-label={t(($) => $['voiceInput.stop'], { ns: 'common' })}
|
|
onClick={handleStopRecorder}
|
|
>
|
|
<span className="i-ri-stop-circle-line size-5 text-primary-600" aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
{(isStarting || isConverting) && (
|
|
<button
|
|
type="button"
|
|
className="mr-1 flex size-8 items-center justify-center rounded-lg outline-hidden hover:bg-gray-200 focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
|
aria-label={t(($) => $['operation.cancel'], { ns: 'common' })}
|
|
onClick={handleCancel}
|
|
>
|
|
<span className="i-ri-close-line size-4 text-gray-500" aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
'w-[45px] pl-1 text-xs font-medium',
|
|
duration > 500 ? 'text-text-destructive' : 'text-text-secondary',
|
|
)}
|
|
data-testid="voice-input-timer"
|
|
>
|
|
{`${minutes}:${seconds}`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default VoiceInput
|