112 lines
3.6 KiB
TypeScript
112 lines
3.6 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { View, Text, Pressable, StyleSheet, ActivityIndicator } from 'react-native'
|
|
import { COLORS } from '../constants/Config'
|
|
|
|
// expo-speech-recognition 선택적 임포트
|
|
let ExpoSpeechRecognitionModule: any = null
|
|
let useSpeechRecognitionEvent: any = null
|
|
try {
|
|
const mod = require('@jamsch/expo-speech-recognition')
|
|
ExpoSpeechRecognitionModule = mod.ExpoSpeechRecognitionModule
|
|
useSpeechRecognitionEvent = mod.useSpeechRecognitionEvent
|
|
} catch {
|
|
// 패키지 미설치 시 폴백
|
|
}
|
|
|
|
interface Props {
|
|
onTranscript: (text: string) => void
|
|
size?: 'small' | 'normal'
|
|
}
|
|
|
|
export function VoiceInput({ onTranscript, size = 'normal' }: Props) {
|
|
const [isListening, setListening] = useState(false)
|
|
const [interim, setInterim] = useState('')
|
|
const [hasPermission, setPermission] = useState<boolean | null>(null)
|
|
|
|
// 음성 인식 이벤트 (패키지 있을 때만)
|
|
if (useSpeechRecognitionEvent) {
|
|
useSpeechRecognitionEvent('result', (e: any) => {
|
|
const t = e.results?.[0]?.transcript || ''
|
|
if (e.isFinal) {
|
|
onTranscript(t)
|
|
setInterim('')
|
|
setListening(false)
|
|
} else {
|
|
setInterim(t)
|
|
}
|
|
})
|
|
useSpeechRecognitionEvent('error', () => { setListening(false); setInterim('') })
|
|
useSpeechRecognitionEvent('end', () => { setListening(false) })
|
|
}
|
|
|
|
async function toggleListen() {
|
|
if (!ExpoSpeechRecognitionModule) {
|
|
// 폴백: 텍스트 입력 모드 안내
|
|
onTranscript('')
|
|
return
|
|
}
|
|
|
|
if (isListening) {
|
|
ExpoSpeechRecognitionModule.stop()
|
|
setListening(false)
|
|
} else {
|
|
try {
|
|
const granted = await ExpoSpeechRecognitionModule.requestPermissionsAsync()
|
|
if (!granted.granted) { setPermission(false); return }
|
|
setPermission(true)
|
|
await ExpoSpeechRecognitionModule.start({
|
|
lang: 'ko-KR',
|
|
interimResults: true,
|
|
continuous: false,
|
|
requiresOnDeviceRecognition: false,
|
|
})
|
|
setListening(true)
|
|
} catch (e) {
|
|
setListening(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
const btnSize = size === 'small' ? 36 : 44
|
|
const iconSize = size === 'small' ? 18 : 22
|
|
|
|
return (
|
|
<View style={S.wrap}>
|
|
<Pressable
|
|
onPress={toggleListen}
|
|
style={[S.btn, { width: btnSize, height: btnSize, borderRadius: btnSize / 2 },
|
|
isListening && S.btnActive]}
|
|
>
|
|
{isListening
|
|
? <ActivityIndicator color="#fff" size="small" />
|
|
: <Text style={{ fontSize: iconSize }}>🎤</Text>
|
|
}
|
|
</Pressable>
|
|
{interim ? (
|
|
<View style={S.interimBox}>
|
|
<Text style={S.interimText} numberOfLines={1}>{interim}</Text>
|
|
</View>
|
|
) : null}
|
|
{hasPermission === false ? (
|
|
<Text style={S.errText}>마이크 권한 필요</Text>
|
|
) : null}
|
|
{!ExpoSpeechRecognitionModule ? (
|
|
<Text style={S.errText}>음성인식 미지원</Text>
|
|
) : null}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const S = StyleSheet.create({
|
|
wrap: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
|
btn: { backgroundColor: COLORS.gnbBg, alignItems: 'center',
|
|
justifyContent: 'center', shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.15,
|
|
shadowRadius: 3, elevation: 2 },
|
|
btnActive: { backgroundColor: '#dc2626' },
|
|
interimBox: { backgroundColor: '#f0f4ff', borderRadius: 8, paddingHorizontal: 8,
|
|
paddingVertical: 3, maxWidth: 160 },
|
|
interimText: { fontSize: 12, color: COLORS.gnbBg },
|
|
errText: { fontSize: 10, color: '#94a3b8' },
|
|
})
|