Skip to content

Commit d8b7b4e

Browse files
committed
polish speechinput
1 parent 940c94b commit d8b7b4e

File tree

5 files changed

+140
-47
lines changed

5 files changed

+140
-47
lines changed

next/README.md

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
## future abstractions
22

3-
- batching translations
4-
53
## now
64

7-
- script to play cartesia audio to loopback device
5+
- flags for langs
86
- unified Speak (Pause) / Type (Submit) input
7+
- transcript view
98

109
## future
1110

next/bun.lock

+11
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"@cartesia/cartesia-js": "^2.1.6",
1111
"@deepgram/sdk": "^3.3.0",
1212
"@radix-ui/react-label": "^2.1.2",
13+
"@radix-ui/react-popover": "^1.1.6",
1314
"@radix-ui/react-progress": "^1.1.2",
1415
"@radix-ui/react-select": "^2.1.6",
1516
"@radix-ui/react-slot": "^1.1.2",
@@ -18,13 +19,15 @@
1819
"class-variance-authority": "^0.7.1",
1920
"classnames": "^2.5.1",
2021
"clsx": "^2.1.1",
22+
"country-flag-icons": "^1.5.16",
2123
"lucide-react": "^0.475.0",
2224
"nanoid": "^5.1.0",
2325
"next": "15.1.7",
2426
"react": "19.0.0",
2527
"react-device-detect": "^2.2.3",
2628
"react-dom": "19.0.0",
2729
"react-github-btn": "^1.4.0",
30+
"react-resizable-panels": "^2.1.7",
2831
"react-syntax-highlighter": "^15.5.0",
2932
"tailwind-merge": "^3.0.1",
3033
"tailwindcss-animate": "^1.0.7",
@@ -300,10 +303,14 @@
300303

301304
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw=="],
302305

306+
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.2", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg=="],
307+
303308
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.2", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-rect": "1.1.0", "@radix-ui/react-use-size": "1.1.0", "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA=="],
304309

305310
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="],
306311

312+
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg=="],
313+
307314
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.2", "", { "dependencies": { "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w=="],
308315

309316
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.2", "", { "dependencies": { "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA=="],
@@ -574,6 +581,8 @@
574581

575582
"cosmiconfig-typescript-loader": ["cosmiconfig-typescript-loader@5.0.0", "", { "dependencies": { "jiti": "^1.19.1" }, "peerDependencies": { "@types/node": "*", "cosmiconfig": ">=8.2", "typescript": ">=4" } }, "sha512-+8cK7jRAReYkMwMiG+bxhcNKiHJDM6bR9FD/nGBXOWdMLuYawjF5cGrtLilJ+LGd3ZjCXnJjR5DkfWPoIVlqJA=="],
576583

584+
"country-flag-icons": ["country-flag-icons@1.5.16", "", {}, "sha512-F9lNvhSrJ9D7Y2a6Tvbx2MFglZ9esNK76uTy4NqvdVzvgvy6/cKMGDYcnR1QOCgtmdc+akz2gqibZn3e3b6rQA=="],
585+
577586
"cross-fetch": ["cross-fetch@3.1.8", "", { "dependencies": { "node-fetch": "^2.6.12" } }, "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg=="],
578587

