Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 134cd29ba8 | |||
| 784a67c921 | |||
| f201ef509d | |||
| 1b74e0d9a1 |
@@ -22,15 +22,10 @@
|
|||||||
- Server-side extraction is NOT acceptable as a workaround
|
- Server-side extraction is NOT acceptable as a workaround
|
||||||
|
|
||||||
### Current Status
|
### Current Status
|
||||||
- ❌ Browser extraction via ffmpeg.wasm: BLOCKED (CORS/CORB issues)
|
- ✅ Browser extraction via Web Audio API (`AudioContext.decodeAudioData`): IMPLEMENTED
|
||||||
- ⚠️ Temporary workaround: Guide users to extract audio locally using ffmpeg CLI
|
- Converts media file data (audio or video) into a `Float32Array` PCM buffer client-side in the browser.
|
||||||
- 🔄 TODO: Find working ffmpeg.wasm CDN or alternative solution
|
- 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.
|
||||||
### 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)
|
|
||||||
|
|
||||||
### NOT Acceptable
|
### NOT Acceptable
|
||||||
- ❌ Uploading full video files to server
|
- ❌ Uploading full video files to server
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ env.allowLocalModels = false;
|
|||||||
let progressCallback = (progress) => {
|
let progressCallback = (progress) => {
|
||||||
const status = document.getElementById('status');
|
const status = document.getElementById('status');
|
||||||
if (progress.status === 'progress') {
|
if (progress.status === 'progress') {
|
||||||
status.textContent = `Downloading model... ${progress.file}`;
|
const pct = progress.progress != null ? ` ${Math.round(progress.progress)}%` : '';
|
||||||
|
status.textContent = `Downloading model... ${progress.file}${pct}`;
|
||||||
} else if (progress.status === 'done') {
|
} else if (progress.status === 'done') {
|
||||||
status.textContent = 'Model loaded';
|
status.textContent = 'Model loaded';
|
||||||
}
|
}
|
||||||
@@ -15,6 +16,7 @@ let progressCallback = (progress) => {
|
|||||||
env.progress_callback = progressCallback;
|
env.progress_callback = progressCallback;
|
||||||
|
|
||||||
const languageSelect = document.getElementById('language');
|
const languageSelect = document.getElementById('language');
|
||||||
|
const engineSelect = document.getElementById('engine');
|
||||||
const recordBtn = document.getElementById('record-btn');
|
const recordBtn = document.getElementById('record-btn');
|
||||||
const stopBtn = document.getElementById('stop-btn');
|
const stopBtn = document.getElementById('stop-btn');
|
||||||
const fileInput = document.getElementById('file-input');
|
const fileInput = document.getElementById('file-input');
|
||||||
@@ -27,7 +29,56 @@ let recognition;
|
|||||||
let transcriber = null;
|
let transcriber = null;
|
||||||
let currentModel = null;
|
let currentModel = null;
|
||||||
|
|
||||||
const getModelForLanguage = () => 'Xenova/whisper-small';
|
const getModelForLanguage = (_lang) => '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', () => {
|
fileInput.addEventListener('change', () => {
|
||||||
uploadBtn.disabled = !fileInput.files.length;
|
uploadBtn.disabled = !fileInput.files.length;
|
||||||
@@ -56,6 +107,14 @@ uploadBtn.addEventListener('click', async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const selectedLang = languageSelect.value;
|
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);
|
const modelName = getModelForLanguage(selectedLang);
|
||||||
|
|
||||||
if (!transcriber || currentModel !== modelName) {
|
if (!transcriber || currentModel !== modelName) {
|
||||||
@@ -64,10 +123,7 @@ uploadBtn.addEventListener('click', async () => {
|
|||||||
currentModel = modelName;
|
currentModel = modelName;
|
||||||
}
|
}
|
||||||
|
|
||||||
status.textContent = 'Processing audio...';
|
status.textContent = 'Transcribing locally...';
|
||||||
const audio = await extractAudio(file);
|
|
||||||
|
|
||||||
status.textContent = 'Transcribing...';
|
|
||||||
const langCode = selectedLang.split('-')[0];
|
const langCode = selectedLang.split('-')[0];
|
||||||
const result = await transcriber(audio, {
|
const result = await transcriber(audio, {
|
||||||
language: langCode,
|
language: langCode,
|
||||||
@@ -75,7 +131,30 @@ uploadBtn.addEventListener('click', async () => {
|
|||||||
stride_length_s: 5,
|
stride_length_s: 5,
|
||||||
return_timestamps: false
|
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') {
|
if (selectedLang === 'zh-CN') {
|
||||||
const converter = OpenCC.Converter({ from: 'tw', to: 'cn' });
|
const converter = OpenCC.Converter({ from: 'tw', to: 'cn' });
|
||||||
text = converter(text);
|
text = converter(text);
|
||||||
@@ -94,7 +173,12 @@ recordBtn.addEventListener('click', async () => {
|
|||||||
try {
|
try {
|
||||||
await navigator.mediaDevices.getUserMedia({ audio: true });
|
await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
|
||||||
recognition = new webkitSpeechRecognition();
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
if (!SpeechRecognition) {
|
||||||
|
status.textContent = 'Speech recognition not supported in this browser';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recognition = new SpeechRecognition();
|
||||||
recognition.continuous = true;
|
recognition.continuous = true;
|
||||||
recognition.interimResults = true;
|
recognition.interimResults = true;
|
||||||
recognition.lang = languageSelect.value;
|
recognition.lang = languageSelect.value;
|
||||||
|
|||||||
+12
-3
@@ -15,6 +15,7 @@
|
|||||||
<h1>Media to Text Converter</h1>
|
<h1>Media to Text Converter</h1>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
<label for="language">Language: </label>
|
||||||
<select id="language">
|
<select id="language">
|
||||||
<option value="en-US">English</option>
|
<option value="en-US">English</option>
|
||||||
<option value="es-ES">Spanish</option>
|
<option value="es-ES">Spanish</option>
|
||||||
@@ -32,10 +33,18 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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;">
|
<div style="margin: 20px 0; padding: 20px; border: 2px dashed #ccc; border-radius: 8px;">
|
||||||
<h3>Upload Audio File</h3>
|
<h3>Upload Media File</h3>
|
||||||
<p style="color: #666; font-size: 14px;">Upload audio file for transcription (processed in browser)</p>
|
<p style="color: #666; font-size: 14px;">Upload audio or video file for transcription</p>
|
||||||
<input type="file" id="file-input" accept="audio/*">
|
<input type="file" id="file-input" accept="audio/*,video/*">
|
||||||
<button id="upload-btn" disabled>Convert to Text</button>
|
<button id="upload-btn" disabled>Convert to Text</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import whisper
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
app = Flask(__name__, static_folder='.', static_url_path='')
|
app = Flask(__name__, static_folder=None)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB max (audio only)
|
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB max (audio only)
|
||||||
|
|
||||||
@@ -19,19 +19,17 @@ print("Loading Whisper model...")
|
|||||||
model = whisper.load_model("base")
|
model = whisper.load_model("base")
|
||||||
print("Model loaded!")
|
print("Model loaded!")
|
||||||
|
|
||||||
|
ALLOWED_STATIC_FILES = {'index.html', 'app.js'}
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
response = send_from_directory('.', 'index.html')
|
return send_from_directory('.', 'index.html')
|
||||||
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'
|
|
||||||
response.headers['Cross-Origin-Embedder-Policy'] = 'require-corp'
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.route('/<path:filename>')
|
@app.route('/<path:filename>')
|
||||||
def static_files(filename):
|
def static_files(filename):
|
||||||
response = send_from_directory('.', filename)
|
if filename not in ALLOWED_STATIC_FILES:
|
||||||
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'
|
return jsonify({'error': 'Not found'}), 404
|
||||||
response.headers['Cross-Origin-Embedder-Policy'] = 'require-corp'
|
return send_from_directory('.', filename)
|
||||||
return response
|
|
||||||
|
|
||||||
@app.route('/transcribe', methods=['POST'])
|
@app.route('/transcribe', methods=['POST'])
|
||||||
def transcribe():
|
def transcribe():
|
||||||
@@ -58,7 +56,9 @@ def transcribe():
|
|||||||
filepath = tmp.name
|
filepath = tmp.name
|
||||||
file.save(filepath)
|
file.save(filepath)
|
||||||
|
|
||||||
|
try:
|
||||||
result = model.transcribe(filepath, language=language)
|
result = model.transcribe(filepath, language=language)
|
||||||
|
finally:
|
||||||
os.remove(filepath)
|
os.remove(filepath)
|
||||||
|
|
||||||
return jsonify({'text': result['text']})
|
return jsonify({'text': result['text']})
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import io
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Mock whisper before importing server
|
||||||
|
mock_whisper = MagicMock()
|
||||||
|
mock_whisper.load_model.return_value = MagicMock()
|
||||||
|
sys.modules['whisper'] = mock_whisper
|
||||||
|
|
||||||
|
from server import app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
with app.test_client() as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
# --- Static file serving ---
|
||||||
|
|
||||||
|
def test_index(client):
|
||||||
|
resp = client.get('/')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_allowed_static_file(client):
|
||||||
|
resp = client.get('/app.js')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_blocked_static_file(client):
|
||||||
|
resp = client.get('/server.py')
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_path_traversal_blocked(client):
|
||||||
|
resp = client.get('/../requirements.txt')
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
# --- COOP/COEP headers ---
|
||||||
|
|
||||||
|
def test_coop_coep_headers(client):
|
||||||
|
resp = client.get('/')
|
||||||
|
assert resp.headers['Cross-Origin-Opener-Policy'] == 'same-origin'
|
||||||
|
assert resp.headers['Cross-Origin-Embedder-Policy'] == 'require-corp'
|
||||||
|
|
||||||
|
# --- /transcribe endpoint ---
|
||||||
|
|
||||||
|
def test_transcribe_no_file(client):
|
||||||
|
resp = client.post('/transcribe')
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert 'No file provided' in resp.get_json()['error']
|
||||||
|
|
||||||
|
def test_transcribe_empty_filename(client):
|
||||||
|
data = {'file': (io.BytesIO(b'data'), '')}
|
||||||
|
resp = client.post('/transcribe', data=data, content_type='multipart/form-data')
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert 'No file selected' in resp.get_json()['error']
|
||||||
|
|
||||||
|
def test_transcribe_video_rejected(client):
|
||||||
|
for ext in ['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm']:
|
||||||
|
data = {'file': (io.BytesIO(b'data'), f'video{ext}')}
|
||||||
|
resp = client.post('/transcribe', data=data, content_type='multipart/form-data')
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert 'Video files not allowed' in resp.get_json()['error']
|
||||||
|
|
||||||
|
@patch('server.model')
|
||||||
|
def test_transcribe_success(mock_model, client):
|
||||||
|
mock_model.transcribe.return_value = {'text': 'hello world'}
|
||||||
|
data = {'file': (io.BytesIO(b'audio data'), 'test.mp3')}
|
||||||
|
resp = client.post('/transcribe', data=data, content_type='multipart/form-data')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()['text'] == 'hello world'
|
||||||
|
|
||||||
|
@patch('server.model')
|
||||||
|
def test_transcribe_with_language(mock_model, client):
|
||||||
|
mock_model.transcribe.return_value = {'text': 'hola'}
|
||||||
|
data = {'file': (io.BytesIO(b'audio'), 'test.mp3'), 'language': 'es-ES'}
|
||||||
|
resp = client.post('/transcribe', data=data, content_type='multipart/form-data')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_model.transcribe.assert_called_once()
|
||||||
|
assert mock_model.transcribe.call_args[1]['language'] == 'es'
|
||||||
|
|
||||||
|
@patch('server.model')
|
||||||
|
def test_transcribe_cleans_up_temp_file_on_error(mock_model, client):
|
||||||
|
mock_model.transcribe.side_effect = RuntimeError('model failed')
|
||||||
|
data = {'file': (io.BytesIO(b'audio'), 'test.mp3')}
|
||||||
|
resp = client.post('/transcribe', data=data, content_type='multipart/form-data')
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert 'model failed' in resp.get_json()['error']
|
||||||
+14
-2
@@ -1,13 +1,25 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import sys
|
import sys
|
||||||
import whisper
|
import whisper
|
||||||
|
import os
|
||||||
|
|
||||||
if len(sys.argv) < 2:
|
if len(sys.argv) < 2:
|
||||||
print("Usage: python transcribe.py <audio_or_video_file> [language]")
|
print("Usage: python transcribe.py <audio_file> [language]")
|
||||||
print("Example: python transcribe.py audio.mp3 es")
|
print("Example: python transcribe.py audio.mp3 es")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
filename = sys.argv[1]
|
||||||
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
video_exts = {'.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm'}
|
||||||
|
|
||||||
|
if ext in video_exts:
|
||||||
|
print(f"Error: Video files ({ext}) are not allowed per DEVELOPMENT_RULES.md.")
|
||||||
|
print("Please extract audio first (e.g., using ffmpeg -i input.mp4 -vn -acodec libmp3lame output.mp3)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Transcribing {filename}...")
|
||||||
model = whisper.load_model("base")
|
model = whisper.load_model("base")
|
||||||
language = sys.argv[2] if len(sys.argv) > 2 else None
|
language = sys.argv[2] if len(sys.argv) > 2 else None
|
||||||
result = model.transcribe(sys.argv[1], language=language)
|
result = model.transcribe(filename, language=language)
|
||||||
|
print("-" * 20)
|
||||||
print(result["text"])
|
print(result["text"])
|
||||||
|
|||||||
Reference in New Issue
Block a user