mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] voice: WhisperFlow top waveform tab, synthesized start/stop chimes, polish capped at 900ms; settings: privacy + advanced unified onto the chip grammar
This commit is contained in:
@@ -13,7 +13,7 @@ const ERASE_WORD = 'ERASE';
|
||||
// The iOS Reset menu, two actions only: "Reset All Settings" (preferences back to defaults, your stuff + sign-in stay) and "Erase All Content and Settings" (factory wipe + relaunch). Flat rows, not a boxed "danger zone": red lives only on the destructive label, and the real friction is the typed-confirm in the dialog.
|
||||
const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) => {
|
||||
const c = useClaudeTokens();
|
||||
const { sectionSx, labelSx, descSx } = styles;
|
||||
const { labelSx, descSx, inlineRowSx, inlineRowLastSx } = styles;
|
||||
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
const [eraseOpen, setEraseOpen] = useState(false);
|
||||
@@ -108,29 +108,29 @@ const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) =>
|
||||
borderColor: c.status.error,
|
||||
'&:hover': { color: c.status.error, borderColor: c.status.error, bgcolor: c.status.errorBg },
|
||||
};
|
||||
const rowSx = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 3, py: 2 };
|
||||
|
||||
|
||||
return (
|
||||
<Box>
|
||||
|
||||
<Box sx={{ ...rowSx, borderBottom: `1px solid ${c.border.subtle}` }}>
|
||||
<Box>
|
||||
<Box sx={inlineRowSx}>
|
||||
<Box sx={{ mr: 3 }}>
|
||||
<Typography sx={labelSx}>Reset all settings</Typography>
|
||||
<Typography sx={descSx}>Puts your preferences back to defaults. Your apps, chats, skills, and sign-in stay.</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" size="small" onClick={() => { setErr(null); setResetOpen(true); }} sx={rowBtnSx}>Reset</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ ...rowSx, borderBottom: `1px solid ${c.border.subtle}` }}>
|
||||
<Box>
|
||||
<Box sx={inlineRowSx}>
|
||||
<Box sx={{ mr: 3 }}>
|
||||
<Typography sx={labelSx}>Clear browsing data</Typography>
|
||||
<Typography sx={descSx}>Signs you out of sites opened in browser cards and clears their cookies, cache, and local storage. Your chats, apps, and settings stay.</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" size="small" onClick={() => { setErr(null); setClearedOk(false); setClearOpen(true); }} sx={rowBtnSx}>Clear</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={rowSx}>
|
||||
<Box>
|
||||
<Box sx={inlineRowLastSx}>
|
||||
<Box sx={{ mr: 3 }}>
|
||||
<Typography sx={{ ...labelSx, color: c.status.error }}>Erase all content and settings</Typography>
|
||||
<Typography sx={descSx}>Removes every chat, app, skill, and setting and restarts OpenSwarm fresh. This can't be undone.</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -64,34 +64,23 @@ const GeneralAdvanced: React.FC<{
|
||||
|
||||
<Box sx={rowSx}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography sx={labelSx}>Version</Typography>
|
||||
<Typography sx={{ ...descSx, fontFamily: c.font.mono }}>
|
||||
{appVersion ?? '-'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={labelSx}>Version</Typography>
|
||||
<Typography sx={{ ...descSx, fontFamily: c.font.mono }}>{appVersion ?? '-'}</Typography>
|
||||
</Box>
|
||||
{buildLabel && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1 }}>
|
||||
<Typography sx={labelSx}>Build</Typography>
|
||||
<Typography sx={{ ...descSx, fontFamily: c.font.mono }}>{buildLabel}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{buildLabel && (
|
||||
<Box sx={rowSx}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography sx={labelSx}>Build</Typography>
|
||||
<Typography sx={{ ...descSx, fontFamily: c.font.mono }}>
|
||||
{buildLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<SoftwareUpdateRow styles={styles} />
|
||||
|
||||
<TrustedFilePatterns />
|
||||
|
||||
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Box sx={inlineRowLastSx}>
|
||||
<Box sx={{ mr: 3 }}>
|
||||
<Typography sx={{ ...labelSx, mb: 0.25 }}>Onboarding tour</Typography>
|
||||
<Typography sx={{ ...descSx, mb: 0 }}>
|
||||
Re-run the Show me walkthrough at any time.
|
||||
|
||||
@@ -98,6 +98,67 @@ const VoiceAurora: React.FC<{ volumeRef: React.MutableRefObject<number> }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// WhisperFlow's signature: a small capsule drops from the TOP edge while the mic is hot, carrying a
|
||||
// live waveform. Canvas bars driven straight off the mic level ring buffer, imperative rAF only.
|
||||
const BAR_COUNT = 26;
|
||||
|
||||
const VoiceTab: React.FC<{ volumeRef: React.MutableRefObject<number> }> = ({ volumeRef }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const history = useRef<number[]>(new Array(BAR_COUNT).fill(0.06));
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
let frame = 0;
|
||||
const draw = (): void => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
const g = canvas.getContext('2d');
|
||||
if (g) {
|
||||
// Shift one bar every other frame so the wave scrolls readably at 60Hz input.
|
||||
frame += 1;
|
||||
if (frame % 2 === 0) {
|
||||
history.current.push(Math.min(1, 0.08 + volumeRef.current * 1.6));
|
||||
history.current.shift();
|
||||
}
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
g.clearRect(0, 0, w, h);
|
||||
const bw = w / BAR_COUNT;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const level = history.current[i];
|
||||
const bh = Math.max(3, level * (h - 4));
|
||||
const x = i * bw + bw * 0.25;
|
||||
g.fillStyle = `rgba(255,255,255,${0.35 + level * 0.6})`;
|
||||
const bwid = bw * 0.5;
|
||||
const y = (h - bh) / 2;
|
||||
g.beginPath();
|
||||
g.roundRect(x, y, bwid, bh, bwid / 2);
|
||||
g.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(draw);
|
||||
};
|
||||
raf = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [volumeRef]);
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed', top: 10, left: '50%', zIndex: 2147483001, pointerEvents: 'none',
|
||||
display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.75, borderRadius: 999,
|
||||
background: 'rgba(18,12,26,0.88)',
|
||||
backdropFilter: 'blur(18px) saturate(150%)', WebkitBackdropFilter: 'blur(18px) saturate(150%)',
|
||||
boxShadow: '0 8px 28px rgba(0,0,0,0.4)',
|
||||
'@keyframes vtab-in': { from: { opacity: 0, transform: 'translate(-50%, -14px)' }, to: { opacity: 1, transform: 'translate(-50%, 0)' } },
|
||||
animation: 'vtab-in 0.22s cubic-bezier(0.2, 0.8, 0.2, 1) both',
|
||||
}}
|
||||
>
|
||||
<MicIcon sx={{ fontSize: 14, color: 'rgba(255,255,255,0.75)' }} />
|
||||
<canvas ref={canvasRef} width={132} height={22} style={{ display: 'block', width: 132, height: 22 }} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// The whole point: dictation must never look like "nothing happened." This floats a small status
|
||||
// card above the composer for every phase (listening, transcribing, downloading the model) and shows
|
||||
// the transcript + whether it was pasted or just copied. Non-interactive, auto-dismisses.
|
||||
@@ -124,7 +185,12 @@ const VoiceOverlay: React.FC = () => {
|
||||
const live = state !== 'idle';
|
||||
const visible = live || (showFeedback && !!feedback);
|
||||
if (!visible) return null;
|
||||
const aurora = state === 'recording' ? <VoiceAurora volumeRef={volumeRef} /> : null;
|
||||
const aurora = state === 'recording' ? (
|
||||
<>
|
||||
<VoiceAurora volumeRef={volumeRef} />
|
||||
<VoiceTab volumeRef={volumeRef} />
|
||||
</>
|
||||
) : null;
|
||||
|
||||
let content: React.ReactElement | null;
|
||||
if (state === 'recording') {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import { encodeWav, VOICE_SAMPLE_RATE } from './encodeWav';
|
||||
import { playVoiceCue } from './voiceCues';
|
||||
import { injectAtFocus } from './injectAtFocus';
|
||||
|
||||
export type VoiceState = 'idle' | 'recording' | 'transcribing' | 'preparing';
|
||||
@@ -117,6 +118,7 @@ export function useVoiceDictation() {
|
||||
node.connect(ctx.destination);
|
||||
recRef.current = { ctx, stream, node, source, chunks };
|
||||
setState('recording');
|
||||
playVoiceCue('start');
|
||||
// Warm the model the moment recording begins so transcription is instant on stop.
|
||||
void window.openswarm?.voiceWarmup?.();
|
||||
} catch (err) {
|
||||
@@ -131,6 +133,7 @@ export function useVoiceDictation() {
|
||||
const stop = useCallback(async (): Promise<void> => {
|
||||
if (stateRef.current !== 'recording') return;
|
||||
const samples = teardown();
|
||||
playVoiceCue('stop');
|
||||
setState('transcribing');
|
||||
try {
|
||||
if (!samples || samples.length < VOICE_SAMPLE_RATE * 0.2) { setState('idle'); return; } // < 0.2s = a misfire
|
||||
@@ -139,7 +142,12 @@ export function useVoiceDictation() {
|
||||
if (res?.ok && res.text) {
|
||||
// WhisperFlow-style cleanup: punctuation + filler words via the cheap aux tier, fail-open to
|
||||
// the raw transcript on any error/timeout so dictation never breaks with the aux down.
|
||||
const text = await polishText(res.text);
|
||||
// Latency: short phrases don't need the aux cleanup (whisper's raw output is fine), and a
|
||||
// slow aux must never hold the cursor hostage; 900ms is the most polish is allowed to cost.
|
||||
const raw = res.text.trim();
|
||||
const text = raw.split(/\s+/).length <= 6
|
||||
? raw
|
||||
: await Promise.race([polishText(raw), new Promise<string>((r) => window.setTimeout(() => r(raw), 900))]);
|
||||
setLastText(text);
|
||||
// Land the text where the user's cursor is: focused field, then focused browser page, then
|
||||
// the OS paste fallback (other apps). The floating bubble is just confirmation, not the output.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// WhisperFlow-grade start/stop chimes, synthesized in WebAudio so they ship weightless and always
|
||||
// match: a soft two-note rise on start ("I'm listening"), the mirrored fall on stop. Sine + gentle
|
||||
// lowpass + fast attack / exponential release = crisp but soothing, never a system beep.
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
|
||||
function ensureCtx(): AudioContext | null {
|
||||
try {
|
||||
if (!ctx || ctx.state === 'closed') ctx = new AudioContext();
|
||||
if (ctx.state === 'suspended') void ctx.resume();
|
||||
return ctx;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function blip(ac: AudioContext, freq: number, at: number, dur: number, peak: number): void {
|
||||
const osc = ac.createOscillator();
|
||||
const gain = ac.createGain();
|
||||
const lp = ac.createBiquadFilter();
|
||||
lp.type = 'lowpass';
|
||||
lp.frequency.value = 2400;
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(freq, at);
|
||||
gain.gain.setValueAtTime(0, at);
|
||||
gain.gain.linearRampToValueAtTime(peak, at + 0.012);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0004, at + dur);
|
||||
osc.connect(lp);
|
||||
lp.connect(gain);
|
||||
gain.connect(ac.destination);
|
||||
osc.start(at);
|
||||
osc.stop(at + dur + 0.02);
|
||||
}
|
||||
|
||||
export function playVoiceCue(kind: 'start' | 'stop'): void {
|
||||
const ac = ensureCtx();
|
||||
if (!ac) return;
|
||||
const t = ac.currentTime + 0.01;
|
||||
if (kind === 'start') {
|
||||
blip(ac, 587, t, 0.16, 0.055);
|
||||
blip(ac, 880, t + 0.085, 0.2, 0.05);
|
||||
} else {
|
||||
blip(ac, 880, t, 0.14, 0.045);
|
||||
blip(ac, 587, t + 0.075, 0.22, 0.05);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user