| 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> |
| ) |
| } |
|
|