474 lines
12 KiB
Vue
474 lines
12 KiB
Vue
<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';
|
|
|
|
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];
|
|
}>();
|
|
|
|
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 MAX_SECONDS = 90;
|
|
|
|
const busy = computed(() => recording.value || transcribing.value);
|
|
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 'تبدیل صوت به متن';
|
|
});
|
|
|
|
function setError(message: string) {
|
|
error.value = message;
|
|
emit('error', message);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function stopTimer() {
|
|
if (timer != null) {
|
|
window.clearInterval(timer);
|
|
timer = null;
|
|
}
|
|
if (limitTimer != null) {
|
|
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());
|
|
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();
|
|
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() {
|
|
setError('');
|
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
setError('این مرورگر به میکروفون دسترسی نمیدهد.');
|
|
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;
|
|
return;
|
|
}
|
|
try {
|
|
ctx = new Ctor({ sampleRate: 16000 });
|
|
} catch {
|
|
ctx = new Ctor();
|
|
}
|
|
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();
|
|
} catch {
|
|
teardownMic();
|
|
setError('دسترسی به میکروفون داده نشد.');
|
|
}
|
|
}
|
|
|
|
function toggle() {
|
|
if (props.disabled || transcribing.value) return;
|
|
if (recording.value) {
|
|
void finish(true);
|
|
return;
|
|
}
|
|
void start();
|
|
}
|
|
|
|
function barHeight(value: number) {
|
|
return `${6 + value * 22}px`;
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
void finish(false);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="vtt-wrap" :class="[variant, { live: recording, wait: transcribing }]">
|
|
<button
|
|
class="vtt"
|
|
type="button"
|
|
:class="{ on: recording, busy: transcribing }"
|
|
:disabled="disabled || transcribing"
|
|
: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" />
|
|
</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>
|
|
</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;
|
|
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) {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.glyph {
|
|
width: 22px;
|
|
height: 22px;
|
|
display: grid;
|
|
place-items: center;
|
|
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;
|
|
min-height: 38px;
|
|
padding: 0;
|
|
border-radius: var(--radius);
|
|
border-color: transparent;
|
|
background: #f8fafc;
|
|
justify-content: center;
|
|
}
|
|
|
|
.toolbar.live .vtt,
|
|
.toolbar.wait .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>
|