Show live mic levels and a transcribing status on voice dictation.
Make long speech-to-text waits obvious and match the Pipecat-style voice meter while recording. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -21,20 +21,24 @@ const emit = defineEmits<{
|
||||
error: [message: string];
|
||||
}>();
|
||||
|
||||
const BAR_COUNT = 16;
|
||||
const recording = ref(false);
|
||||
const transcribing = ref(false);
|
||||
const error = ref('');
|
||||
const seconds = ref(0);
|
||||
const bars = ref<number[]>(Array.from({ length: BAR_COUNT }, () => 0.12));
|
||||
|
||||
let stream: MediaStream | null = null;
|
||||
let ctx: AudioContext | null = null;
|
||||
let source: MediaStreamAudioSourceNode | null = null;
|
||||
let processor: ScriptProcessorNode | null = null;
|
||||
let analyser: AnalyserNode | null = null;
|
||||
let mute: GainNode | null = null;
|
||||
let chunks: Float32Array[] = [];
|
||||
let sampleRate = 16000;
|
||||
let timer: number | null = null;
|
||||
let limitTimer: number | null = null;
|
||||
let raf = 0;
|
||||
|
||||
const MAX_SECONDS = 90;
|
||||
|
||||
@@ -75,18 +79,42 @@ function stopTimer() {
|
||||
window.clearTimeout(limitTimer);
|
||||
limitTimer = null;
|
||||
}
|
||||
if (raf) {
|
||||
window.cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function resetBars() {
|
||||
bars.value = Array.from({ length: BAR_COUNT }, () => 0.12);
|
||||
}
|
||||
|
||||
function paintMeter() {
|
||||
if (!analyser || !recording.value) return;
|
||||
const freq = new Uint8Array(analyser.frequencyBinCount);
|
||||
analyser.getByteFrequencyData(freq);
|
||||
const next = bars.value.slice();
|
||||
for (let i = 0; i < BAR_COUNT; i += 1) {
|
||||
const idx = Math.min(freq.length - 1, Math.floor(2 + i * (freq.length * 0.42) / BAR_COUNT));
|
||||
const raw = (freq[idx] ?? 0) / 255;
|
||||
next[i] = next[i] * 0.45 + Math.max(0.08, raw) * 0.55;
|
||||
}
|
||||
bars.value = next;
|
||||
raf = window.requestAnimationFrame(paintMeter);
|
||||
}
|
||||
|
||||
function teardownMic() {
|
||||
stopTimer();
|
||||
try {
|
||||
processor?.disconnect();
|
||||
analyser?.disconnect();
|
||||
source?.disconnect();
|
||||
mute?.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
processor = null;
|
||||
analyser = null;
|
||||
source = null;
|
||||
mute = null;
|
||||
stream?.getTracks().forEach((track) => track.stop());
|
||||
@@ -95,6 +123,7 @@ function teardownMic() {
|
||||
void ctx.close().catch(() => undefined);
|
||||
ctx = null;
|
||||
}
|
||||
resetBars();
|
||||
}
|
||||
|
||||
function mergeChunks(parts: Float32Array[]) {
|
||||
@@ -199,6 +228,9 @@ async function start() {
|
||||
await ctx.resume();
|
||||
sampleRate = ctx.sampleRate || 16000;
|
||||
source = ctx.createMediaStreamSource(stream);
|
||||
analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 128;
|
||||
analyser.smoothingTimeConstant = 0.62;
|
||||
processor = ctx.createScriptProcessor(4096, 1, 1);
|
||||
mute = ctx.createGain();
|
||||
mute.gain.value = 0;
|
||||
@@ -207,10 +239,12 @@ async function start() {
|
||||
if (!recording.value) return;
|
||||
chunks.push(new Float32Array(event.inputBuffer.getChannelData(0)));
|
||||
};
|
||||
source.connect(analyser);
|
||||
source.connect(processor);
|
||||
processor.connect(mute);
|
||||
mute.connect(ctx.destination);
|
||||
seconds.value = 0;
|
||||
resetBars();
|
||||
timer = window.setInterval(() => {
|
||||
seconds.value += 1;
|
||||
}, 1000);
|
||||
@@ -218,6 +252,7 @@ async function start() {
|
||||
void finish(true);
|
||||
}, MAX_SECONDS * 1000);
|
||||
setRecording(true);
|
||||
paintMeter();
|
||||
} catch {
|
||||
teardownMic();
|
||||
setError('دسترسی به میکروفون داده نشد.');
|
||||
@@ -233,13 +268,17 @@ function toggle() {
|
||||
void start();
|
||||
}
|
||||
|
||||
function barHeight(value: number) {
|
||||
return `${6 + value * 22}px`;
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
void finish(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vtt-wrap" :class="variant">
|
||||
<div class="vtt-wrap" :class="[variant, { live: recording, wait: transcribing }]">
|
||||
<button
|
||||
class="vtt"
|
||||
type="button"
|
||||
@@ -249,9 +288,17 @@ onBeforeUnmount(() => {
|
||||
:title="ariaLabel"
|
||||
@click="toggle"
|
||||
>
|
||||
<UiIcon :name="recording ? 'stop' : 'mic'" :size="variant === 'toolbar' ? 18 : 16" />
|
||||
<span v-if="variant === 'default' && recording" class="timer">{{ timerLabel }}</span>
|
||||
<span v-else-if="variant === 'default' && transcribing" class="timer">تبدیل…</span>
|
||||
<span class="glyph" aria-hidden="true">
|
||||
<span v-if="transcribing" class="spinner" />
|
||||
<UiIcon v-else :name="recording ? 'stop' : 'mic'" :size="variant === 'toolbar' ? 18 : 16" />
|
||||
</span>
|
||||
|
||||
<span v-if="recording" class="meter" aria-hidden="true">
|
||||
<i v-for="(level, index) in bars" :key="index" :style="{ height: barHeight(level) }" />
|
||||
</span>
|
||||
|
||||
<span v-if="recording" class="timer">{{ timerLabel }}</span>
|
||||
<span v-else-if="transcribing" class="status">در حال تبدیل صوت به متن</span>
|
||||
</button>
|
||||
<p v-if="error && !hideError" class="err">{{ error }}</p>
|
||||
</div>
|
||||
@@ -267,7 +314,7 @@ onBeforeUnmount(() => {
|
||||
.vtt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid #d7e3dc;
|
||||
background: #f4faf7;
|
||||
color: #0f766e;
|
||||
@@ -285,22 +332,70 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.vtt.on {
|
||||
border-color: #f87171;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
border-color: #5eead4;
|
||||
background: #042f2e;
|
||||
color: #ecfdf5;
|
||||
min-height: 44px;
|
||||
padding-inline: 0.7rem 0.85rem;
|
||||
box-shadow: 0 10px 24px rgba(15, 118, 110, 0.22);
|
||||
}
|
||||
|
||||
.vtt.busy:not(.on) {
|
||||
opacity: 0.72;
|
||||
.vtt.busy {
|
||||
min-height: 44px;
|
||||
border-color: #99f6e4;
|
||||
background: #ecfdf5;
|
||||
color: #0f766e;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.vtt:disabled {
|
||||
.vtt:disabled:not(.busy) {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.glyph {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 28px;
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.meter i {
|
||||
display: block;
|
||||
width: 3px;
|
||||
border-radius: 99px;
|
||||
background: #5eead4;
|
||||
box-shadow: 0 0 8px rgba(94, 234, 212, 0.45);
|
||||
transition: height 70ms linear;
|
||||
}
|
||||
|
||||
.status {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 2.4rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #99f6e4;
|
||||
border-top-color: #0f766e;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.toolbar .vtt {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
@@ -312,12 +407,21 @@ onBeforeUnmount(() => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toolbar .vtt.on {
|
||||
background: #fef2f2;
|
||||
.toolbar.live .vtt,
|
||||
.toolbar.wait .vtt {
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-height: 40px;
|
||||
padding: 0 0.7rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-variant-numeric: tabular-nums;
|
||||
.toolbar .vtt.on {
|
||||
background: #042f2e;
|
||||
}
|
||||
|
||||
.toolbar .vtt.busy {
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.err {
|
||||
@@ -326,9 +430,18 @@ onBeforeUnmount(() => {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px rgba(248, 113, 113, 0.16);
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.status {
|
||||
max-width: 11.5rem;
|
||||
white-space: normal;
|
||||
text-align: start;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -458,7 +458,7 @@ onBeforeUnmount(() => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-wrap :deep(.vtt) {
|
||||
.tool-wrap :deep(.vtt:not(.on):not(.busy)) {
|
||||
color: #0d9488;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user