diff --git a/apps/web/src/components/VoiceToTextButton.vue b/apps/web/src/components/VoiceToTextButton.vue index 7cff061..e309b59 100644 --- a/apps/web/src/components/VoiceToTextButton.vue +++ b/apps/web/src/components/VoiceToTextButton.vue @@ -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(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); });