AI-powered speech-to-text transformation

This commit is contained in:
2026-09-23 13:58:43 +03:30
parent 58c3e25db6
commit 230d7b3bf6
4 changed files with 120 additions and 420 deletions
+116 -302
View File
@@ -1,10 +1,30 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
type SpeechResult = {
isFinal: boolean;
0: { transcript: string };
};
type SpeechResultEvent = {
resultIndex: number;
results: ArrayLike<SpeechResult>;
};
type SpeechRec = {
lang: string;
continuous: boolean;
interimResults: boolean;
start: () => void;
stop: () => void;
abort: () => void;
onresult: ((ev: SpeechResultEvent) => void) | null;
onerror: ((ev: { error: string }) => void) | null;
onend: (() => void) | null;
};
const props = withDefaults(
defineProps<{
disabled?: boolean;
@@ -21,38 +41,35 @@ 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 UNSUPPORTED = 'تبدیل صوت به متن در این مرورگر پشتیبانی نمی‌شود. از Chrome استفاده کنید.';
const MAX_SECONDS = 90;
const busy = computed(() => recording.value || transcribing.value);
const recording = ref(false);
const error = ref('');
const seconds = ref(0);
let recognition: SpeechRec | null = null;
let timer: number | null = null;
let limitTimer: number | null = null;
let stopping = false;
let failed = false;
const timerLabel = computed(() => {
const m = String(Math.floor(seconds.value / 60)).padStart(2, '0');
const s = String(seconds.value % 60).padStart(2, '0');
return toFaDigits(`${m}:${s}`);
});
const ariaLabel = computed(() => {
if (transcribing.value) return 'در حال تبدیل صوت به متن';
if (recording.value) return 'توقف ضبط و تبدیل به متن';
return 'تبدیل صوت به متن';
});
const ariaLabel = computed(() =>
recording.value ? 'توقف تبدیل صوت به متن' : 'تبدیل صوت به متن',
);
function speechCtor(): (new () => SpeechRec) | null {
const w = window as Window & {
SpeechRecognition?: new () => SpeechRec;
webkitSpeechRecognition?: new () => SpeechRec;
};
return w.SpeechRecognition || w.webkitSpeechRecognition || null;
}
function setError(message: string) {
error.value = message;
@@ -62,12 +79,7 @@ function setError(message: string) {
function setRecording(active: boolean) {
recording.value = active;
emit('recording', active);
emit('busy', busy.value);
}
function setTranscribing(active: boolean) {
transcribing.value = active;
emit('busy', recording.value || active);
emit('busy', active);
}
function stopTimer() {
@@ -79,250 +91,115 @@ 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() {
function stop() {
stopping = true;
stopTimer();
try {
processor?.disconnect();
analyser?.disconnect();
source?.disconnect();
mute?.disconnect();
recognition?.stop();
} catch {
/* ignore */
/* already stopped */
}
processor = null;
analyser = null;
source = null;
mute = null;
stream?.getTracks().forEach((track) => track.stop());
stream = null;
if (ctx) {
void ctx.close().catch(() => undefined);
ctx = null;
}
resetBars();
}
function mergeChunks(parts: Float32Array[]) {
const length = parts.reduce((sum, part) => sum + part.length, 0);
const out = new Float32Array(length);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
/** Downsample to 16 kHz so STT does not depend on server ffmpeg. */
function resampleTo16k(samples: Float32Array, srcRate: number) {
const dstRate = 16000;
if (!srcRate || srcRate === dstRate) return { samples, rate: srcRate || dstRate };
const ratio = srcRate / dstRate;
const newLen = Math.max(1, Math.floor(samples.length / ratio));
const out = new Float32Array(newLen);
for (let i = 0; i < newLen; i += 1) {
const srcIndex = i * ratio;
const i0 = Math.floor(srcIndex);
const i1 = Math.min(samples.length - 1, i0 + 1);
const t = srcIndex - i0;
const a = samples[i0] ?? 0;
const b = samples[i1] ?? a;
out[i] = a + (b - a) * t;
}
return { samples: out, rate: dstRate };
}
function encodeWav(samples: Float32Array, rate: number) {
const bytes = samples.length * 2;
const buffer = new ArrayBuffer(44 + bytes);
const view = new DataView(buffer);
const write = (offset: number, text: string) => {
for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
};
write(0, 'RIFF');
view.setUint32(4, 36 + bytes, true);
write(8, 'WAVE');
write(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, rate, true);
view.setUint32(28, rate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
write(36, 'data');
view.setUint32(40, bytes, true);
let offset = 44;
for (let i = 0; i < samples.length; i += 1) {
const sample = Math.max(-1, Math.min(1, samples[i] ?? 0));
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
offset += 2;
}
return new Blob([buffer], { type: 'audio/wav' });
}
async function upload(blob: Blob) {
const form = new FormData();
form.append('audio', blob, 'dictation.wav');
const { data } = await api.post<{ text: string }>('/ai/transcribe', form);
return (data.text ?? '').trim();
}
async function finish(keep: boolean) {
const parts = chunks;
chunks = [];
const rate = sampleRate;
teardownMic();
recognition = null;
setRecording(false);
if (!keep) return;
const samples = mergeChunks(parts);
if (samples.length < rate * 0.35) {
setError('صدای کافی ضبط نشد. کمی بیشتر صحبت کنید.');
return;
}
setTranscribing(true);
try {
const prepared = resampleTo16k(samples, rate);
const text = await upload(encodeWav(prepared.samples, prepared.rate));
if (!text) {
setError('متنی از صدا تشخیص داده نشد.');
return;
}
emit('transcript', text);
} catch (err) {
if (axios.isAxiosError(err)) {
const msg = (err.response?.data as { message?: string | string[] })?.message;
setError(Array.isArray(msg) ? msg.join('، ') : (msg ?? 'تبدیل صوت به متن ناموفق بود'));
} else {
setError('تبدیل صوت به متن ناموفق بود');
}
} finally {
setTranscribing(false);
}
}
async function start() {
function start() {
setError('');
if (!navigator.mediaDevices?.getUserMedia) {
setError('این مرورگر به میکروفون دسترسی نمی‌دهد.');
const Ctor = speechCtor();
if (!Ctor) {
setError(UNSUPPORTED);
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 },
});
const Ctor = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) {
setError('ضبط صدا در این مرورگر پشتیبانی نمی‌شود.');
stream.getTracks().forEach((track) => track.stop());
stream = null;
const rec = new Ctor();
rec.lang = 'fa-IR';
rec.continuous = true;
rec.interimResults = false;
rec.onresult = (event) => {
let text = '';
for (let i = event.resultIndex; i < event.results.length; i += 1) {
const row = event.results[i];
if (row?.isFinal) text += row[0]?.transcript ?? '';
}
const next = text.replace(/\s+/g, ' ').trim();
if (next) emit('transcript', next);
};
rec.onerror = (event) => {
if (event.error === 'aborted' || event.error === 'no-speech') return;
failed = true;
if (event.error === 'not-allowed' || event.error === 'service-not-allowed') {
setError('دسترسی به میکروفون داده نشد.');
return;
}
try {
ctx = new Ctor({ sampleRate: 16000 });
} catch {
ctx = new Ctor();
if (event.error === 'network') {
setError('اتصال به تشخیص گفتار Chrome برقرار نشد.');
return;
}
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;
chunks = [];
processor.onaudioprocess = (event) => {
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);
limitTimer = window.setTimeout(() => {
void finish(true);
}, MAX_SECONDS * 1000);
setRecording(true);
paintMeter();
setError('تبدیل صوت به متن ناموفق بود.');
};
rec.onend = () => {
if (!stopping && !failed && recording.value) {
try {
rec.start();
return;
} catch {
/* fall through */
}
}
stopTimer();
recognition = null;
setRecording(false);
};
recognition = rec;
stopping = false;
failed = false;
seconds.value = 0;
try {
rec.start();
} catch {
teardownMic();
setError('دسترسی به میکروفون داده نشد.');
setError(UNSUPPORTED);
recognition = null;
return;
}
setRecording(true);
timer = window.setInterval(() => {
seconds.value += 1;
}, 1000);
limitTimer = window.setTimeout(() => stop(), MAX_SECONDS * 1000);
}
function toggle() {
if (props.disabled || transcribing.value) return;
if (props.disabled) return;
if (recording.value) {
void finish(true);
stop();
return;
}
void start();
}
function barHeight(value: number) {
return `${6 + value * 22}px`;
start();
}
onBeforeUnmount(() => {
void finish(false);
stop();
});
</script>
<template>
<div class="vtt-wrap" :class="[variant, { live: recording, wait: transcribing }]">
<div class="vtt-wrap" :class="[variant, { live: recording }]">
<button
class="vtt"
type="button"
:class="{ on: recording, busy: transcribing }"
:disabled="disabled || transcribing"
:class="{ on: recording }"
:disabled="disabled"
:aria-label="ariaLabel"
:title="ariaLabel"
@click="toggle"
>
<span class="glyph" aria-hidden="true">
<span v-if="transcribing" class="spinner" />
<UiIcon v-else :name="recording ? 'stop' : 'mic'" :size="variant === 'toolbar' ? 18 : 16" />
<UiIcon :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>
@@ -360,20 +237,11 @@ onBeforeUnmount(() => {
border-color: #5eead4;
background: #042f2e;
color: #ecfdf5;
min-height: var(--control-h);
padding-inline: 0.7rem 0.85rem;
box-shadow: 0 10px 24px rgba(15, 118, 110, 0.22);
}
.vtt.busy {
min-height: var(--control-h);
border-color: #99f6e4;
background: #ecfdf5;
color: #0f766e;
cursor: wait;
}
.vtt:disabled:not(.busy) {
.vtt:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@@ -386,41 +254,11 @@ onBeforeUnmount(() => {
flex-shrink: 0;
}
.meter {
display: flex;
align-items: center;
gap: 2px;
height: 28px;
min-width: 72px;
}
.meter i {
display: block;
width: 3px;
border-radius: var(--radius);
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;
@@ -432,42 +270,18 @@ onBeforeUnmount(() => {
justify-content: center;
}
.toolbar.live .vtt,
.toolbar.wait .vtt {
.toolbar.live .vtt {
width: auto;
height: auto;
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.7rem;
border-radius: var(--radius);
}
.toolbar .vtt.on {
background: #042f2e;
}
.toolbar .vtt.busy {
background: #ecfdf5;
}
.err {
margin: 0;
color: #b91c1c;
font-size: 0.78rem;
}
@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>