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 (