feat(voice): live dictation with browser pause restart and server audio chunking
This commit is contained in:
1 parent
446f8e4649
commit
6795edbf4b
5 files changed
+415
-77
No files matched your search
@@ -9,16 +9,24 @@ interface DictationBlockProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function formatDuration(sec: number): string {
|
||||
const m = String(Math.floor(sec / 60)).padStart(2, '0');
|
||||
const s = String(sec % 60).padStart(2, '0');
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
// What an empty bullet turns into after /diktat: listening starts right away, the whole bullet is one
|
||||
// button with the live wave and a translucent "Stopp" on top. A tap ends it; the text lands in the
|
||||
// bullet as plain text.
|
||||
// button with live text growing above the wave, mm:ss timer and a translucent "Stopp" on top. A tap ends
|
||||
// it; the text lands in the bullet as plain text.
|
||||
export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel }) => {
|
||||
const [interim, setInterim] = useState('');
|
||||
const [elapsedSec, setElapsedSec] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const doneRef = useRef(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const voice = useVoiceInput({
|
||||
live: true,
|
||||
continuous: true,
|
||||
onText: (text, final) => {
|
||||
if (!final) {
|
||||
@@ -45,11 +53,17 @@ export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel
|
||||
triggerHaptic([15, 60, 25]);
|
||||
void voice.start();
|
||||
buttonRef.current?.focus();
|
||||
const started = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
setElapsedSec(Math.floor((Date.now() - started) / 1000));
|
||||
}, 500);
|
||||
return () => window.clearInterval(timer);
|
||||
// start once when the bullet turns into the dictation
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const transcribing = voice.state === 'transcribing';
|
||||
const chunkBusy = voice.pendingChunks > 0 || transcribing;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -71,27 +85,35 @@ export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel
|
||||
}}
|
||||
disabled={transcribing || !!error}
|
||||
aria-label="Diktat beenden"
|
||||
className="relative w-full min-h-[56px] sm:min-h-[44px] my-1 rounded-card border border-accent bg-accent-dim overflow-hidden flex items-center justify-center cursor-pointer disabled:cursor-default focus:outline-none"
|
||||
className="relative w-full min-h-[56px] sm:min-h-[44px] my-1 px-3 py-2 rounded-card border border-accent bg-accent-dim overflow-hidden flex flex-col gap-1.5 text-left cursor-pointer disabled:cursor-default focus:outline-none"
|
||||
>
|
||||
{/* Live wave across the whole bullet */}
|
||||
<VoiceWave getLevel={voice.level} bars={32} className="absolute inset-x-3 inset-y-2 justify-between h-auto text-accent opacity-60" />
|
||||
{interim && !error && (
|
||||
<span className="absolute inset-x-3 bottom-1 text-[11px] text-main truncate text-left pointer-events-none">{interim}</span>
|
||||
{(interim || chunkBusy) && !error && (
|
||||
<div className="w-full text-sm text-main whitespace-pre-wrap break-words leading-relaxed pointer-events-none">
|
||||
{interim}
|
||||
{chunkBusy && <span className="text-muted animate-pulse">{interim ? ' …' : '…'}</span>}
|
||||
</div>
|
||||
)}
|
||||
{/* Floating stop label on top */}
|
||||
<span className="relative z-10 flex items-center gap-1.5 px-3 py-1 rounded-pill bg-panel/70 text-main text-xs font-semibold backdrop-blur-sm">
|
||||
{error ? (
|
||||
<span className="text-status-err font-normal">{error}</span>
|
||||
) : transcribing ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Wird erkannt…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Square className="w-3 h-3 fill-current" /> Stopp
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<div className="relative w-full min-h-[32px] flex items-center justify-between gap-2">
|
||||
{/* Live wave across the bottom bar */}
|
||||
<VoiceWave getLevel={voice.level} bars={32} className="absolute inset-x-0 inset-y-0 justify-between h-auto text-accent opacity-60" />
|
||||
<span className="relative z-10 px-2 py-0.5 rounded-pill bg-panel/70 text-muted text-[11px] font-mono tabular-nums backdrop-blur-sm">
|
||||
{formatDuration(elapsedSec)}
|
||||
</span>
|
||||
{/* Floating stop label on top */}
|
||||
<span className="relative z-10 flex items-center gap-1.5 px-3 py-1 rounded-pill bg-panel/70 text-main text-xs font-semibold backdrop-blur-sm">
|
||||
{error ? (
|
||||
<span className="text-status-err font-normal">{error}</span>
|
||||
) : transcribing ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Wird erkannt…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Square className="w-3 h-3 fill-current" /> Stopp
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user