579588
"cross-spawn": ["cross-spawn@7.0.3", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w=="],
@@ -1242,6 +1251,8 @@
12421251

12431252
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
12441253

1254+
"react-resizable-panels": ["react-resizable-panels@2.1.7", "", { "peerDependencies": { "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA=="],
1255+
12451256
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
12461257

12471258
"react-syntax-highlighter": ["react-syntax-highlighter@15.5.0", "", { "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "lowlight": "^1.17.0", "prismjs": "^1.27.0", "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg=="],

next/components/main-view.tsx

+8-3
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const MainView: () => JSX.Element = () => {
4444
Language.SPANISH_MX
4545
);
4646
const [inputLanguage, setInputLanguage] = useState<Language>(Language.ENGLISH_US);
47+
const [isPaused, setIsPaused] = useState(false);
4748

4849
const scrollToBottom = () => {
4950
if (!scrollContainerRef.current) return;
@@ -108,6 +109,8 @@ const MainView: () => JSX.Element = () => {
108109
if (!microphone || !connection) return;
109110

110111
const onData = (e: BlobEvent) => {
112+
if (isPaused) return;
113+
111114
if (connectionState === LiveConnectionState.OPEN) {
112115
connection.send(e.data);
113116
}
@@ -162,17 +165,17 @@ const MainView: () => JSX.Element = () => {
162165
microphone,
163166
connection,
164167
startMicrophone,
168+
isPaused
165169
]);
166170

167171
useEffect(() => {
168172
if (!connection) return;
169173

170174
if (
171-
microphoneState !== MicrophoneState.Open &&
175+
(microphoneState !== MicrophoneState.Open || isPaused) &&
172176
connectionState === LiveConnectionState.OPEN
173177
) {
174178
connection.keepAlive();
175-
176179
keepAliveInterval.current = setInterval(() => {
177180
connection.keepAlive();
178181
}, 10000);
@@ -184,7 +187,7 @@ const MainView: () => JSX.Element = () => {
184187
clearInterval(keepAliveInterval.current);
185188
};
186189
// eslint-disable-next-line react-hooks/exhaustive-deps
187-
}, [microphoneState, connectionState]);
190+
}, [microphoneState, connectionState, isPaused]);
188191

189192
useEffect(() => {
190193
// Only translate if languages differ
@@ -376,6 +379,8 @@ const MainView: () => JSX.Element = () => {
376379
onSubmit={handleSubmit}
377380
microphone={microphone ?? undefined}
378381
speechState={managerRef.current}
382+
onTogglePause={() => setIsPaused((prev) => !prev)}
383+
isPaused={isPaused}
379384
/>
380385
</div>
381386
</div>

next/components/speech-input.tsx

+118-41
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,34 @@
22

33
import { Textarea } from "@/components/ui/textarea";
44
import { Button } from "@/components/ui/button";
5-
import { ArrowUp } from "lucide-react";
6-
import Visualizer from "./Visualizer";
5+
import { Mic, Keyboard, ArrowUp, PauseIcon, PlayIcon } from "lucide-react";
6+
import Visualizer from "./visualizer";
77
import { SpeechStateManager } from "@/lib/speech-state";
8-
import { useEffect, useState } from "react";
8+
import { useEffect, useState, useRef } from "react";
99

1010
interface SpeechInputProps {
1111
value: string;
1212
onChange: (value: string) => void;
1313
onSubmit: () => void;
1414
microphone?: MediaRecorder;
15-
placeholder?: string;
1615
speechState?: SpeechStateManager;
16+
onTogglePause?: () => void;
17+
isPaused?: boolean;
1718
}
1819

1920
export const SpeechInput = ({
2021
value,
2122
onChange,
2223
onSubmit,
2324
microphone,
24-
placeholder = "Type or speak...",
2525
speechState,
26+
onTogglePause,
27+
isPaused,
2628
}: SpeechInputProps) => {
29+
const [activeInput, setActiveInput] = useState<"record" | "text" | null>(null);
30+
const [expandedInput, setExpandedInput] = useState<"record" | "text" | null>(null);
2731
const [isSpeechActive, setIsSpeechActive] = useState(false);
32+
const textareaRef = useRef<HTMLTextAreaElement>(null);
2833

2934
useEffect(() => {
3035
if (!speechState) return;
@@ -35,50 +40,122 @@ export const SpeechInput = ({
3540
return () => clearInterval(intervalId);
3641
}, [speechState]);
3742

43+
useEffect(() => {
44+
// Add click outside listener
45+
const handleClickOutside = (e: MouseEvent) => {
46+
const target = e.target as HTMLElement;
47+
if (!target.closest('.input-container')) {
48+
setExpandedInput(null);
49+
}
50+
};
51+
document.addEventListener('mousedown', handleClickOutside);
52+
return () => document.removeEventListener('mousedown', handleClickOutside);
53+
}, []);
54+
55+
useEffect(() => {
56+
if (activeInput === "text") {
57+
textareaRef.current?.focus();
58+
}
59+
}, [activeInput]);
60+
3861
const handleSubmit = () => {
3962
if (!value.trim()) return;
4063
onSubmit();
4164
};
4265

4366
return (
44-
<div className="relative min-h-[48px]">
45-
{microphone && <Visualizer microphone={microphone} />}
46-
<div className="relative w-full">
47-
{isSpeechActive && (
48-
<div className="absolute -inset-[2px] rounded-md border-2 border-muted-foreground animate-pulse z-0"></div>
49-
)}
50-
<Textarea
51-
value={value}
52-
onChange={(e) => onChange(e.target.value)}
53-
placeholder={placeholder}
54-
className={`resize-none min-h-[48px] max-h-[200px] overflow-y-auto text-2xl pr-12 bg-transparent relative z-10 ${
55-
isSpeechActive ? "" : "border border-input"
67+
<div className="flex items-center space-x-2 input-container">
68+
<div
69+
onClick={() => {
70+
setActiveInput("record");
71+
setExpandedInput("record");
72+
}}
73+
className={`transition-all duration-300 ${
74+
expandedInput === null ? "flex-1 h-12" :
75+
expandedInput === "record" ? "flex-1 h-12" : "w-12 h-12"
76+
}`}
77+
>
78+
<div
79+
className={`w-full h-full rounded-md relative hover:bg-accent hover:text-accent-foreground ${
80+
isSpeechActive ? "border-2 border-muted-foreground animate-pulse" : ""
5681
}`}
57-
rows={1}
58-
style={{ height: "auto" }}
59-
onKeyDown={(e) => {
60-
if (e.key === "Enter" && !e.shiftKey) {
61-
e.preventDefault();
62-
handleSubmit();
63-
}
64-
}}
65-
onInput={(e) => {
66-
const target = e.target as HTMLTextAreaElement;
67-
target.style.height = "auto";
68-
target.style.height = `${target.scrollHeight}px`;
69-
}}
70-
/>
71-
</div>
72-
{value && (
73-
<Button
74-
size="icon"
75-
className="border border-black rounded-full absolute right-1 bottom-1 z-20"
76-
variant="ghost"
77-
onClick={handleSubmit}
7882
>
79-
<ArrowUp className="h-4 w-4" />
80-
</Button>
81-
)}
83+
{microphone && <Visualizer microphone={microphone} />}
84+
<div className="absolute inset-0 flex items-center justify-center gap-2">
85+
<Mic className={`w-6 h-6 ${isPaused ? "text-muted-foreground" : ""}`} />
86+
{expandedInput === "record" && (
87+
<span className={`text-sm ${isPaused ? "text-muted-foreground" : ""}`}>
88+
{isPaused ? "Paused" : "Listening"}
89+
</span>
90+
)}
91+
</div>
92+
93+
{(expandedInput === "record" || activeInput === "record") && onTogglePause && (
94+
<Button
95+
variant="ghost"
96+
size="icon"
97+
className="absolute left-1 top-1/2 -translate-y-1/2"
98+
onClick={(e) => {
99+
e.stopPropagation();
100+
onTogglePause();
101+
}}
102+
>
103+
{isPaused ? (
104+
<PlayIcon className="w-4 h-4" />
105+
) : (
106+
<PauseIcon className="w-4 h-4" />
107+
)}
108+
</Button>
109+
)}
110+
</div>
111+
</div>
112+
113+
<div
114+
onClick={() => {
115+
setActiveInput("text");
116+
setExpandedInput("text");
117+
}}
118+
className={`transition-all duration-300 relative ${
119+
expandedInput === null ? "flex-1 h-12" :
120+
expandedInput === "text" ? "flex-1 h-12" : "w-12 h-12"
121+
}`}
122+
>
123+
{expandedInput === "text" ? (
124+
<>
125+
<Textarea
126+
ref={textareaRef}
127+
value={value}
128+
onChange={(e) => onChange(e.target.value)}
129+
placeholder="Type"
130+
className="w-full h-full rounded-md transition-all duration-300 pr-12"
131+
rows={1}
132+
onKeyDown={(e) => {
133+
if (e.key === "Enter" && !e.shiftKey) {
134+
e.preventDefault();
135+
handleSubmit();
136+
}
137+
}}
138+
/>
139+
{value && (
140+
<Button
141+
size="icon"
142+
className="absolute right-1 top-1/2 -translate-y-1/2"
143+
variant="ghost"
144+
onClick={handleSubmit}
145+
>
146+
<ArrowUp className="h-4 w-4" />
147+
</Button>
148+
)}
149+
</>
150+
) : (
151+
<Button variant="ghost" className="w-full h-full rounded-md relative">
152+
<div className="absolute inset-0 flex items-center justify-center gap-2">
153+
<Keyboard className="w-6 h-6" />
154+
{expandedInput === null && <span className="text-base">Type</span>}
155+
</div>
156+
</Button>
157+
)}
158+
</div>
82159
</div>
83160
);
84161
};

next/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"class-variance-authority": "^0.7.1",
3131
"classnames": "^2.5.1",
3232
"clsx": "^2.1.1",
33+
"country-flag-icons": "^1.5.16",
3334
"lucide-react": "^0.475.0",
3435
"nanoid": "^5.1.0",
3536
"next": "15.1.7",

0 commit comments

Comments
 (0)