Implement native browser-side audio extraction and server upload
- Update index.html file input to accept audio/*,video/* - Add engine selection dropdown (Local vs Server) in index.html - Implement JS bufferToWav WAV encoder in app.js (Float32Array PCM to 16-bit WAV) - Integrate server-side transcribing route via FormData POST in app.js - Update DEVELOPMENT_RULES.md to mark browser-side extraction as implemented Co-Authored-By: Claude Opus 4.8 <noreply@anthreply.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,6 +106,14 @@ uploadBtn.addEventListener('click', async () => {
|
||||
|
||||
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) {
|
||||
@@ -64,10 +122,7 @@ uploadBtn.addEventListener('click', async () => {
|
||||
currentModel = modelName;
|
||||
}
|
||||
|
||||
status.textContent = 'Processing audio...';
|
||||
const audio = await extractAudio(file);
|
||||
|
||||
status.textContent = 'Transcribing...';
|
||||
status.textContent = 'Transcribing locally...';
|
||||
const langCode = selectedLang.split('-')[0];
|
||||
const result = await transcriber(audio, {
|
||||
language: langCode,
|
||||
@@ -75,7 +130,30 @@ uploadBtn.addEventListener('click', async () => {
|
||||
stride_length_s: 5,
|
||||
return_timestamps: false
|
||||
});
|
||||
let text = result.text;
|
||||
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);
|
||||
|
||||
+12
-3
@@ -15,6 +15,7 @@
|
||||
<h1>Media to Text Converter</h1>
|
||||
|
||||
<div>
|
||||
<label for="language">Language: </label>
|
||||
<select id="language">
|
||||
<option value="en-US">English</option>
|
||||
<option value="es-ES">Spanish</option>
|
||||
@@ -32,10 +33,18 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 10px;">
|
||||
<label for="engine">Transcription Engine: </label>
|
||||
<select id="engine">
|
||||
<option value="local">Local (Offline Browser)</option>
|
||||
<option value="server">Server (Whisper Python)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="margin: 20px 0; padding: 20px; border: 2px dashed #ccc; border-radius: 8px;">
|
||||
<h3>Upload Audio File</h3>
|
||||
<p style="color: #666; font-size: 14px;">Upload audio file for transcription (processed in browser)</p>
|
||||
<input type="file" id="file-input" accept="audio/*">
|
||||
<h3>Upload Media File</h3>
|
||||
<p style="color: #666; font-size: 14px;">Upload audio or video file for transcription</p>
|
||||
<input type="file" id="file-input" accept="audio/*,video/*">
|
||||
<button id="upload-btn" disabled>Convert to Text</button>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user