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

288 lines
6.1 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
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>
2026-09-23 13:58:43 +03:30
<div class="vtt-wrap" :class="[variant, { live: recording }]">
<button
class="vtt"
type="button"
2026-09-23 13:58:43 +03:30
:class="{ on: recording }"
:disabled="disabled"
:aria-label="ariaLabel"
:title="ariaLabel"
@click="toggle"
>
<span class="glyph" aria-hidden="true">
2026-09-23 13:58:43 +03:30
<UiIcon :name="recording ? 'stop' : 'mic'" :size="variant === 'toolbar' ? 18 : 16" />
</span>
<span v-if="recording" class="timer">{{ timerLabel }}</span>
</button>
<p v-if="error && !hideError" class="err">{{ error }}</p>
</div>
</template>
<style scoped>
.vtt-wrap {
display: grid;
gap: 0.3rem;
justify-items: start;
}
.vtt {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: 1px solid #d7e3dc;
background: #f4faf7;
color: #0f766e;
border-radius: var(--radius);
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.75rem;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
}
.vtt:hover:not(:disabled) {
background: #ecfdf5;
}
.vtt.on {
border-color: #5eead4;
background: #042f2e;
color: #ecfdf5;
padding-inline: 0.7rem 0.85rem;
box-shadow: 0 10px 24px rgba(15, 118, 110, 0.22);
}
2026-09-23 13:58:43 +03:30
.vtt:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.glyph {
width: 22px;
height: 22px;
display: grid;
place-items: center;
flex-shrink: 0;
}
.timer {
font-variant-numeric: tabular-nums;
min-width: 2.4rem;
}
.toolbar .vtt {
width: 38px;
height: 38px;
min-height: 38px;
padding: 0;
border-radius: var(--radius);
border-color: transparent;
background: #f8fafc;
justify-content: center;
}
2026-09-23 13:58:43 +03:30
.toolbar.live .vtt {
width: auto;
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.7rem;
border-radius: var(--radius);
background: #042f2e;
}
.err {
margin: 0;
color: #b91c1c;
font-size: 0.78rem;
}
</style>