| import React, { useState, useRef } from 'react'; |
| import { Mic, Square, Send, Loader2 } from 'lucide-react'; |
| import { apiService } from '../services/api'; |
|
|
| interface HomeInputProps { |
| onRecordComplete?: () => void; |
| } |
|
|
| |
| |
| |
| |
| |
| export function HomeInput({ onRecordComplete }: HomeInputProps) { |
| const [isRecording, setIsRecording] = useState(false); |
| const [textInput, setTextInput] = useState(''); |
| const [processing, setProcessing] = useState(false); |
| const [error, setError] = useState<string | null>(null); |
| const [showSuccess, setShowSuccess] = useState(false); |
| |
| const mediaRecorderRef = useRef<MediaRecorder | null>(null); |
| const audioChunksRef = useRef<Blob[]>([]); |
|
|
| const startRecording = async () => { |
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| |
| |
| let options: MediaRecorderOptions = { mimeType: 'audio/webm' }; |
| |
| if (MediaRecorder.isTypeSupported('audio/wav')) { |
| options = { mimeType: 'audio/wav' }; |
| } else if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) { |
| options = { mimeType: 'audio/webm;codecs=opus' }; |
| } else if (MediaRecorder.isTypeSupported('audio/webm')) { |
| options = { mimeType: 'audio/webm' }; |
| } |
| |
| const mediaRecorder = new MediaRecorder(stream, options); |
| |
| mediaRecorderRef.current = mediaRecorder; |
| audioChunksRef.current = []; |
|
|
| mediaRecorder.ondataavailable = (event) => { |
| if (event.data.size > 0) { |
| audioChunksRef.current.push(event.data); |
| } |
| }; |
|
|
| mediaRecorder.onstop = async () => { |
| const mimeType = mediaRecorder.mimeType; |
| const audioBlob = new Blob(audioChunksRef.current, { type: mimeType }); |
| await processAudio(audioBlob, mimeType); |
| |
| |
| stream.getTracks().forEach(track => track.stop()); |
| }; |
|
|
| mediaRecorder.start(); |
| setIsRecording(true); |
| setError(null); |
| setShowSuccess(false); |
| } catch (err) { |
| console.error('Failed to start recording:', err); |
| setError('无法访问麦克风,请检查权限设置'); |
| } |
| }; |
|
|
| const stopRecording = () => { |
| if (mediaRecorderRef.current && isRecording) { |
| mediaRecorderRef.current.stop(); |
| setIsRecording(false); |
| } |
| }; |
|
|
| const processAudio = async (audioBlob: Blob, mimeType: string) => { |
| setProcessing(true); |
| setError(null); |
| |
| try { |
| |
| let extension = 'webm'; |
| let fileName = 'recording.webm'; |
| |
| if (mimeType.includes('wav')) { |
| extension = 'wav'; |
| fileName = 'recording.wav'; |
| } else if (mimeType.includes('mp4') || mimeType.includes('m4a')) { |
| extension = 'm4a'; |
| fileName = 'recording.m4a'; |
| } else if (mimeType.includes('mpeg') || mimeType.includes('mp3')) { |
| extension = 'mp3'; |
| fileName = 'recording.mp3'; |
| } |
| |
| |
| |
| if (extension === 'webm') { |
| |
| try { |
| const wavBlob = await convertWebmToWav(audioBlob); |
| const file = new File([wavBlob], 'recording.wav', { type: 'audio/wav' }); |
| await apiService.processInput(file); |
| } catch (conversionError) { |
| console.error('Conversion failed, trying direct upload:', conversionError); |
| |
| const file = new File([audioBlob], 'recording.mp3', { type: 'audio/mpeg' }); |
| await apiService.processInput(file); |
| } |
| } else { |
| const file = new File([audioBlob], fileName, { type: mimeType }); |
| await apiService.processInput(file); |
| } |
| |
| |
| setShowSuccess(true); |
| setTimeout(() => setShowSuccess(false), 3000); |
| |
| if (onRecordComplete) { |
| onRecordComplete(); |
| } |
| } catch (err: any) { |
| console.error('Failed to process audio:', err); |
| setError(err.message || '处理失败,请重试'); |
| } finally { |
| setProcessing(false); |
| } |
| }; |
|
|
| |
| const convertWebmToWav = async (webmBlob: Blob): Promise<Blob> => { |
| const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); |
| const arrayBuffer = await webmBlob.arrayBuffer(); |
| const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); |
| |
| |
| const wavBuffer = audioBufferToWav(audioBuffer); |
| return new Blob([wavBuffer], { type: 'audio/wav' }); |
| }; |
|
|
| |
| const audioBufferToWav = (buffer: AudioBuffer): ArrayBuffer => { |
| const length = buffer.length * buffer.numberOfChannels * 2 + 44; |
| const arrayBuffer = new ArrayBuffer(length); |
| const view = new DataView(arrayBuffer); |
| const channels: Float32Array[] = []; |
| let offset = 0; |
| let pos = 0; |
|
|
| |
| const setUint16 = (data: number) => { |
| view.setUint16(pos, data, true); |
| pos += 2; |
| }; |
| const setUint32 = (data: number) => { |
| view.setUint32(pos, data, true); |
| pos += 4; |
| }; |
|
|
| |
| setUint32(0x46464952); |
| |
| setUint32(length - 8); |
| |
| setUint32(0x45564157); |
| |
| setUint32(0x20746d66); |
| |
| setUint32(16); |
| |
| setUint16(1); |
| |
| setUint16(buffer.numberOfChannels); |
| |
| setUint32(buffer.sampleRate); |
| |
| setUint32(buffer.sampleRate * buffer.numberOfChannels * 2); |
| |
| setUint16(buffer.numberOfChannels * 2); |
| |
| setUint16(16); |
| |
| setUint32(0x61746164); |
| |
| setUint32(length - pos - 4); |
|
|
| |
| for (let i = 0; i < buffer.numberOfChannels; i++) { |
| channels.push(buffer.getChannelData(i)); |
| } |
|
|
| while (pos < length) { |
| for (let i = 0; i < buffer.numberOfChannels; i++) { |
| let sample = Math.max(-1, Math.min(1, channels[i][offset])); |
| sample = sample < 0 ? sample * 0x8000 : sample * 0x7fff; |
| view.setInt16(pos, sample, true); |
| pos += 2; |
| } |
| offset++; |
| } |
|
|
| return arrayBuffer; |
| }; |
|
|
| const processText = async () => { |
| if (!textInput.trim()) return; |
| |
| setProcessing(true); |
| setError(null); |
| |
| try { |
| await apiService.processInput(undefined, textInput); |
| setTextInput(''); |
| |
| |
| setShowSuccess(true); |
| setTimeout(() => setShowSuccess(false), 3000); |
| |
| if (onRecordComplete) { |
| onRecordComplete(); |
| } |
| } catch (err: any) { |
| console.error('Failed to process text:', err); |
| setError(err.message || '处理失败,请重试'); |
| } finally { |
| setProcessing(false); |
| } |
| }; |
|
|
| return ( |
| <div className="w-full max-w-md mx-auto px-6 space-y-4"> |
| {/* Voice Recording Button */} |
| <div className="flex items-center justify-center gap-4"> |
| <button |
| onClick={isRecording ? stopRecording : startRecording} |
| disabled={processing} |
| className={` |
| relative p-8 rounded-full transition-all duration-300 shadow-2xl |
| ${isRecording |
| ? 'bg-gradient-to-br from-red-400 to-red-600 animate-pulse scale-110' |
| : 'bg-gradient-to-br from-purple-400 to-pink-500 hover:scale-105' |
| } |
| text-white disabled:opacity-50 disabled:cursor-not-allowed |
| ${!processing && !isRecording ? 'hover:shadow-purple-300' : ''} |
| `} |
| aria-label={isRecording ? '停止录音' : '开始录音'} |
| > |
| {isRecording ? <Square size={36} /> : <Mic size={36} />} |
| |
| {/* Recording indicator ring */} |
| {isRecording && ( |
| <span className="absolute inset-0 rounded-full border-4 border-red-300 animate-ping" /> |
| )} |
| </button> |
| </div> |
| |
| {/* Recording status text */} |
| {isRecording && ( |
| <div className="text-center"> |
| <span className="text-sm text-slate-600 animate-pulse"> |
| 正在录音... 点击停止 |
| </span> |
| </div> |
| )} |
| |
| {/* Text Input */} |
| <div className="flex gap-2"> |
| <input |
| type="text" |
| value={textInput} |
| onChange={(e) => setTextInput(e.target.value)} |
| onKeyPress={(e) => e.key === 'Enter' && !e.shiftKey && processText()} |
| placeholder="或者在这里输入文字..." |
| disabled={processing || isRecording} |
| className=" |
| flex-1 px-5 py-3 rounded-full |
| bg-white/90 backdrop-blur-sm |
| border-2 border-slate-200 |
| focus:outline-none focus:ring-2 focus:ring-purple-300 focus:border-transparent |
| disabled:opacity-50 disabled:cursor-not-allowed |
| placeholder:text-slate-400 |
| transition-all duration-200 |
| " |
| /> |
| <button |
| onClick={processText} |
| disabled={processing || isRecording || !textInput.trim()} |
| className=" |
| px-6 py-3 rounded-full |
| bg-gradient-to-r from-purple-500 to-pink-500 |
| hover:from-purple-600 hover:to-pink-600 |
| text-white transition-all duration-200 |
| disabled:opacity-50 disabled:cursor-not-allowed |
| shadow-lg hover:shadow-xl |
| flex items-center gap-2 |
| " |
| aria-label="发送" |
| > |
| {processing ? ( |
| <Loader2 size={20} className="animate-spin" /> |
| ) : ( |
| <Send size={20} /> |
| )} |
| </button> |
| </div> |
| |
| {/* Processing Indicator */} |
| {processing && ( |
| <div className="text-center"> |
| <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-purple-100 text-purple-700"> |
| <Loader2 size={16} className="animate-spin" /> |
| <span className="text-sm">正在分析...</span> |
| </div> |
| </div> |
| )} |
| |
| {/* Success Message */} |
| {showSuccess && ( |
| <div className="text-center animate-fade-in"> |
| <div className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-green-100 text-green-700 shadow-lg"> |
| <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> |
| </svg> |
| <span className="text-sm font-medium">记录成功!</span> |
| </div> |
| </div> |
| )} |
| |
| {/* Error Message */} |
| {error && ( |
| <div className="text-center animate-fade-in"> |
| <div className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-red-100 text-red-700 shadow-lg"> |
| <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> |
| </svg> |
| <span className="text-sm">{error}</span> |
| </div> |
| </div> |
| )} |
| |
| {/* Hint text */} |
| <div className="text-center"> |
| <p className="text-xs text-slate-400 leading-relaxed"> |
| 说出或写下你的想法、灵感、待办事项<br /> |
| 我会帮你整理和记录 |
| </p> |
| </div> |
| </div> |
| ); |
| } |
|
|