784a67c921
- 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>
218 lines
7.2 KiB
JavaScript
218 lines
7.2 KiB
JavaScript
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';
|
|
});
|