2026-09-03 14:20:55 +03:30
|
|
|
<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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-03 14:20:55 +03:30
|
|
|
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 استفاده کنید.';
|
2026-09-03 14:20:55 +03:30
|
|
|
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;
|
|
|
|
|
|
2026-09-03 14:20:55 +03:30
|
|
|
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;
|
|
|
|
|
}
|
2026-09-03 14:20:55 +03:30
|
|
|
|
|
|
|
|
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);
|
2026-09-03 14:20:55 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stopTimer() {
|
|
|
|
|
if (timer != null) {
|
|
|
|
|
window.clearInterval(timer);
|
|
|
|
|
timer = null;
|
|
|
|
|
}
|
|
|
|
|
if (limitTimer != null) {
|
|
|
|
|
window.clearTimeout(limitTimer);
|
|
|
|
|
limitTimer = null;
|
|
|
|
|
}
|
2026-09-03 15:48:28 +03:30
|
|
|
}
|
|
|
|
|
|
2026-09-23 13:58:43 +03:30
|
|
|
function stop() {
|
|
|
|
|
stopping = true;
|
2026-09-03 14:20:55 +03:30
|
|
|
stopTimer();
|
|
|
|
|
try {
|
2026-09-23 13:58:43 +03:30
|
|
|
recognition?.stop();
|
2026-09-03 14:20:55 +03:30
|
|
|
} catch {
|
2026-09-23 13:58:43 +03:30
|
|
|
/* already stopped */
|
2026-09-03 14:20:55 +03:30
|
|
|
}
|
2026-09-23 13:58:43 +03:30
|
|
|
recognition = null;
|
2026-09-03 14:20:55 +03:30
|
|
|
setRecording(false);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 13:58:43 +03:30
|
|
|
function start() {
|
2026-09-03 14:20:55 +03:30
|
|
|
setError('');
|
2026-09-23 13:58:43 +03:30
|
|
|
const Ctor = speechCtor();
|
|
|
|
|
if (!Ctor) {
|
|
|
|
|
setError(UNSUPPORTED);
|
2026-09-03 14:20:55 +03:30
|
|
|
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('دسترسی به میکروفون داده نشد.');
|
2026-09-03 14:20:55 +03:30
|
|
|
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();
|
2026-09-03 14:20:55 +03:30
|
|
|
} catch {
|
2026-09-23 13:58:43 +03:30
|
|
|
setError(UNSUPPORTED);
|
|
|
|
|
recognition = null;
|
|
|
|
|
return;
|
2026-09-03 14:20:55 +03:30
|
|
|
}
|
2026-09-23 13:58:43 +03:30
|
|
|
setRecording(true);
|
|
|
|
|
timer = window.setInterval(() => {
|
|
|
|
|
seconds.value += 1;
|
|
|
|
|
}, 1000);
|
|
|
|
|
limitTimer = window.setTimeout(() => stop(), MAX_SECONDS * 1000);
|
2026-09-03 14:20:55 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggle() {
|
2026-09-23 13:58:43 +03:30
|
|
|
if (props.disabled) return;
|
2026-09-03 14:20:55 +03:30
|
|
|
if (recording.value) {
|
2026-09-23 13:58:43 +03:30
|
|
|
stop();
|
2026-09-03 14:20:55 +03:30
|
|
|
return;
|
|
|
|
|
}
|
2026-09-23 13:58:43 +03:30
|
|
|
start();
|
2026-09-03 15:48:28 +03:30
|
|
|
}
|
|
|
|
|
|
2026-09-03 14:20:55 +03:30
|
|
|
onBeforeUnmount(() => {
|
2026-09-23 13:58:43 +03:30
|
|
|
stop();
|
2026-09-03 14:20:55 +03:30
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-09-23 13:58:43 +03:30
|
|
|
<div class="vtt-wrap" :class="[variant, { live: recording }]">
|
2026-09-03 14:20:55 +03:30
|
|
|
<button
|
|
|
|
|
class="vtt"
|
|
|
|
|
type="button"
|
2026-09-23 13:58:43 +03:30
|
|
|
:class="{ on: recording }"
|
|
|
|
|
:disabled="disabled"
|
2026-09-03 14:20:55 +03:30
|
|
|
:aria-label="ariaLabel"
|
|
|
|
|
:title="ariaLabel"
|
|
|
|
|
@click="toggle"
|
|
|
|
|
>
|
2026-09-03 15:48:28 +03:30
|
|
|
<span class="glyph" aria-hidden="true">
|
2026-09-23 13:58:43 +03:30
|
|
|
<UiIcon :name="recording ? 'stop' : 'mic'" :size="variant === 'toolbar' ? 18 : 16" />
|
2026-09-03 15:48:28 +03:30
|
|
|
</span>
|
|
|
|
|
<span v-if="recording" class="timer">{{ timerLabel }}</span>
|
2026-09-03 14:20:55 +03:30
|
|
|
</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;
|
2026-09-03 15:48:28 +03:30
|
|
|
gap: 0.45rem;
|
2026-09-03 14:20:55 +03:30
|
|
|
border: 1px solid #d7e3dc;
|
|
|
|
|
background: #f4faf7;
|
|
|
|
|
color: #0f766e;
|
2026-09-10 23:45:11 +03:30
|
|
|
border-radius: var(--radius);
|
|
|
|
|
min-height: var(--control-h);
|
|
|
|
|
height: var(--control-h);
|
2026-09-03 14:20:55 +03:30
|
|
|
padding: 0 0.75rem;
|
|
|
|
|
font: inherit;
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.vtt:hover:not(:disabled) {
|
|
|
|
|
background: #ecfdf5;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.vtt.on {
|
2026-09-03 15:48:28 +03:30
|
|
|
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-03 14:20:55 +03:30
|
|
|
}
|
|
|
|
|
|
2026-09-23 13:58:43 +03:30
|
|
|
.vtt:disabled {
|
2026-09-03 14:20:55 +03:30
|
|
|
opacity: 0.45;
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 15:48:28 +03:30
|
|
|
.glyph {
|
|
|
|
|
width: 22px;
|
|
|
|
|
height: 22px;
|
|
|
|
|
display: grid;
|
|
|
|
|
place-items: center;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.timer {
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
min-width: 2.4rem;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 14:20:55 +03:30
|
|
|
.toolbar .vtt {
|
|
|
|
|
width: 38px;
|
|
|
|
|
height: 38px;
|
|
|
|
|
min-height: 38px;
|
|
|
|
|
padding: 0;
|
2026-09-12 01:33:07 +03:30
|
|
|
border-radius: var(--radius);
|
2026-09-03 14:20:55 +03:30
|
|
|
border-color: transparent;
|
|
|
|
|
background: #f8fafc;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 13:58:43 +03:30
|
|
|
.toolbar.live .vtt {
|
2026-09-03 15:48:28 +03:30
|
|
|
width: auto;
|
2026-09-10 23:45:11 +03:30
|
|
|
min-height: var(--control-h);
|
|
|
|
|
height: var(--control-h);
|
2026-09-03 15:48:28 +03:30
|
|
|
padding: 0 0.7rem;
|
2026-09-10 23:45:11 +03:30
|
|
|
border-radius: var(--radius);
|
2026-09-03 15:48:28 +03:30
|
|
|
background: #042f2e;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 14:20:55 +03:30
|
|
|
.err {
|
|
|
|
|
margin: 0;
|
|
|
|
|
color: #b91c1c;
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
}
|
|
|
|
|
</style>
|