Files
CRM_Montzar/apps/web/src/components/VoiceToTextButton.vue
T

212 lines
4.9 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { Button as AButton, Typography } from 'ant-design-vue';
import { AudioOutlined, PauseCircleOutlined } from '@ant-design/icons-vue';
import { toFaDigits } from '@/lib/jalali';
const { Text: ATypographyText } = Typography;
2026-09-23 13:58:43 +03:30
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;
variant?: 'default' | 'toolbar';
hideError?: boolean;
}>(),
{ variant: 'default', hideError: false },
);
const emit = defineEmits<{
transcript: [text: string];
recording: [active: boolean];
busy: [active: boolean];
error: [message: string];
}>();
2026-09-23 13:58:43 +03:30
const UNSUPPORTED = 'تبدیل صوت به متن در این مرورگر پشتیبانی نمی‌شود. از Chrome استفاده کنید.';
const MAX_SECONDS = 90;
2026-09-23 13:58:43 +03:30
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}`);
});
2026-09-23 13:58:43 +03:30
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;
emit('error', message);
}
function setRecording(active: boolean) {
recording.value = active;
emit('recording', active);
2026-09-23 13:58:43 +03:30
emit('busy', active);
}
function stopTimer() {
if (timer != null) {
window.clearInterval(timer);
timer = null;
}
if (limitTimer != null) {
window.clearTimeout(limitTimer);
limitTimer = null;
}
}
2026-09-23 13:58:43 +03:30
function stop() {
stopping = true;
stopTimer();
try {
2026-09-23 13:58:43 +03:30
recognition?.stop();
} catch {
2026-09-23 13:58:43 +03:30
/* already stopped */
}
2026-09-23 13:58:43 +03:30
recognition = null;
setRecording(false);
}
2026-09-23 13:58:43 +03:30
function start() {
setError('');
2026-09-23 13:58:43 +03:30
const Ctor = speechCtor();
if (!Ctor) {
setError(UNSUPPORTED);
return;
}
2026-09-23 13:58:43 +03:30
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;
}
2026-09-23 13:58:43 +03:30
if (event.error === 'network') {
setError('اتصال به تشخیص گفتار Chrome برقرار نشد.');
return;
2026-09-22 12:04:35 +03:30
}
2026-09-23 13:58:43 +03:30
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 {
2026-09-23 13:58:43 +03:30
setError(UNSUPPORTED);
recognition = null;
return;
}
2026-09-23 13:58:43 +03:30
setRecording(true);
timer = window.setInterval(() => {
seconds.value += 1;
}, 1000);
limitTimer = window.setTimeout(() => stop(), MAX_SECONDS * 1000);
}
function toggle() {
2026-09-23 13:58:43 +03:30
if (props.disabled) return;
if (recording.value) {
2026-09-23 13:58:43 +03:30
stop();
return;
}
2026-09-23 13:58:43 +03:30
start();
}
onBeforeUnmount(() => {
2026-09-23 13:58:43 +03:30
stop();
});
</script>
<template>
<div>
<a-button
:type="recording ? 'primary' : 'default'"
:danger="recording"
2026-09-23 13:58:43 +03:30
:disabled="disabled"
:aria-label="ariaLabel"
:title="ariaLabel"
@click="toggle"
>
<template #icon>
<PauseCircleOutlined v-if="recording" />
<AudioOutlined v-else />
</template>
<span v-if="recording">{{ timerLabel }}</span>
</a-button>
<div v-if="error && !hideError">
<a-typography-text type="danger">{{ error }}</a-typography-text>
</div>
</div>
</template>