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) => { 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', 'X-API-Key': 'your-secret-key-here', }, body: JSON.stringify({ file: base64, filename: file.name || 'document', }), }) 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 }