Migrate backend jobs to Redis Streams

This commit is contained in:
“ydy0615”
2026-06-06 15:44:00 +08:00
parent 2c7a02f587
commit 81f711ef0b
69 changed files with 2709 additions and 6291 deletions
+272 -7
View File
@@ -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
}