Skip to content

Commit 844bdff

Browse files
committed
Handle audio too
1 parent 7cfa91a commit 844bdff

File tree

10 files changed

+295
-54
lines changed

10 files changed

+295
-54
lines changed

app/css/ui-kit/animations.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,15 @@
7474
transform: rotate(360deg);
7575
}
7676
}
77+
78+
@keyframes blink {
79+
0% {
80+
opacity: 1;
81+
}
82+
50% {
83+
opacity: 0.3;
84+
}
85+
100% {
86+
opacity: 1;
87+
}
88+
}

app/javascript/components/bootcamp/FrontendExercisePage/RHS/Panels/ChatPanel/AiChat/AudioRecorder/AudioRecorder.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useAudioRecorder } from './useAudioRecorder'
44
import { assembleClassNames } from '@/utils/assemble-classnames'
55
import { formatDuration } from './format-duration'
66
import { GraphicalIcon } from '@/components/common'
7+
import { useAiChatStore } from '../store/aiChatStore'
78

89
export default function AudioRecorder() {
910
const {
@@ -14,6 +15,7 @@ export default function AudioRecorder() {
1415
startRecording,
1516
stopRecording,
1617
} = useAudioRecorder()
18+
const { isResponseBeingGenerated } = useAiChatStore()
1719
return (
1820
<div
1921
className={assembleClassNames(
@@ -55,7 +57,11 @@ export default function AudioRecorder() {
5557
</button>
5658
) : (
5759
<button
58-
className="p-2 w-28 h-28 hover:bg-bootcamp-very-light-purple rounded-8"
60+
className={assembleClassNames(
61+
'p-2 w-28 h-28 hover:bg-bootcamp-very-light-purple rounded-8',
62+
isResponseBeingGenerated && 'opacity-50'
63+
)}
64+
disabled={isResponseBeingGenerated}
5965
onClick={startRecording}
6066
>
6167
<GraphicalIcon

app/javascript/components/bootcamp/FrontendExercisePage/RHS/Panels/ChatPanel/AiChat/AudioRecorder/useAudioRecorder.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
import { useState, useRef, useContext, useCallback } from 'react'
2-
import { v4 as uuid } from 'uuid'
32
import { ChatContext } from '..'
3+
import { convertToPCM16, resampleAudioBuffer } from '../utils'
4+
import { useAiChatStore } from '../store/aiChatStore'
45

56
export type AudioRecorderProps = ReturnType<typeof useAudioRecorder>
67

78
export function useAudioRecorder() {
89
const [isRecording, setIsRecording] = useState(false)
910
const [liveDuration, setLiveDuration] = useState(0)
11+
const { finishStream, setIsResponseBeingGenerated } = useAiChatStore()
1012

1113
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
1214
const audioContextRef = useRef<AudioContext | null>(null)
1315
const timerRef = useRef<NodeJS.Timeout | null>(null)
1416
const streamRef = useRef<MediaStream | null>(null)
1517
const audioChunksRef = useRef<BlobPart[]>([])
1618

17-
const { analyserRef } = useContext(ChatContext)
19+
const { analyserRef, geminiVoice } = useContext(ChatContext)
1820

1921
const cleanup = useCallback(() => {
2022
if (timerRef.current) {
@@ -55,20 +57,34 @@ export function useAudioRecorder() {
5557
}
5658
}, [])
5759

58-
const processRecording = useCallback(() => {
60+
const processRecording = useCallback(async () => {
5961
const chunks = audioChunksRef.current
6062
if (chunks.length === 0) return
6163

6264
const mimeType = mediaRecorderRef.current?.mimeType || 'audio/webm'
6365
const audioBlob = new Blob(chunks, { type: mimeType })
64-
const formData = new FormData()
65-
formData.append('audio', audioBlob)
66-
67-
console.log({
68-
audio: formData,
69-
uuid: uuid(),
70-
size: audioBlob.size,
71-
duration: liveDuration,
66+
const arrayBuffer = await audioBlob.arrayBuffer()
67+
68+
// Decode audio
69+
const audioContext = new AudioContext()
70+
const decoded = await audioContext.decodeAudioData(arrayBuffer)
71+
72+
// Downsample to 16kHz mono PCM
73+
const resampledBuffer = await resampleAudioBuffer(decoded, 16000)
74+
const pcmData = convertToPCM16(resampledBuffer.getChannelData(0))
75+
76+
// Convert to base64
77+
const base64Audio = btoa(
78+
String.fromCharCode(...new Uint8Array(pcmData.buffer))
79+
)
80+
81+
console.log('Sending audio to Gemini Voice:', base64Audio)
82+
geminiVoice.sendAudio(base64Audio)
83+
setIsResponseBeingGenerated(true)
84+
const turns = await geminiVoice.getNextTurn()
85+
turns.forEach((turn) => {
86+
console.log('TURNS', turn)
87+
if (turn.type === 'audio') finishStream(turn.audioBlob)
7288
})
7389
}, [liveDuration])
7490

app/javascript/components/bootcamp/FrontendExercisePage/RHS/Panels/ChatPanel/AiChat/ChatInput.tsx

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,18 @@ import { assembleClassNames } from '@/utils/assemble-classnames'
44
import { ChatContext } from '.'
55
import { Message, useAiChatStore } from './store/aiChatStore'
66
import AudioRecorder from './AudioRecorder/AudioRecorder'
7-
import { API_KEY, useGeminiLive } from './useGeminiLive'
87

98
export function ChatInput() {
109
const [value, setValue] = React.useState('')
11-
const { inputRef } = useContext(ChatContext)
12-
const { appendMessage, streamMessage, finishStream, isMessageBeingStreamed } =
13-
useAiChatStore()
14-
15-
const { sendMessage, getNextTurn } = useGeminiLive(API_KEY)
10+
const { inputRef, geminiText } = useContext(ChatContext)
11+
const {
12+
appendMessage,
13+
streamMessage,
14+
finishStream,
15+
isMessageBeingStreamed,
16+
setIsResponseBeingGenerated,
17+
isResponseBeingGenerated,
18+
} = useAiChatStore()
1619

1720
const handleSendOnEnter = useCallback(
1821
async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -32,17 +35,12 @@ export function ChatInput() {
3235
setValue('')
3336

3437
try {
35-
sendMessage(value)
36-
37-
const turns = await getNextTurn()
38-
for (const turn of turns) {
39-
console.log('turn', turn)
40-
if (turn.text) {
41-
streamMessage(turn.text)
42-
} else if (turn.data) {
43-
streamMessage(turn.data)
44-
}
45-
}
38+
geminiText.sendText(value)
39+
setIsResponseBeingGenerated(true)
40+
const turns = await geminiText.getNextTurn()
41+
turns.forEach((turn) => {
42+
if (turn.type === 'text') streamMessage(turn.text)
43+
})
4644

4745
finishStream()
4846
} catch (err) {
@@ -51,7 +49,7 @@ export function ChatInput() {
5149
}
5250
}
5351
},
54-
[value, sendMessage, getNextTurn]
52+
[value, geminiText]
5553
)
5654

5755
const handleInput = useCallback(
@@ -69,16 +67,23 @@ export function ChatInput() {
6967
ref={inputRef}
7068
id="text"
7169
name="text"
72-
placeholder="Ask your question here"
70+
placeholder={
71+
isResponseBeingGenerated
72+
? 'Generating response...'
73+
: isMessageBeingStreamed
74+
? 'Streaming response...'
75+
: 'Ask anything'
76+
}
7377
rows={1}
7478
className={assembleClassNames(
7579
'chat-textarea w-full text-16',
76-
isMessageBeingStreamed && 'opacity-50'
80+
isMessageBeingStreamed && 'opacity-50',
81+
isResponseBeingGenerated && 'animate-blink'
7782
)}
7883
value={value}
7984
onInput={handleInput}
8085
onKeyDown={handleSendOnEnter}
81-
disabled={isMessageBeingStreamed}
86+
disabled={isMessageBeingStreamed || isResponseBeingGenerated}
8287
/>
8388

8489
{value.length > 0 && (

app/javascript/components/bootcamp/FrontendExercisePage/RHS/Panels/ChatPanel/AiChat/ChatThread.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import React, { useContext, useEffect } from 'react'
22
import { assembleClassNames } from '@/utils/assemble-classnames'
33
import { ChatContext } from '.'
44
import { useAiChatStore } from './store/aiChatStore'
5-
import { FAKE_LONG_STREAM_MESSAGE } from './ChatInput'
65
import { useContinuousHighlighting } from '@/hooks/use-syntax-highlighting'
76

87
export function ChatThread() {
@@ -27,11 +26,22 @@ export function ChatThread() {
2726
{messages.map((message, index) => {
2827
return (
2928
<div
30-
ref={threadElementRef}
3129
key={message.id + index}
30+
ref={threadElementRef}
3231
className={assembleClassNames('chat-message', message.sender)}
33-
dangerouslySetInnerHTML={{ __html: message.content }}
34-
/>
32+
>
33+
{message.content && (
34+
<div dangerouslySetInnerHTML={{ __html: message.content }} />
35+
)}
36+
37+
{message.audioUrl && (
38+
<audio
39+
controls
40+
src={message.audioUrl}
41+
className="h-32 w-[300px]"
42+
/>
43+
)}
44+
</div>
3545
)
3646
})}
3747

app/javascript/components/bootcamp/FrontendExercisePage/RHS/Panels/ChatPanel/AiChat/index.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,51 @@
11
import React, { createContext, useRef } from 'react'
22
import { ChatInput } from './ChatInput'
33
import { ChatThread } from './ChatThread'
4+
import { useGeminiLive } from './useGeminiLive'
5+
import { Modality } from '@google/genai'
6+
7+
const API_KEY = ''
48

59
type ChatContextType = {
610
inputRef: React.RefObject<HTMLTextAreaElement>
711
scrollContainerRef: React.RefObject<HTMLDivElement>
812
analyserRef: React.MutableRefObject<AnalyserNode | null>
13+
geminiText: ReturnType<typeof useGeminiLive>
14+
geminiVoice: ReturnType<typeof useGeminiLive>
915
}
1016

1117
export const ChatContext = createContext<ChatContextType>({
1218
inputRef: {} as ChatContextType['inputRef'],
1319
scrollContainerRef: {} as ChatContextType['scrollContainerRef'],
1420
analyserRef: {} as ChatContextType['analyserRef'],
21+
geminiText: {} as ChatContextType['geminiText'],
22+
geminiVoice: {} as ChatContextType['geminiVoice'],
1523
})
1624

1725
export function Chat() {
1826
const inputRef = useRef<HTMLTextAreaElement>(null)
1927
const scrollContainerRef = useRef<HTMLDivElement>(null)
2028
const analyserRef = useRef<AnalyserNode | null>(null)
2129

30+
const geminiText = useGeminiLive({
31+
apiKey: API_KEY,
32+
responseModality: Modality.TEXT,
33+
})
34+
const geminiVoice = useGeminiLive({
35+
apiKey: API_KEY,
36+
responseModality: Modality.AUDIO,
37+
})
38+
2239
return (
23-
<ChatContext.Provider value={{ inputRef, scrollContainerRef, analyserRef }}>
40+
<ChatContext.Provider
41+
value={{
42+
inputRef,
43+
scrollContainerRef,
44+
analyserRef,
45+
geminiText,
46+
geminiVoice,
47+
}}
48+
>
2449
<div className="ai-chat-container">
2550
<ChatThread />
2651
<ChatInput />

app/javascript/components/bootcamp/FrontendExercisePage/RHS/Panels/ChatPanel/AiChat/store/aiChatStore.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export type Message = {
44
id: string
55
sender: 'user' | 'ai'
66
content: string
7+
audioUrl?: string
78
timestamp: string
89
}
910

@@ -12,8 +13,10 @@ type AiChatStore = {
1213
appendMessage: (message: Message) => void
1314
messageStream: string
1415
streamMessage: (message: string) => void
15-
finishStream: () => void
16+
finishStream: (audioBlob?: Blob) => void
1617
isMessageBeingStreamed: boolean
18+
isResponseBeingGenerated: boolean
19+
setIsResponseBeingGenerated: (isGenerating: boolean) => void
1720
}
1821

1922
const MOCK_MESSAGES: Message[] = [
@@ -45,23 +48,45 @@ export const useAiChatStore = create<AiChatStore>((set, get) => ({
4548
}))
4649
},
4750
isMessageBeingStreamed: false,
51+
isResponseBeingGenerated: false,
52+
setIsResponseBeingGenerated: (isGenerating: boolean) => {
53+
set({ isResponseBeingGenerated: isGenerating })
54+
},
4855
messageStream: '',
4956
streamMessage: (message: string) => {
5057
set((state) => ({
5158
isMessageBeingStreamed: true,
59+
isResponseBeingGenerated: false,
5260
messageStream: state.messageStream + message,
5361
}))
5462
},
55-
finishStream: () => {
63+
finishStream: (audioBlob?: Blob) => {
5664
const { messageStream, appendMessage } = get()
57-
if (messageStream.trim() === '') return
65+
66+
const hasText = messageStream.trim() !== ''
67+
const hasAudio = !!audioBlob
68+
69+
if (!hasText && !hasAudio) {
70+
set({
71+
messageStream: '',
72+
isMessageBeingStreamed: false,
73+
isResponseBeingGenerated: false,
74+
})
75+
return
76+
}
77+
5878
const newMessage: Message = {
5979
id: Date.now().toString(),
6080
sender: 'ai',
61-
content: messageStream,
81+
content: hasText ? messageStream : '',
82+
audioUrl: hasAudio ? URL.createObjectURL(audioBlob) : '',
6283
timestamp: new Date().toISOString(),
6384
}
6485
appendMessage(newMessage)
65-
set({ messageStream: '', isMessageBeingStreamed: false })
86+
set({
87+
messageStream: '',
88+
isMessageBeingStreamed: false,
89+
isResponseBeingGenerated: false,
90+
})
6691
},
6792
}))

0 commit comments

Comments
 (0)