Migrate backend jobs to Redis Streams
This commit is contained in:
+272
-7
@@ -1,4 +1,71 @@
|
||||
import { CONVERT_URL } from './config.js'
|
||||
import { CONVERT_URL, ASR_URL } from './config.js'
|
||||
|
||||
function parseSseEvent(rawEvent) {
|
||||
const lines = String(rawEvent || '').replace(/\r/g, '').split('\n')
|
||||
let event = 'message'
|
||||
const dataLines = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) continue
|
||||
if (line.startsWith('event:')) {
|
||||
event = line.slice(6).trim() || 'message'
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trimStart())
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
data: dataLines.join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
async function consumeSseResult(res) {
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text()
|
||||
throw new Error(`HTTP ${res.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
if (!res.body) {
|
||||
throw new Error('流式响应不可用')
|
||||
}
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let finalResult = null
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
let boundary = buffer.indexOf('\n\n')
|
||||
while (boundary >= 0) {
|
||||
const chunk = buffer.slice(0, boundary)
|
||||
buffer = buffer.slice(boundary + 2)
|
||||
const parsed = parseSseEvent(chunk)
|
||||
const data = parsed.data ? JSON.parse(parsed.data) : {}
|
||||
|
||||
if (parsed.event === 'done') {
|
||||
finalResult = data.result || data
|
||||
return finalResult
|
||||
}
|
||||
if (parsed.event === 'error') {
|
||||
throw new Error(String(data.error || '请求失败'))
|
||||
}
|
||||
if (parsed.event === 'cancelled') {
|
||||
throw new Error('请求已取消')
|
||||
}
|
||||
|
||||
boundary = buffer.indexOf('\n\n')
|
||||
}
|
||||
}
|
||||
|
||||
return finalResult
|
||||
}
|
||||
|
||||
function readFileAsBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -31,14 +98,212 @@ export async function convertFileToMarkdown(file) {
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text()
|
||||
throw new Error(`HTTP ${res.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const data = await consumeSseResult(res)
|
||||
if (!data || typeof data.markdown !== 'string') {
|
||||
throw new Error('No markdown returned')
|
||||
}
|
||||
return data.markdown
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode AudioBuffer as WAV (16kHz mono, 16-bit PCM) and return base64 string
|
||||
*/
|
||||
function audioBufferToWavBase64(audioBuffer) {
|
||||
// Resample to 16kHz if needed using OfflineAudioContext
|
||||
const targetSampleRate = 16000
|
||||
|
||||
if (audioBuffer.sampleRate === targetSampleRate) {
|
||||
// No resampling needed, just convert to mono and encode WAV
|
||||
} else {
|
||||
const offlineCtx = new OfflineAudioContext(1, audioBuffer.length * (targetSampleRate / audioBuffer.sampleRate), targetSampleRate)
|
||||
const source = offlineCtx.createBufferSource()
|
||||
source.buffer = audioBuffer
|
||||
source.connect(offlineCtx.destination)
|
||||
// We need to wait for the offline context to finish rendering
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const processBuffer = async (buffer) => {
|
||||
// Convert to mono if stereo/multi-channel
|
||||
let channels = buffer.numberOfChannels
|
||||
const length = buffer.length
|
||||
|
||||
if (channels === 1) {
|
||||
// Already mono, use directly
|
||||
const channelData = buffer.getChannelData(0)
|
||||
} else {
|
||||
// Mix down to mono by averaging channels
|
||||
const channelData = new Float32Array(length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
let sum = 0
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
sum += buffer.getChannelData(ch)[i]
|
||||
}
|
||||
channelData[i] = sum / channels
|
||||
}
|
||||
}
|
||||
|
||||
// Encode as 16-bit PCM WAV (simplified - we'll use the actual channel data)
|
||||
const sampleRate = buffer.sampleRate
|
||||
const numSamples = buffer.length
|
||||
|
||||
// Get mono data properly
|
||||
let samples
|
||||
if (buffer.numberOfChannels === 1) {
|
||||
samples = buffer.getChannelData(0)
|
||||
} else {
|
||||
const monoSamples = new Float32Array(numSamples)
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
let sum = 0
|
||||
for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
|
||||
sum += buffer.getChannelData(ch)[i]
|
||||
}
|
||||
monoSamples[i] = sum / buffer.numberOfChannels
|
||||
}
|
||||
}
|
||||
|
||||
// Convert float32 [-1, 1] to int16 PCM
|
||||
const pcmData = new Int16Array(numSamples)
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
const s = Math.max(-1, Math.min(1, samples[i]))
|
||||
pcmData[i] = s < 0 ? s * 32768 : s * 32767
|
||||
}
|
||||
|
||||
// Build WAV file (RIFF format)
|
||||
const wavBuffer = new ArrayBuffer(44 + numSamples * 2)
|
||||
const view = new DataView(wavBuffer)
|
||||
|
||||
// RIFF header
|
||||
writeString(view, 0, 'RIFF')
|
||||
view.setUint32(4, 36 + numSamples * 2, true)
|
||||
writeString(view, 8, 'WAVE')
|
||||
|
||||
// fmt chunk
|
||||
writeString(view, 12, 'fmt ')
|
||||
view.setUint32(16, 16, true) // chunk size
|
||||
view.setUint16(20, 1, true) // PCM format
|
||||
view.setUint16(22, 1, true) // mono channels
|
||||
view.setUint32(24, sampleRate, true) // sample rate
|
||||
view.setUint32(28, sampleRate * 2, true) // byte rate
|
||||
view.setUint16(32, 2, true) // block align
|
||||
view.setUint16(34, 16, true) // bits per sample
|
||||
|
||||
// data chunk
|
||||
writeString(view, 36, 'data')
|
||||
view.setUint32(40, numSamples * 2, true)
|
||||
|
||||
// Write PCM data
|
||||
let offset = 44
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
view.setInt16(offset, pcmData[i], true)
|
||||
offset += 2
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
const bytes = new Uint8Array(wavBuffer)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
|
||||
resolve(btoa(binary))
|
||||
}
|
||||
|
||||
// Handle resampling if needed
|
||||
const targetSampleRate = 16000
|
||||
|
||||
if (audioBuffer.sampleRate === targetSampleRate) {
|
||||
processBuffer(audioBuffer).catch(reject)
|
||||
} else {
|
||||
const offlineCtx = new OfflineAudioContext(1, Math.ceil(audioBuffer.duration * targetSampleRate), targetSampleRate)
|
||||
const source = offlineCtx.createBufferSource()
|
||||
source.buffer = audioBuffer
|
||||
source.connect(offlineCtx.destination)
|
||||
|
||||
offlineCtx.oncomplete = (e) => {
|
||||
processBuffer(e.renderedBuffer).catch(reject)
|
||||
}
|
||||
|
||||
offlineCtx.startRendering()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function writeString(view, offset, string) {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert audio file to WAV base64 (16kHz mono, 16-bit PCM)
|
||||
* Uses Web Audio API to decode and resample if needed.
|
||||
*/
|
||||
export async function audioToWavBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const arrayBuffer = reader.result
|
||||
|
||||
// Decode audio data using Web Audio API
|
||||
const audioContext = new (window.AudioContext || window.webkitAudioContext)()
|
||||
|
||||
// Use a short timeout to avoid hanging on unsupported formats
|
||||
const decodePromise = audioContext.decodeAudioData(arrayBuffer.slice(0))
|
||||
|
||||
// Set a timeout (10 seconds)
|
||||
const timeoutPromise = new Promise((_, rej) => {
|
||||
setTimeout(() => rej(new Error('音频解码超时,格式可能不支持')), 10000)
|
||||
})
|
||||
|
||||
const audioBuffer = await Promise.race([decodePromise, timeoutPromise])
|
||||
|
||||
// Close the context
|
||||
audioContext.close()
|
||||
|
||||
const wavBase64 = await audioBufferToWavBase64(audioBuffer)
|
||||
resolve(wavBase64)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
}
|
||||
|
||||
reader.onerror = () => reject(reader.error || new Error('Failed to read audio file'))
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert audio file to text using ASR endpoint.
|
||||
* Returns the recognized text string.
|
||||
*/
|
||||
export async function convertAudioToText(file, language = 'zh-CN') {
|
||||
// Step 1: Convert to WAV base64 (handles format conversion)
|
||||
const wavBase64 = await audioToWavBase64(file)
|
||||
|
||||
// Step 2: Send to ASR endpoint
|
||||
const res = await fetch(ASR_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'your-secret-key-here',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
audio_base64: wavBase64,
|
||||
language: language || 'zh-CN',
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.status === 501) {
|
||||
throw new Error('ASR 功能不可用,当前环境不支持语音识别')
|
||||
}
|
||||
|
||||
const data = await consumeSseResult(res)
|
||||
if (!data || typeof data.text !== 'string') {
|
||||
throw new Error('ASR 返回结果为空')
|
||||
}
|
||||
|
||||
return data.text
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user