Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

マイク入力機能の仕様変更 #160

Merged
merged 4 commits into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/components/iconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { ButtonHTMLAttributes } from 'react'
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
iconName: keyof KnownIconType
isProcessing: boolean
isProcessingIcon?: keyof KnownIconType
label?: string
}

export const IconButton = ({
iconName,
isProcessing,
isProcessingIcon,
label,
...rest
}: Props) => {
Expand All @@ -20,7 +22,7 @@ export const IconButton = ({
`}
>
{isProcessing ? (
<pixiv-icon name={'24/Dot'} scale="1"></pixiv-icon>
<pixiv-icon name={isProcessingIcon || '24/Dot'} scale="1"></pixiv-icon>
) : (
<pixiv-icon name={iconName} scale="1"></pixiv-icon>
)}
Expand Down
10 changes: 9 additions & 1 deletion src/components/messageInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'

import homeStore from '@/features/stores/home'
Expand Down Expand Up @@ -26,6 +26,7 @@ export const MessageInput = ({
const slidePlaying = slideStore((s) => s.isPlaying)
const [rows, setRows] = useState(1)
const [loadingDots, setLoadingDots] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)

const { t } = useTranslation()

Expand All @@ -39,6 +40,11 @@ export const MessageInput = ({
}, 200)

return () => clearInterval(interval)
} else {
if (textareaRef.current) {
textareaRef.current.value = ''
textareaRef.current.focus()
}
}
}, [chatProcessing])

Expand Down Expand Up @@ -76,10 +82,12 @@ export const MessageInput = ({
iconName="24/Microphone"
className="bg-secondary hover:bg-secondary-hover active:bg-secondary-press disabled:bg-secondary-disabled"
isProcessing={isMicRecording}
isProcessingIcon={'24/PauseAlt'}
disabled={chatProcessing}
onClick={onClickMicButton}
/>
<textarea
ref={textareaRef}
placeholder={
chatProcessing
? `${t('AnswerGenerating')}${loadingDots}`
Expand Down
161 changes: 96 additions & 65 deletions src/components/messageInputContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,96 +1,127 @@
import { useState, useEffect, useCallback } from 'react'

import { useState, useEffect, useCallback, useRef } from 'react'
import { MessageInput } from '@/components/messageInput'
import homeStore from '@/features/stores/home'
import settingsStore from '@/features/stores/settings'

type Props = {
onChatProcessStart: (text: string) => void
}

/**
* テキスト入力と音声入力を提供する
*
* 音声認識の完了時は自動で送信し、返答文の生成中は入力を無効化する
*
*/
export const MessageInputContainer = ({ onChatProcessStart }: Props) => {
const chatProcessing = homeStore((s) => s.chatProcessing)
const [userMessage, setUserMessage] = useState('')
const [speechRecognition, setSpeechRecognition] =
useState<SpeechRecognition>()
const [isMicRecording, setIsMicRecording] = useState(false)

// 音声認識の結果を処理する
const handleRecognitionResult = useCallback(
(event: SpeechRecognitionEvent) => {
const text = event.results[0][0].transcript
setUserMessage(text)

// 発言の終了時
if (event.results[0].isFinal) {
setUserMessage(text)
// 返答文の生成を開始
onChatProcessStart(text)
}
},
[onChatProcessStart]
)
const [isListening, setIsListening] = useState(false)
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null)
const keyPressStartTime = useRef<number | null>(null)
const transcriptRef = useRef('')
const isKeyboardTriggered = useRef(false)

// 無音が続いた場合も終了する
const handleRecognitionEnd = useCallback(() => {
setIsMicRecording(false)
}, [])
useEffect(() => {
const SpeechRecognition =
window.SpeechRecognition || window.webkitSpeechRecognition
if (SpeechRecognition) {
const newRecognition = new SpeechRecognition()
const ss = settingsStore.getState()
newRecognition.lang = ss.selectVoiceLanguage
newRecognition.continuous = true
newRecognition.interimResults = true

newRecognition.onresult = (event) => {
const transcript = Array.from(event.results)
.map((result) => result[0].transcript)
.join('')
transcriptRef.current = transcript
setUserMessage(transcript)
}

const handleClickMicButton = useCallback(() => {
if (isMicRecording) {
speechRecognition?.abort()
setIsMicRecording(false)
newRecognition.onerror = (event) => {
console.error('音声認識エラー:', event.error)
setIsListening(false)
}

return
setRecognition(newRecognition)
}
}, [])

speechRecognition?.start()
setIsMicRecording(true)
}, [isMicRecording, speechRecognition])
const startListening = useCallback(() => {
if (recognition && !isListening) {
transcriptRef.current = ''
setUserMessage('')
recognition.start()
setIsListening(true)
}
}, [recognition, isListening])

const stopListening = useCallback(() => {
if (recognition && isListening) {
recognition.stop()
setIsListening(false)
if (isKeyboardTriggered.current) {
const pressDuration = Date.now() - (keyPressStartTime.current || 0)
if (pressDuration >= 1000 && transcriptRef.current.trim()) {
onChatProcessStart(transcriptRef.current)
setUserMessage('')
}
isKeyboardTriggered.current = false
} else if (transcriptRef.current.trim()) {
onChatProcessStart(transcriptRef.current)
setUserMessage('')
}
}
}, [recognition, isListening, onChatProcessStart])

const handleClickSendButton = useCallback(() => {
onChatProcessStart(userMessage)
}, [onChatProcessStart, userMessage])
const toggleListening = useCallback(() => {
if (isListening) {
stopListening()
} else {
startListening()
}
}, [isListening, startListening, stopListening])

useEffect(() => {
const SpeechRecognition =
window.webkitSpeechRecognition || window.SpeechRecognition
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.altKey || e.metaKey) && !isListening) {
keyPressStartTime.current = Date.now()
isKeyboardTriggered.current = true
startListening()
}
}

// FirefoxなどSpeechRecognition非対応環境対策
if (!SpeechRecognition) {
return
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Alt' || e.key === 'Meta') {
stopListening()
keyPressStartTime.current = null
}
}
const ss = settingsStore.getState()
const recognition = new SpeechRecognition()
recognition.lang = ss.selectVoiceLanguage
recognition.interimResults = true // 認識の途中結果を返す
recognition.continuous = false // 発言の終了時に認識を終了する

recognition.addEventListener('result', handleRecognitionResult)
recognition.addEventListener('end', handleRecognitionEnd)
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)

setSpeechRecognition(recognition)
}, [handleRecognitionResult, handleRecognitionEnd])
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [isListening, startListening, stopListening])

useEffect(() => {
if (!chatProcessing) {
const handleSendMessage = useCallback(() => {
if (userMessage.trim()) {
onChatProcessStart(userMessage)
setUserMessage('')
}
}, [chatProcessing])
}, [userMessage, onChatProcessStart])

const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setUserMessage(e.target.value)
},
[]
)

return (
<MessageInput
userMessage={userMessage}
isMicRecording={isMicRecording}
onChangeUserMessage={(e) => setUserMessage(e.target.value)}
onClickMicButton={handleClickMicButton}
onClickSendButton={handleClickSendButton}
isMicRecording={isListening}
onChangeUserMessage={handleInputChange}
onClickMicButton={toggleListening}
onClickSendButton={handleSendMessage}
/>
)
}
Loading