import { pipeline, env } from 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2'; import * as OpenCC from 'https://cdn.jsdelivr.net/npm/opencc-js@1.0.5/+esm'; env.allowLocalModels = false; let progressCallback = (progress) => { const status = document.getElementById('status'); if (progress.status === 'progress') { status.textContent = `Downloading model... ${progress.file}`; } else if (progress.status === 'done') { status.textContent = 'Model loaded'; } }; env.progress_callback = progressCallback; const languageSelect = document.getElementById('language'); const engineSelect = document.getElementById('engine'); const recordBtn = document.getElementById('record-btn'); const stopBtn = document.getElementById('stop-btn'); const fileInput = document.getElementById('file-input'); const uploadBtn = document.getElementById('upload-btn'); const output = document.getElementById('output'); const status = document.getElementById('status'); const copyBtn = document.getElementById('copy-btn'); let recognition; let transcriber = null; let currentModel = null; const getModelForLanguage = () => 'Xenova/whisper-small'; const bufferToWav = (buffer, sampleRate = 16000) => { const numOfChan = 1; const length = buffer.length * 2; const bufferArray = new ArrayBuffer(44 + length); const view = new DataView(bufferArray); const writeString = (view, offset, string) => { for (let i = 0; i < string.length; i++) { view.setUint8(offset + i, string.charCodeAt(i)); } }; /* RIFF identifier */ writeString(view, 0, 'RIFF'); /* file size - 8 */ view.setUint32(4, 36 + length, true); /* RIFF type */ writeString(view, 8, 'WAVE'); /* format chunk identifier */ writeString(view, 12, 'fmt '); /* format chunk length */ view.setUint32(16, 16, true); /* sample format (PCM = 1) */ view.setUint16(20, 1, true); /* channel count */ view.setUint16(22, numOfChan, true); /* sample rate */ view.setUint32(24, sampleRate, true); /* byte rate (sample rate * block align) */ view.setUint32(28, sampleRate * 2, true); /* block align (channel count * bytes per sample) */ view.setUint16(32, numOfChan * 2, true); /* bits per sample */ view.setUint16(34, 16, true); /* data chunk identifier */ writeString(view, 36, 'data'); /* chunk length */ view.setUint32(40, length, true); // Convert Float32Array to 16-bit signed PCM let offset = 44; for (let i = 0; i < buffer.length; i++, offset += 2) { let s = Math.max(-1, Math.min(1, buffer[i])); view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true); } return new Blob([view], { type: 'audio/wav' }); }; fileInput.addEventListener('change', () => { uploadBtn.disabled = !fileInput.files.length; }); const extractAudio = async (file) => { status.textContent = `Reading file... (${(file.size / 1024 / 1024).toFixed(1)} MB)`; const arrayBuffer = await file.arrayBuffer(); status.textContent = 'Decoding audio... (page may be briefly unresponsive)'; await new Promise(r => setTimeout(r, 100)); const audioContext = new AudioContext({ sampleRate: 16000 }); const audioBuffer = await new Promise((resolve, reject) => { audioContext.decodeAudioData(arrayBuffer, resolve, reject); }); status.textContent = `Audio decoded (${Math.round(audioBuffer.duration)}s)`; await audioContext.close(); return audioBuffer.getChannelData(0); }; uploadBtn.addEventListener('click', async () => { const file = fileInput.files[0]; if (!file) return; uploadBtn.disabled = true; output.textContent = ''; try { const selectedLang = languageSelect.value; const engine = engineSelect.value; status.textContent = 'Processing media file...'; const audio = await extractAudio(file); let text = ''; if (engine === 'local') { const modelName = getModelForLanguage(selectedLang); if (!transcriber || currentModel !== modelName) { status.textContent = 'Downloading model...'; transcriber = await pipeline('automatic-speech-recognition', modelName); currentModel = modelName; } status.textContent = 'Transcribing locally...'; const langCode = selectedLang.split('-')[0]; const result = await transcriber(audio, { language: langCode, chunk_length_s: 30, stride_length_s: 5, return_timestamps: false }); text = result.text; } else if (engine === 'server') { status.textContent = 'Encoding audio to WAV in browser...'; const wavBlob = bufferToWav(audio, 16000); status.textContent = 'Uploading and transcribing on server...'; const formData = new FormData(); formData.append('file', wavBlob, 'audio.wav'); formData.append('language', selectedLang); const response = await fetch('/transcribe', { method: 'POST', body: formData }); if (!response.ok) { const errData = await response.json().catch(() => ({})); throw new Error(errData.error || `Server error: ${response.status}`); } const result = await response.json(); text = result.text; } if (selectedLang === 'zh-CN') { const converter = OpenCC.Converter({ from: 'tw', to: 'cn' }); text = converter(text); } output.textContent = text; copyBtn.style.display = 'block'; status.textContent = 'Complete'; } catch (err) { status.textContent = `Error: ${err.message}`; } finally { uploadBtn.disabled = false; } }); recordBtn.addEventListener('click', async () => { try { await navigator.mediaDevices.getUserMedia({ audio: true }); recognition = new webkitSpeechRecognition(); recognition.continuous = true; recognition.interimResults = true; recognition.lang = languageSelect.value; let transcript = ''; recognition.onresult = (event) => { let interim = ''; for (let i = event.resultIndex; i < event.results.length; i++) { if (event.results[i].isFinal) { transcript += event.results[i][0].transcript + ' '; } else { interim += event.results[i][0].transcript; } } output.textContent = transcript + interim; copyBtn.style.display = 'block'; }; recognition.start(); recordBtn.disabled = true; stopBtn.disabled = false; status.textContent = 'Recording...'; } catch (err) { status.textContent = 'Microphone access denied'; } }); stopBtn.addEventListener('click', () => { if (recognition) { recognition.stop(); recordBtn.disabled = false; stopBtn.disabled = true; status.textContent = 'Recording stopped'; } }); copyBtn.addEventListener('click', () => { navigator.clipboard.writeText(output.textContent); status.textContent = 'Copied to clipboard'; });