2026-06-06 15:44:00 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-14 19:24:15 +08:00
|
|
|
|
|
|
|
|
function readFileAsBase64(file) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const reader = new FileReader()
|
|
|
|
|
reader.onload = () => {
|
|
|
|
|
const dataUrl = typeof reader.result === 'string' ? reader.result : ''
|
|
|
|
|
const splitIndex = dataUrl.indexOf(',')
|
|
|
|
|
if (splitIndex === -1) {
|
|
|
|
|
reject(new Error('Invalid file data'))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
resolve(dataUrl.slice(splitIndex + 1))
|
|
|
|
|
}
|
|
|
|
|
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
|
|
|
|
|
reader.readAsDataURL(file)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function convertFileToMarkdown(file) {
|
|
|
|
|
const base64 = await readFileAsBase64(file)
|
|
|
|
|
const res = await fetch(CONVERT_URL, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
2026-04-04 23:56:18 +08:00
|
|
|
'X-API-Key': 'your-secret-key-here',
|
2026-03-14 19:24:15 +08:00
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
file: base64,
|
|
|
|
|
filename: file.name || 'document',
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
const data = await consumeSseResult(res)
|
2026-03-14 19:24:15 +08:00
|
|
|
if (!data || typeof data.markdown !== 'string') {
|
|
|
|
|
throw new Error('No markdown returned')
|
|
|
|
|
}
|
|
|
|
|
return data.markdown
|
|
|
|
|
}
|
2026-06-06 15:44:00 +08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}
|