Skip to content

Commit f6c58fa

Browse files
nyra voice beta
1 parent 5f15bbc commit f6c58fa

11 files changed

Lines changed: 860 additions & 66 deletions

src/components/NyraChatbot.tsx

Lines changed: 348 additions & 65 deletions
Large diffs are not rendered by default.

src/components/NyraMessage.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// src/components/NyraMessage.tsx
2+
import { useState, useEffect } from 'react';
3+
import ReactMarkdown from 'react-markdown';
4+
import { motion } from 'framer-motion';
5+
import { FiLoader } from 'react-icons/fi';
6+
7+
interface NyraMessageProps {
8+
fullText: string;
9+
isTyping: boolean;
10+
onTypingComplete: () => void;
11+
typingSpeed?: number;
12+
}
13+
14+
// Helper to clean markdown for the speech engine
15+
export const cleanTextForSpeech = (text: string) => {
16+
return text
17+
.replace(/(\*\*|__)(.*?)\1/g, '$2') // Remove bold
18+
.replace(/(\*|_)(.*?)\1/g, '$2') // Remove italic
19+
.replace(/`([^`]+)`/g, '$1') // Remove inline code
20+
.replace(/#+\s/g, '') // Remove headings
21+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // Remove links
22+
};
23+
24+
const NyraMessage = ({ fullText, isTyping, onTypingComplete, typingSpeed = 100 }: NyraMessageProps) => {
25+
const [typedText, setTypedText] = useState('');
26+
27+
useEffect(() => {
28+
setTypedText(''); // Reset on new message
29+
if (!isTyping || fullText === "...") {
30+
// If it's a "thinking" message or not supposed to type, show it
31+
setTypedText(fullText);
32+
onTypingComplete();
33+
return;
34+
}
35+
36+
const words = fullText.split(' ');
37+
let i = 0;
38+
const timer = setInterval(() => {
39+
if (i < words.length) {
40+
setTypedText(prev => prev + words[i] + ' ');
41+
i++;
42+
} else {
43+
clearInterval(timer);
44+
onTypingComplete();
45+
}
46+
}, typingSpeed);
47+
48+
return () => {
49+
clearInterval(timer);
50+
};
51+
}, [fullText, isTyping, onTypingComplete, typingSpeed]);
52+
53+
// Show "thinking" spinner
54+
if (fullText === "...") {
55+
return (
56+
<motion.div
57+
className="flex items-center gap-2 text-slate-500"
58+
initial={{ opacity: 0 }}
59+
animate={{ opacity: 1 }}
60+
>
61+
<motion.div animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 1, ease: "linear" }}>
62+
<FiLoader />
63+
</motion.div>
64+
Nyra is thinking...
65+
</motion.div>
66+
);
67+
}
68+
69+
// Show text (typed or final)
70+
return <ReactMarkdown>{typedText}</ReactMarkdown>;
71+
};
72+
73+
export default NyraMessage;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { motion, AnimatePresence } from 'framer-motion';
2+
import { FiMic, FiLoader, FiSquare } from 'react-icons/fi';
3+
4+
interface NyraVoiceVisualizerProps {
5+
state: 'listening' | 'thinking' | 'transcribing' | 'idle';
6+
onClick: () => void;
7+
}
8+
9+
export default function NyraVoiceVisualizer({ state, onClick }: NyraVoiceVisualizerProps) {
10+
const isBusy = state === 'thinking' || state === 'transcribing';
11+
12+
return (
13+
<div className="p-4 border-t border-slate-200 flex flex-col items-center justify-center bg-white rounded-b-xl h-[110px] relative">
14+
<motion.button
15+
key={state}
16+
onClick={onClick}
17+
disabled={isBusy}
18+
aria-pressed={state === 'listening'}
19+
className={`w-20 h-20 text-white rounded-full flex items-center justify-center shadow-2xl focus:outline-none relative transition-colors
20+
${state === 'listening' ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'}
21+
${isBusy ? 'bg-slate-400 cursor-not-allowed' : ''}
22+
`}
23+
initial={{ scale: 0.75, opacity: 0 }}
24+
animate={{ scale: 1, opacity: 1 }}
25+
transition={{ type: 'spring', stiffness: 400, damping: 20 }}
26+
>
27+
{/* Soft pulsing rings when listening */}
28+
<AnimatePresence>
29+
{state === 'listening' && (
30+
<motion.div
31+
className="absolute w-full h-full rounded-full bg-red-500/30"
32+
initial={{ scale: 1, opacity: 0.6 }}
33+
animate={{ scale: 2.2, opacity: 0 }}
34+
exit={{ opacity: 0 }}
35+
transition={{ repeat: Infinity, duration: 1.6, ease: 'easeInOut' }}
36+
/>
37+
)}
38+
</AnimatePresence>
39+
40+
{/* Icon and tiny waveform indicator */}
41+
{isBusy ? (
42+
<motion.div animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 1, ease: 'linear' }}>
43+
<FiLoader size={28} />
44+
</motion.div>
45+
) : (
46+
<div className="flex flex-col items-center gap-1">
47+
{state === 'listening' ? <FiSquare size={22} /> : <FiMic size={22} />}
48+
<div className="w-16 h-2 bg-white/20 rounded-full overflow-hidden">
49+
<div className="h-full bg-white/60 animate-pulse" style={{ width: state === 'listening' ? '70%' : '20%' }} />
50+
</div>
51+
</div>
52+
)}
53+
</motion.button>
54+
55+
<div className="text-xs text-slate-600 mt-3 flex flex-col items-center">
56+
<div>
57+
{state === 'listening' && 'Listening — auto-stops on silence'}
58+
{state === 'transcribing' && 'Transcribing...'}
59+
{state === 'thinking' && 'Nyra is thinking...'}
60+
{state === 'idle' && 'Tap to speak'}
61+
</div>
62+
<div className="text-[10px] text-slate-400">Tip: tap to stop, or remain silent to auto-stop</div>
63+
</div>
64+
</div>
65+
);
66+
}

src/components/ui/MobileMenu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { motion, AnimatePresence } from 'framer-motion';
22
import { FiX, FiMail } from 'react-icons/fi';
33
import SocialLinks from './SocialLinks';
4-
import MyMind from '../../../public/android-chrome-512x512.png.png';
4+
import MyMind from '../../../public/android-chrome-512x512.png';
55
import { Link } from 'react-router-dom';
66
// info icon import
77
import { FiInfo } from 'react-icons/fi';

src/hooks/useAudioPlayer.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useState, useRef, useCallback } from 'react';
2+
3+
export const useAudioPlayer = () => {
4+
const [isSpeaking, setIsSpeaking] = useState(false);
5+
const [isMuted, setIsMuted] = useState(false);
6+
const audioRef = useRef<HTMLAudioElement | null>(null);
7+
8+
const play = useCallback((audioBlob: Blob, onEnd: () => void) => {
9+
if (isMuted) {
10+
onEnd(); // Call onEnd immediately if muted
11+
return;
12+
}
13+
if (!audioRef.current) {
14+
audioRef.current = new Audio();
15+
}
16+
const audioUrl = URL.createObjectURL(audioBlob);
17+
audioRef.current.src = audioUrl;
18+
19+
audioRef.current.onplay = () => setIsSpeaking(true);
20+
audioRef.current.onended = () => {
21+
setIsSpeaking(false);
22+
URL.revokeObjectURL(audioUrl); // Clean up
23+
onEnd(); // This is the crucial callback for the loop
24+
};
25+
audioRef.current.onerror = () => {
26+
setIsSpeaking(false);
27+
URL.revokeObjectURL(audioUrl);
28+
onEnd();
29+
};
30+
audioRef.current.play().catch(e => {
31+
console.error("Audio play failed:", e);
32+
onEnd();
33+
});
34+
}, [isMuted]);
35+
36+
const stop = useCallback(() => {
37+
if (audioRef.current) {
38+
audioRef.current.pause();
39+
audioRef.current.src = "";
40+
}
41+
setIsSpeaking(false);
42+
}, []);
43+
44+
const toggleMute = useCallback(() => {
45+
setIsMuted(prev => {
46+
if (!prev === true) stop(); // If muting, stop playback
47+
return !prev;
48+
});
49+
}, [stop]);
50+
51+
return { play, stop, isSpeaking, isMuted, toggleMute };
52+
};

0 commit comments

Comments
 (0)