Compare commits

...

3 Commits

4 changed files with 106 additions and 29 deletions

View File

@ -853,7 +853,7 @@
},
"web/app/components/base/chat/chat/chat-input-area/index.tsx": {
"ts/no-explicit-any": {
"count": 3
"count": 2
}
},
"web/app/components/base/chat/chat/check-input-forms-hooks.ts": {

View File

@ -6,7 +6,6 @@ import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { noop } from 'es-toolkit/function'
import { decode } from 'html-entities'
import Recorder from 'js-audio-recorder'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from 'react-textarea-autosize'
@ -14,12 +13,18 @@ import FeatureBar from '@/app/components/base/features/new-feature-panel/feature
import { FileListInChatInput } from '@/app/components/base/file-uploader'
import { useFile } from '@/app/components/base/file-uploader/hooks'
import { FileContextProvider, useFileStore } from '@/app/components/base/file-uploader/store'
import VoiceInput from '@/app/components/base/voice-input'
import dynamic from '@/next/dynamic'
import { TransferMethod } from '@/types/app'
import { useCheckInputsForms } from '../check-input-forms-hooks'
import { useTextAreaHeight } from './hooks'
import Operation from './operation'
const VoiceInput = dynamic(() => import('@/app/components/base/voice-input'), { ssr: false })
type RecorderConstructorWithPermission = typeof import('js-audio-recorder').default & {
getPermission: () => Promise<void>
}
type ChatInputAreaProps = {
readonly?: boolean
botName?: string
@ -128,12 +133,16 @@ const ChatInputArea = ({ readonly, botName, showFeatureBar, showFileUpload, feat
}
}
}
const handleShowVoiceInput = useCallback(() => {
(Recorder as any).getPermission().then(() => {
const handleShowVoiceInput = useCallback(async () => {
const { default: Recorder } = await import('js-audio-recorder')
try {
await (Recorder as RecorderConstructorWithPermission).getPermission()
setShowVoiceInput(true)
}, () => {
}
catch {
toast.error(t('voiceInput.notAllow', { ns: 'common' }))
})
}
}, [t])
const operation = (<Operation ref={holdSpaceRef} readonly={readonly} fileConfig={visionConfig} speechToTextConfig={speechToTextConfig} onShowVoiceInput={handleShowVoiceInput} onSend={handleSend} theme={theme} />)
return (

View File

@ -98,6 +98,9 @@ describe('VoiceInput', () => {
it('should start recording on mount and show speaking state', async () => {
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
await waitFor(() => {
expect(mockState.recorderInstances).toHaveLength(1)
})
// eslint-disable-next-line ts/no-explicit-any
const recorder = mockState.recorderInstances[0] as any
expect(recorder.start).toHaveBeenCalled()
@ -390,8 +393,11 @@ describe('VoiceInput', () => {
expect(await screen.findByText('common.voiceInput.speaking'))!.toBeInTheDocument()
})
it('should cleanup on unmount', () => {
it('should cleanup on unmount', async () => {
const { unmount } = render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
await waitFor(() => {
expect(mockState.recorderInstances).toHaveLength(1)
})
// eslint-disable-next-line ts/no-explicit-any
const recorder = mockState.recorderInstances[0] as any
@ -400,6 +406,31 @@ describe('VoiceInput', () => {
expect(recorder.stop).toHaveBeenCalled()
})
it('should stop without cancelling after unmount while recording start is pending', async () => {
let resolveStart!: () => void
mockState.startOverride = () => new Promise<void>((resolve) => {
resolveStart = resolve
})
const { unmount } = render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
await waitFor(() => {
expect(mockState.recorderInstances).toHaveLength(1)
})
// eslint-disable-next-line ts/no-explicit-any
const recorder = mockState.recorderInstances[0] as any
unmount()
expect(recorder.stop).toHaveBeenCalledTimes(1)
await act(async () => {
resolveStart()
await Promise.resolve()
})
expect(recorder.stop).toHaveBeenCalledTimes(2)
expect(onCancel).not.toHaveBeenCalled()
})
it('should handle all data in recordAnalyseData for canvas drawing', async () => {
const allDataValues = []
for (let i = 0; i < 256; i++) {

View File

@ -1,12 +1,13 @@
'use client'
import type Recorder from 'js-audio-recorder'
import { cn } from '@langgenius/dify-ui/cn'
import { useRafInterval } from 'ahooks'
import Recorder from 'js-audio-recorder'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams, usePathname } from '@/next/navigation'
import { AppSourceType, audioToText } from '@/service/share'
import s from './index.module.css'
import { convertToMp3 } from './utils'
type VoiceInputTypes = {
onConverted: (text: string) => void
@ -20,15 +21,11 @@ const VoiceInput = ({
wordTimestamps,
}: VoiceInputTypes) => {
const { t } = useTranslation()
const recorder = useRef(new Recorder({
sampleBits: 16,
sampleRate: 16000,
numChannels: 1,
compiling: false,
}))
const recorderRef = useRef<Recorder | null>(null)
const mountedRef = useRef(false)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const ctxRef = useRef<CanvasRenderingContext2D | null>(null)
const drawRecordId = useRef<number | null>(null)
const drawRecordIdRef = useRef<number | null>(null)
const [originDuration, setOriginDuration] = useState(0)
const [startRecord, setStartRecord] = useState(false)
const [startConvert, setStartConvert] = useState(false)
@ -38,11 +35,29 @@ const VoiceInput = ({
setOriginDuration(originDuration + 1)
}, 1000)
const getRecorder = useCallback(async () => {
if (!recorderRef.current) {
const { default: Recorder } = await import('js-audio-recorder')
recorderRef.current = new Recorder({
sampleBits: 16,
sampleRate: 16000,
numChannels: 1,
compiling: false,
})
}
return recorderRef.current
}, [])
const drawRecord = useCallback(() => {
drawRecordId.current = requestAnimationFrame(drawRecord)
drawRecordIdRef.current = requestAnimationFrame(drawRecord)
const canvas = canvasRef.current!
const ctx = ctxRef.current!
const dataUnit8Array = recorder.current.getRecordAnalyseData()
const currentRecorder = recorderRef.current
if (!currentRecorder)
return
const dataUnit8Array = currentRecorder.getRecordAnalyseData()
const dataArray = [].slice.call(dataUnit8Array)
const lineLength = Number.parseInt(`${canvas.width / 3}`)
const gap = Number.parseInt(`${1024 / lineLength}`)
@ -72,17 +87,22 @@ const VoiceInput = ({
ctx.closePath()
}, [])
const handleStopRecorder = useCallback(async () => {
const currentRecorder = recorderRef.current
if (!currentRecorder)
return
clearInterval()
setStartRecord(false)
setStartConvert(true)
recorder.current.stop()
if (drawRecordId.current)
cancelAnimationFrame(drawRecordId.current)
drawRecordId.current = null
currentRecorder.stop()
if (drawRecordIdRef.current)
cancelAnimationFrame(drawRecordIdRef.current)
drawRecordIdRef.current = null
const canvas = canvasRef.current!
const ctx = ctxRef.current!
ctx.clearRect(0, 0, canvas.width, canvas.height)
const mp3Blob = convertToMp3(recorder.current)
const { convertToMp3 } = await import('./utils')
const mp3Blob = convertToMp3(currentRecorder)
const mp3File = new File([mp3Blob], 'temp.mp3', { type: 'audio/mp3' })
const formData = new FormData()
formData.append('file', mp3File)
@ -114,7 +134,20 @@ const VoiceInput = ({
}, [clearInterval, onCancel, onConverted, params.appId, params.token, pathname, wordTimestamps])
const handleStartRecord = useCallback(async () => {
try {
await recorder.current.start()
const currentRecorder = await getRecorder()
if (!mountedRef.current) {
currentRecorder.stop()
return
}
await currentRecorder.start()
if (!mountedRef.current) {
currentRecorder.stop()
return
}
setStartRecord(true)
setStartConvert(false)
@ -122,9 +155,10 @@ const VoiceInput = ({
drawRecord()
}
catch {
onCancel()
if (mountedRef.current)
onCancel()
}
}, [drawRecord, onCancel, setStartRecord, setStartConvert])
}, [drawRecord, getRecorder, onCancel])
const initCanvas = useCallback(() => {
const dpr = window.devicePixelRatio || 1
const canvas = document.getElementById('voice-input-record') as HTMLCanvasElement
@ -148,11 +182,14 @@ const VoiceInput = ({
handleStopRecorder()
useEffect(() => {
mountedRef.current = true
initCanvas()
handleStartRecord()
const recorderRef = recorder?.current
return () => {
recorderRef?.stop()
mountedRef.current = false
if (drawRecordIdRef.current)
cancelAnimationFrame(drawRecordIdRef.current)
recorderRef.current?.stop()
}
}, [handleStartRecord, initCanvas])