diff --git a/DEVELOPMENT_RULES.md b/DEVELOPMENT_RULES.md index d93f08b..9cf53f3 100644 --- a/DEVELOPMENT_RULES.md +++ b/DEVELOPMENT_RULES.md @@ -22,15 +22,10 @@ - Server-side extraction is NOT acceptable as a workaround ### Current Status -- ❌ Browser extraction via ffmpeg.wasm: BLOCKED (CORS/CORB issues) -- ⚠️ Temporary workaround: Guide users to extract audio locally using ffmpeg CLI -- 🔄 TODO: Find working ffmpeg.wasm CDN or alternative solution - -### Acceptable Solutions (In Priority Order) -1. ffmpeg.wasm from working CDN (no CORS/CORB issues) -2. Local ffmpeg.wasm with complete wasm binaries -3. Alternative WebAssembly video processing library -4. User-extracted audio files only (with clear instructions) +- ✅ Browser extraction via Web Audio API (`AudioContext.decodeAudioData`): IMPLEMENTED + - Converts media file data (audio or video) into a `Float32Array` PCM buffer client-side in the browser. + - For server upload, encodes the PCM buffer to a standard 16-bit mono 16kHz WAV Blob in Javascript. + - Ensures absolute compliance with the rule that full video files are never uploaded to the server. ### NOT Acceptable - ❌ Uploading full video files to server diff --git a/app.js b/app.js index b75d3d0..e9486cf 100644 --- a/app.js +++ b/app.js @@ -15,6 +15,7 @@ let progressCallback = (progress) => { 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'); @@ -29,6 +30,55 @@ 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; }); @@ -56,26 +106,54 @@ uploadBtn.addEventListener('click', async () => { try { const selectedLang = languageSelect.value; - const modelName = getModelForLanguage(selectedLang); + const engine = engineSelect.value; - if (!transcriber || currentModel !== modelName) { - status.textContent = 'Downloading model...'; - transcriber = await pipeline('automatic-speech-recognition', modelName); - currentModel = modelName; - } - - status.textContent = 'Processing audio...'; + status.textContent = 'Processing media file...'; const audio = await extractAudio(file); - status.textContent = 'Transcribing...'; - const langCode = selectedLang.split('-')[0]; - const result = await transcriber(audio, { - language: langCode, - chunk_length_s: 30, - stride_length_s: 5, - return_timestamps: false - }); - let text = result.text; + 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); diff --git a/index.html b/index.html index 87f6fbf..8193861 100644 --- a/index.html +++ b/index.html @@ -15,6 +15,7 @@
Upload audio file for transcription (processed in browser)
- +Upload audio or video file for transcription
+