MedRAG / frontend /src /components /chat /ChatInput.jsx
hetsheta's picture
Initial commit
ea50fb7
Raw
History Blame Contribute Delete
15.3 kB
import { useEffect, useRef, useState } from 'react'
import { LoaderCircle, Mic, Paperclip, Plus, Send, Square } from 'lucide-react'
import clsx from 'clsx'
import toast from 'react-hot-toast'
import { audioApi, getApiErrorMessage } from '../../api/client'
const SAMPLE_RATE = 16000
const MIN_RECORDING_SECONDS = 0.5
const TARGET_PEAK = 0.92
const LOW_INPUT_LEVEL = 0.015
function floatTo16BitPCM(view, offset, input) {
for (let i = 0; i < input.length; i += 1) {
const sample = Math.max(-1, Math.min(1, input[i]))
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true)
offset += 2
}
}
function encodeWav(samples, sampleRate) {
const buffer = new ArrayBuffer(44 + samples.length * 2)
const view = new DataView(buffer)
const writeString = (offset, value) => {
for (let i = 0; i < value.length; i += 1) {
view.setUint8(offset + i, value.charCodeAt(i))
}
}
writeString(0, 'RIFF')
view.setUint32(4, 36 + samples.length * 2, true)
writeString(8, 'WAVE')
writeString(12, 'fmt ')
view.setUint32(16, 16, true)
view.setUint16(20, 1, true)
view.setUint16(22, 1, true)
view.setUint32(24, sampleRate, true)
view.setUint32(28, sampleRate * 2, true)
view.setUint16(32, 2, true)
view.setUint16(34, 16, true)
writeString(36, 'data')
view.setUint32(40, samples.length * 2, true)
floatTo16BitPCM(view, 44, samples)
return new Blob([buffer], { type: 'audio/wav' })
}
function downsampleBuffer(buffer, inputSampleRate, outputSampleRate) {
if (outputSampleRate >= inputSampleRate) return buffer
const sampleRateRatio = inputSampleRate / outputSampleRate
const newLength = Math.round(buffer.length / sampleRateRatio)
const result = new Float32Array(newLength)
let offsetResult = 0
let offsetBuffer = 0
while (offsetResult < result.length) {
const nextOffsetBuffer = Math.round((offsetResult + 1) * sampleRateRatio)
let accum = 0
let count = 0
for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i += 1) {
accum += buffer[i]
count += 1
}
result[offsetResult] = count > 0 ? accum / count : 0
offsetResult += 1
offsetBuffer = nextOffsetBuffer
}
return result
}
function normalizeSamples(samples, targetPeak = TARGET_PEAK) {
let peak = 0
for (let i = 0; i < samples.length; i += 1) {
peak = Math.max(peak, Math.abs(samples[i]))
}
if (peak === 0) return samples
const gain = Math.min(targetPeak / peak, 8)
if (gain <= 1.05) return samples
const normalized = new Float32Array(samples.length)
for (let i = 0; i < samples.length; i += 1) {
normalized[i] = Math.max(-1, Math.min(1, samples[i] * gain))
}
return normalized
}
function getPeak(samples) {
let peak = 0
for (let i = 0; i < samples.length; i += 1) {
peak = Math.max(peak, Math.abs(samples[i]))
}
return peak
}
function mixToMono(audioBuffer) {
const channelCount = audioBuffer.numberOfChannels
if (channelCount === 1) return new Float32Array(audioBuffer.getChannelData(0))
const mono = new Float32Array(audioBuffer.length)
for (let channel = 0; channel < channelCount; channel += 1) {
const data = audioBuffer.getChannelData(channel)
for (let i = 0; i < data.length; i += 1) {
mono[i] += data[i] / channelCount
}
}
return mono
}
async function decodeBlobToMonoWav(blob) {
const AudioContextClass = window.AudioContext || window.webkitAudioContext
const audioContext = new AudioContextClass()
try {
if (audioContext.state === 'suspended') await audioContext.resume()
const arrayBuffer = await blob.arrayBuffer()
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
const mono = mixToMono(audioBuffer)
const downsampled = downsampleBuffer(mono, audioBuffer.sampleRate, SAMPLE_RATE)
const peakBefore = getPeak(downsampled)
const normalized = normalizeSamples(downsampled)
return {
wavBlob: encodeWav(normalized, SAMPLE_RATE),
durationSeconds: normalized.length / SAMPLE_RATE,
sampleCount: normalized.length,
peakBefore,
}
} finally {
if (audioContext.state !== 'closed') await audioContext.close()
}
}
export default function ChatInput({ onSend, onUploadClick, isLoading, disabled, disabledReason }) {
const [text, setText] = useState('')
const [isRecording, setIsRecording] = useState(false)
const [isTranscribing, setIsTranscribing] = useState(false)
const [inputLevel, setInputLevel] = useState(0)
const textareaRef = useRef(null)
const streamRef = useRef(null)
const mediaRecorderRef = useRef(null)
const chunksRef = useRef([])
const monitorAudioContextRef = useRef(null)
const analyserRef = useRef(null)
const monitorFrameRef = useRef(null)
useEffect(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 200) + 'px'
}, [text])
useEffect(() => () => {
if (mediaRecorderRef.current?.state && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop()
}
if (monitorFrameRef.current) cancelAnimationFrame(monitorFrameRef.current)
if (monitorAudioContextRef.current && monitorAudioContextRef.current.state !== 'closed') {
monitorAudioContextRef.current.close().catch(() => {})
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop())
}
}, [])
const submit = () => {
const q = text.trim()
if (!q || isLoading || disabled || isRecording || isTranscribing) return
onSend(q)
setText('')
if (textareaRef.current) textareaRef.current.style.height = 'auto'
}
const handleKey = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
const stopInputMonitor = async () => {
if (monitorFrameRef.current) {
cancelAnimationFrame(monitorFrameRef.current)
monitorFrameRef.current = null
}
analyserRef.current = null
if (monitorAudioContextRef.current && monitorAudioContextRef.current.state !== 'closed') {
await monitorAudioContextRef.current.close()
}
monitorAudioContextRef.current = null
setInputLevel(0)
}
const stopStream = async () => {
await stopInputMonitor()
if (!streamRef.current) return
streamRef.current.getTracks().forEach((track) => track.stop())
streamRef.current = null
}
const startInputMonitor = async (stream) => {
const AudioContextClass = window.AudioContext || window.webkitAudioContext
const audioContext = new AudioContextClass()
if (audioContext.state === 'suspended') await audioContext.resume()
const source = audioContext.createMediaStreamSource(stream)
const analyser = audioContext.createAnalyser()
analyser.fftSize = 2048
source.connect(analyser)
monitorAudioContextRef.current = audioContext
analyserRef.current = analyser
const buffer = new Float32Array(analyser.fftSize)
const tick = () => {
if (!analyserRef.current) return
analyserRef.current.getFloatTimeDomainData(buffer)
let sumSquares = 0
for (let i = 0; i < buffer.length; i += 1) {
sumSquares += buffer[i] * buffer[i]
}
const rms = Math.sqrt(sumSquares / buffer.length)
setInputLevel(Math.min(1, rms * 12))
monitorFrameRef.current = requestAnimationFrame(tick)
}
tick()
}
const transcribeWavBlob = async (blob) => {
const formData = new FormData()
formData.append('file', new File([blob], 'recording.wav', { type: 'audio/wav' }))
setIsTranscribing(true)
try {
const result = await audioApi.transcribe(formData)
if (!result.text?.trim()) {
toast.error('No speech was detected in that recording')
return
}
setText((prev) => [prev.trim(), result.text.trim()].filter(Boolean).join(prev.trim() ? '\n' : ''))
toast.success('Transcript added to the message box')
} catch (err) {
toast.error(getApiErrorMessage(err, 'Failed to transcribe audio'))
} finally {
setIsTranscribing(false)
}
}
const handleRecordedBlob = async (blob) => {
try {
const { wavBlob, durationSeconds, sampleCount, peakBefore } = await decodeBlobToMonoWav(blob)
if (sampleCount === 0) {
toast.error('Recording was empty')
return
}
if (durationSeconds < MIN_RECORDING_SECONDS) {
toast.error('Recording is too short. Please speak for a bit longer.')
return
}
if (peakBefore < LOW_INPUT_LEVEL) {
toast.error('The selected browser microphone is almost silent. Check the browser mic permission and selected input device.')
return
}
await transcribeWavBlob(wavBlob)
} catch (err) {
toast.error(err?.message || 'Could not process the microphone recording')
}
}
const toggleRecording = async () => {
if (disabled || isLoading || isTranscribing) return
if (isRecording) {
mediaRecorderRef.current?.stop()
setIsRecording(false)
return
}
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
toast.error('Your browser does not support microphone recording')
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
await startInputMonitor(stream)
const track = stream.getAudioTracks()[0]
const label = track?.label?.trim()
if (label) {
toast.success(`Using microphone: ${label}`)
} else {
toast.success('Recording started. Press the mic again to stop.')
}
const mimeType = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
].find((type) => MediaRecorder.isTypeSupported(type))
if (!mimeType) {
await stopStream()
toast.error('This browser cannot record in a supported audio format')
return
}
const recorder = new MediaRecorder(stream, { mimeType })
mediaRecorderRef.current = recorder
chunksRef.current = []
recorder.ondataavailable = (event) => {
if (event.data?.size) chunksRef.current.push(event.data)
}
recorder.onerror = async () => {
await stopStream()
setIsRecording(false)
toast.error('Microphone recording failed')
}
recorder.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || mimeType })
chunksRef.current = []
await stopStream()
await handleRecordedBlob(blob)
}
recorder.start()
setIsRecording(true)
} catch (err) {
await stopStream()
toast.error(err?.message || 'Could not access the microphone')
}
}
return (
<div className="border border-white/8 bg-surface-2 rounded-2xl shadow-xl overflow-hidden transition-all duration-150 focus-within:border-accent/30 focus-within:shadow-accent/5">
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKey}
disabled={disabled || isLoading || isTranscribing}
placeholder={disabled && disabledReason ? disabledReason : 'Ask a medical question...'}
rows={1}
className="w-full bg-transparent text-white text-sm placeholder-gray-600 px-4 pt-4 pb-2 resize-none outline-none leading-relaxed disabled:opacity-50"
/>
<div className="flex items-center justify-between px-3 pb-3 pt-1">
<div className="flex items-center gap-1">
<button
onClick={onUploadClick}
title="Upload PDF / document"
className={clsx(
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all duration-150',
'text-gray-400 hover:text-accent hover:bg-accent/10 border border-white/5 hover:border-accent/20'
)}
>
<Plus size={13} />
<Paperclip size={12} />
</button>
<button
onClick={toggleRecording}
disabled={disabled || isLoading || isTranscribing}
title={
isTranscribing
? 'Transcribing audio...'
: isRecording
? 'Stop recording'
: 'Record audio for transcription'
}
className={clsx(
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all duration-150 border',
isRecording
? 'text-red-300 bg-red-500/10 border-red-400/30 hover:bg-red-500/15'
: 'text-gray-400 hover:text-accent hover:bg-accent/10 border-white/5 hover:border-accent/20',
(disabled || isLoading || isTranscribing) && 'opacity-60 cursor-not-allowed'
)}
>
{isTranscribing
? <LoaderCircle size={13} className="animate-spin" />
: <Mic size={13} className={isRecording ? 'animate-pulse' : ''} />
}
</button>
</div>
<div className="flex items-center gap-2">
{(text || isRecording || isTranscribing) && (
<span className="text-[10px] text-gray-600 select-none">
{isRecording
? 'Recording... press mic to stop'
: isTranscribing
? 'Transcribing audio...'
: 'Enter send | Shift+Enter newline'}
</span>
)}
<button
onClick={submit}
disabled={!text.trim() || disabled || isRecording || isTranscribing}
title={disabled && disabledReason ? disabledReason : (isLoading ? 'Processing...' : 'Send')}
className={clsx(
'w-8 h-8 rounded-lg flex items-center justify-center transition-all duration-150',
text.trim() && !disabled && !isRecording && !isTranscribing
? 'bg-accent hover:bg-accent-hover text-white shadow-md shadow-accent/20 active:scale-95'
: 'bg-surface-3 text-gray-600 cursor-not-allowed'
)}
>
{isLoading
? <Square size={12} className="fill-current" />
: <Send size={13} className={text.trim() ? '' : 'opacity-40'} />
}
</button>
</div>
</div>
{(isRecording || inputLevel > 0) && (
<div className="px-4 pb-3">
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 rounded-full bg-surface-3 overflow-hidden">
<div
className={clsx(
'h-full transition-[width] duration-100',
inputLevel < LOW_INPUT_LEVEL ? 'bg-amber-400/80' : 'bg-emerald-400/90'
)}
style={{ width: `${Math.max(4, inputLevel * 100)}%` }}
/>
</div>
<span className="text-[10px] text-gray-500 w-12 text-right">
{inputLevel < LOW_INPUT_LEVEL ? 'Low mic' : 'Mic ok'}
</span>
</div>
</div>
)}
{disabled && disabledReason && (
<div className="px-4 pb-3 text-[11px] text-amber-300/80">
{disabledReason}
</div>
)}
</div>
)
}