feat(voice): all microphone buttons use the server's speech recognition
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
- New useVoiceInput hook (lib/voice.ts): "server" records with MediaRecorder and sends to /voice/transcribe, "browser" keeps the live recognition, "off" or missing browser support hides the microphone. - Quick dump bar, mobile capture sheet and hold-to-record use it; live waves from the real input level (VoiceWave) and "Wird erkannt…". - Recordings stop after STT_MAX_SECONDS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7801cd13b9
commit
7fa21e485b
9 files changed
+753
-248
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
interface VoiceWaveProps {
|
||||
/** Input level 0..1; null (unknown, e.g. browser recognition) shows a calm idle wave. */
|
||||
getLevel: () => number | null;
|
||||
bars?: number;
|
||||
/** Bar colour and size come from the caller, e.g. "h-4 text-accent". */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Live wave reacting to the voice. Heights are set directly on the bars every frame, so React does
|
||||
// not re-render 60 times a second.
|
||||
export const VoiceWave: React.FC<VoiceWaveProps> = ({ getLevel, bars = 5, className = 'h-4 text-accent' }) => {
|
||||
const barRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||
const getLevelRef = useRef(getLevel);
|
||||
getLevelRef.current = getLevel;
|
||||
|
||||
useEffect(() => {
|
||||
const history: number[] = new Array(bars).fill(0);
|
||||
let frame = 0;
|
||||
let raf = 0;
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
const tick = () => {
|
||||
frame++;
|
||||
const measured = getLevelRef.current();
|
||||
const level = measured ?? 0.25 + 0.2 * Math.sin(frame / 9);
|
||||
// Newest level enters in the middle and travels outwards
|
||||
if (frame % 3 === 0) {
|
||||
history.pop();
|
||||
history.unshift(level);
|
||||
}
|
||||
const mid = Math.floor(bars / 2);
|
||||
barRefs.current.forEach((el, i) => {
|
||||
if (!el) return;
|
||||
const v = reduceMotion ? 0.5 : history[Math.abs(i - mid)] ?? 0;
|
||||
el.style.transform = `scaleY(${Math.max(0.15, Math.min(1, v * 1.4))})`;
|
||||
});
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [bars]);
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-[3px] ${className}`} aria-hidden="true">
|
||||
{Array.from({ length: bars }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
barRefs.current[i] = el;
|
||||
}}
|
||||
className="w-[3px] h-full rounded-full bg-current origin-center transition-transform duration-75"
|
||||
style={{ transform: 'scaleY(0.15)' }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user