File size: 11,962 Bytes
59bd45e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 | import React, { useState, useRef } from 'react';
import { Mic, Square, Send, Loader2 } from 'lucide-react';
import { apiService } from '../services/api';
interface HomeInputProps {
onRecordComplete?: () => void;
}
/**
* Home page input component for recording inspirations
* This component allows users to record voice or type text
* The input is processed and split into inspirations, todos, and moods
*/
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 });
// Try to use audio/wav if supported, otherwise use default
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);
// Stop all tracks
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 {
// Determine file extension based on mime type
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 it's webm, we need to convert it or send with proper naming
// For now, we'll try to send as wav by renaming (browser may support it)
if (extension === 'webm') {
// Try to convert webm to wav using Web Audio API
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);
// Fallback: try sending as mp3 (some backends might accept webm as mp3)
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);
}
// Show success message
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);
}
};
// Convert webm to wav using Web Audio API
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);
// Convert to WAV format
const wavBuffer = audioBufferToWav(audioBuffer);
return new Blob([wavBuffer], { type: 'audio/wav' });
};
// Convert AudioBuffer to WAV format
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;
// Write WAV header
const setUint16 = (data: number) => {
view.setUint16(pos, data, true);
pos += 2;
};
const setUint32 = (data: number) => {
view.setUint32(pos, data, true);
pos += 4;
};
// RIFF identifier
setUint32(0x46464952);
// File length
setUint32(length - 8);
// RIFF type
setUint32(0x45564157);
// Format chunk identifier
setUint32(0x20746d66);
// Format chunk length
setUint32(16);
// Sample format (raw)
setUint16(1);
// Channel count
setUint16(buffer.numberOfChannels);
// Sample rate
setUint32(buffer.sampleRate);
// Byte rate
setUint32(buffer.sampleRate * buffer.numberOfChannels * 2);
// Block align
setUint16(buffer.numberOfChannels * 2);
// Bits per sample
setUint16(16);
// Data chunk identifier
setUint32(0x61746164);
// Data chunk length
setUint32(length - pos - 4);
// Write interleaved data
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('');
// Show success message
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>
);
}
|