Initial commit
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
.wrapper {
|
||||
background: linear-gradient(131deg, #2250F2 0%, #0EBCF3 100%);
|
||||
box-shadow: 0px 4px 6px -2px rgba(16, 24, 40, 0.03), 0px 12px 16px -4px rgba(16, 24, 40, 0.08);
|
||||
}
|
||||
|
||||
.convert {
|
||||
background: linear-gradient(91.92deg, #104AE1 -1.74%, #0098EE 75.74%);
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams, usePathname } from 'next/navigation'
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiLoader2Line,
|
||||
} from '@remixicon/react'
|
||||
import Recorder from 'js-audio-recorder'
|
||||
import { useRafInterval } from 'ahooks'
|
||||
import { convertToMp3 } from './utils'
|
||||
import s from './index.module.css'
|
||||
import cn from '@/utils/classnames'
|
||||
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
|
||||
import { audioToText } from '@/service/index'
|
||||
|
||||
type VoiceInputTypes = {
|
||||
onConverted: (text: string) => void
|
||||
onCancel: () => void
|
||||
wordTimestamps?: string
|
||||
}
|
||||
|
||||
const VoiceInput = ({
|
||||
onCancel,
|
||||
onConverted,
|
||||
wordTimestamps,
|
||||
}: VoiceInputTypes) => {
|
||||
const { t } = useTranslation()
|
||||
const recorder = useRef(new Recorder({
|
||||
sampleBits: 16,
|
||||
sampleRate: 16000,
|
||||
numChannels: 1,
|
||||
compiling: false,
|
||||
}))
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const ctxRef = useRef<CanvasRenderingContext2D | null>(null)
|
||||
const drawRecordId = useRef<number | null>(null)
|
||||
const [originDuration, setOriginDuration] = useState(0)
|
||||
const [startRecord, setStartRecord] = useState(false)
|
||||
const [startConvert, setStartConvert] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const params = useParams()
|
||||
const clearInterval = useRafInterval(() => {
|
||||
setOriginDuration(originDuration + 1)
|
||||
}, 1000)
|
||||
|
||||
const drawRecord = useCallback(() => {
|
||||
drawRecordId.current = requestAnimationFrame(drawRecord)
|
||||
const canvas = canvasRef.current!
|
||||
const ctx = ctxRef.current!
|
||||
const dataUnit8Array = recorder.current.getRecordAnalyseData()
|
||||
const dataArray = [].slice.call(dataUnit8Array)
|
||||
const lineLength = parseInt(`${canvas.width / 3}`)
|
||||
const gap = parseInt(`${1024 / lineLength}`)
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.beginPath()
|
||||
let x = 0
|
||||
for (let i = 0; i < lineLength; i++) {
|
||||
let v = dataArray.slice(i * gap, i * gap + gap).reduce((prev: number, next: number) => {
|
||||
return prev + next
|
||||
}, 0) / gap
|
||||
|
||||
if (v < 128)
|
||||
v = 128
|
||||
if (v > 178)
|
||||
v = 178
|
||||
const y = (v - 128) / 50 * canvas.height
|
||||
|
||||
ctx.moveTo(x, 16)
|
||||
if (ctx.roundRect)
|
||||
ctx.roundRect(x, 16 - y, 2, y, [1, 1, 0, 0])
|
||||
else
|
||||
ctx.rect(x, 16 - y, 2, y)
|
||||
ctx.fill()
|
||||
x += 3
|
||||
}
|
||||
ctx.closePath()
|
||||
}, [])
|
||||
const handleStopRecorder = useCallback(async () => {
|
||||
clearInterval()
|
||||
setStartRecord(false)
|
||||
setStartConvert(true)
|
||||
recorder.current.stop()
|
||||
drawRecordId.current && cancelAnimationFrame(drawRecordId.current)
|
||||
drawRecordId.current = null
|
||||
const canvas = canvasRef.current!
|
||||
const ctx = ctxRef.current!
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
const mp3Blob = convertToMp3(recorder.current)
|
||||
const mp3File = new File([mp3Blob], 'temp.mp3', { type: 'audio/mp3' })
|
||||
const formData = new FormData()
|
||||
formData.append('file', mp3File)
|
||||
formData.append('word_timestamps', wordTimestamps || 'disabled')
|
||||
try {
|
||||
const audioResponse = await audioToText(formData)
|
||||
onConverted(audioResponse.text)
|
||||
onCancel()
|
||||
}
|
||||
catch (e) {
|
||||
onConverted('')
|
||||
onCancel()
|
||||
}
|
||||
}, [clearInterval, onCancel, onConverted, params.appId, params.token, pathname, wordTimestamps])
|
||||
const handleStartRecord = async () => {
|
||||
try {
|
||||
await recorder.current.start()
|
||||
setStartRecord(true)
|
||||
setStartConvert(false)
|
||||
|
||||
if (canvasRef.current && ctxRef.current)
|
||||
drawRecord()
|
||||
}
|
||||
catch (e) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
const initCanvas = () => {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const canvas = document.getElementById('voice-input-record') as HTMLCanvasElement
|
||||
|
||||
if (canvas) {
|
||||
const { width: cssWidth, height: cssHeight } = canvas.getBoundingClientRect()
|
||||
|
||||
canvas.width = dpr * cssWidth
|
||||
canvas.height = dpr * cssHeight
|
||||
canvasRef.current = canvas
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.fillStyle = 'rgba(209, 224, 255, 1)'
|
||||
ctxRef.current = ctx
|
||||
}
|
||||
}
|
||||
}
|
||||
if (originDuration >= 600 && startRecord)
|
||||
handleStopRecorder()
|
||||
|
||||
useEffect(() => {
|
||||
initCanvas()
|
||||
handleStartRecord()
|
||||
const recorderRef = recorder?.current
|
||||
return () => {
|
||||
recorderRef?.stop()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const minutes = parseInt(`${parseInt(`${originDuration}`) / 60}`)
|
||||
const seconds = parseInt(`${originDuration}`) % 60
|
||||
|
||||
return (
|
||||
<div className={cn(s.wrapper, 'absolute inset-0 rounded-xl')}>
|
||||
<div className='absolute inset-[1.5px] flex items-center pl-[14.5px] pr-[6.5px] py-[14px] bg-primary-25 rounded-[10.5px] overflow-hidden'>
|
||||
<canvas id='voice-input-record' className='absolute left-0 bottom-0 w-full h-4' />
|
||||
{
|
||||
startConvert && <RiLoader2Line className='animate-spin mr-2 w-4 h-4 text-primary-700' />
|
||||
}
|
||||
<div className='grow'>
|
||||
{
|
||||
startRecord && (
|
||||
<div className='text-sm text-gray-500'>
|
||||
{t('common.voiceInput.speaking')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
startConvert && (
|
||||
<div className={cn(s.convert, 'text-sm')}>
|
||||
{t('common.voiceInput.converting')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
startRecord && (
|
||||
<div
|
||||
className='flex justify-center items-center mr-1 w-8 h-8 hover:bg-primary-100 rounded-lg cursor-pointer'
|
||||
onClick={handleStopRecorder}
|
||||
>
|
||||
<StopCircle className='w-5 h-5 text-primary-600' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
startConvert && (
|
||||
<div
|
||||
className='flex justify-center items-center mr-1 w-8 h-8 hover:bg-gray-200 rounded-lg cursor-pointer'
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className='w-4 h-4 text-gray-500' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className={`w-[45px] pl-1 text-xs font-medium ${originDuration > 500 ? 'text-[#F04438]' : 'text-gray-700'}`}>{`0${minutes.toFixed(0)}:${seconds >= 10 ? seconds : `0${seconds}`}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceInput
|
||||
@@ -0,0 +1,47 @@
|
||||
import lamejs from 'lamejs'
|
||||
import MPEGMode from 'lamejs/src/js/MPEGMode'
|
||||
import Lame from 'lamejs/src/js/Lame'
|
||||
import BitStream from 'lamejs/src/js/BitStream'
|
||||
|
||||
if (globalThis) {
|
||||
(globalThis as any).MPEGMode = MPEGMode
|
||||
; (globalThis as any).Lame = Lame
|
||||
; (globalThis as any).BitStream = BitStream
|
||||
}
|
||||
|
||||
export const convertToMp3 = (recorder: any) => {
|
||||
const wav = lamejs.WavHeader.readHeader(recorder.getWAV())
|
||||
const { channels, sampleRate } = wav
|
||||
const mp3enc = new lamejs.Mp3Encoder(channels, sampleRate, 128)
|
||||
const result = recorder.getChannelData()
|
||||
const buffer = []
|
||||
|
||||
const leftData = result.left && new Int16Array(result.left.buffer, 0, result.left.byteLength / 2)
|
||||
const rightData = result.right && new Int16Array(result.right.buffer, 0, result.right.byteLength / 2)
|
||||
const remaining = leftData.length + (rightData ? rightData.length : 0)
|
||||
|
||||
const maxSamples = 1152
|
||||
for (let i = 0; i < remaining; i += maxSamples) {
|
||||
const left = leftData.subarray(i, i + maxSamples)
|
||||
let right = null
|
||||
let mp3buf = null
|
||||
|
||||
if (channels === 2) {
|
||||
right = rightData.subarray(i, i + maxSamples)
|
||||
mp3buf = mp3enc.encodeBuffer(left, right)
|
||||
}
|
||||
else {
|
||||
mp3buf = mp3enc.encodeBuffer(left)
|
||||
}
|
||||
|
||||
if (mp3buf.length > 0)
|
||||
buffer.push(mp3buf)
|
||||
}
|
||||
|
||||
const enc = mp3enc.flush()
|
||||
|
||||
if (enc.length > 0)
|
||||
buffer.push(enc)
|
||||
|
||||
return new Blob(buffer, { type: 'audio/mp3' })
|
||||
}
|
||||
Reference in New Issue
Block a user