diff --git a/package.json b/package.json index ea9b80d..947286f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "vite build", - "pub": "ev deploy ./dist -k vite-3d-template-demo -v 1.0.0 -u " + "pub": "ev deploy ./dist -k video-html-demos -v 1.0.0 -u " }, "keywords": [], "author": "", @@ -14,9 +14,13 @@ "packageManager": "pnpm@10.18.2", "devDependencies": { "@types/three": "^0.180.0", - "vite": "^7.1.9" + "@vitejs/plugin-basic-ssl": "^2.1.0", + "vite": "^7.1.9", + "ws": "npm:@kevisual/ws" }, "dependencies": { + "eventemitter3": "^5.0.1", + "nanoid": "^5.1.6", "three": "^0.180.0" } } diff --git a/public/b.html b/public/b.html new file mode 100644 index 0000000..5dfce68 --- /dev/null +++ b/public/b.html @@ -0,0 +1,45 @@ + + + + + + + 网页 + + + + sdf + + + + \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..852dcc7 --- /dev/null +++ b/readme.md @@ -0,0 +1,4 @@ +# 上传音频转文字 + +火山语音转文字,一句话识别技术 +https://www.volcengine.com/docs/6561/192519 \ No newline at end of file diff --git a/src/auc.ts b/src/auc.ts new file mode 100644 index 0000000..b6573ef --- /dev/null +++ b/src/auc.ts @@ -0,0 +1,136 @@ +// https://git.xiongxiao.me/kevisual/video-tools/raw/branch/main/src/asr/provider/volcengine/auc.ts +import { nanoid } from "nanoid" + +export const FlashURL = "https://openspeech.bytedance.com/api/v3/auc/bigmodel/recognize/flash" +export const AsrBaseURL = 'https://openspeech.bytedance.com/api/v3/auc/bigmodel/submit' +export const AsrBase = 'volc.bigasr.auc' +export const AsrTurbo = 'volc.bigasr.auc_turbo' + +const uuid = () => nanoid() + +type AsrOptions = { + url?: string + appid?: string + token?: string + type?: AsrType +} + +type AsrType = 'flash' | 'standard' | 'turbo' +export class Asr { + url: string = FlashURL + appid: string = "" + token: string = "" + type: AsrType = 'flash' + constructor(options: AsrOptions = {}) { + this.appid = options.appid || "" + this.token = options.token || "" + this.type = options.type || 'flash' + if (this.type !== 'flash') { + this.url = AsrBaseURL + } + if (!this.appid || !this.token) { + throw new Error("VOLCENGINE_Asr_APPID or VOLCENGINE_Asr_TOKEN is not set") + } + } + + header() { + const model = this.type === 'flash' ? AsrTurbo : AsrBase + return { + "X-Api-App-Key": this.appid, + "X-Api-Access-Key": this.token, + "X-Api-Resource-Id": model, + "X-Api-Request-Id": uuid(), + "X-Api-Sequence": "-1", + } + } + submit(body: AsrRequest) { + if (!body.audio || (!body.audio.url && !body.audio.data)) { + throw new Error("audio.url or audio.data is required") + } + const data: AsrRequest = { + ...body, + } + return fetch(this.url, { method: "POST", headers: this.header(), body: JSON.stringify(data) }) + } + async getText(body: AsrRequest) { + const res = await this.submit(body) + return res.json() + } +} + +export type AsrResponse = { + audio_info: { + /** + * 音频时长,单位为 ms + */ + duration: number; + }; + result: { + additions: { + duration: string; + }; + text: string; + utterances: Array<{ + end_time: number; + start_time: number; + text: string; + words: Array<{ + confidence: number; + end_time: number; + start_time: number; + text: string; + }>; + }>; + }; +} +export interface AsrRequest { + user?: { + uid: string; + }; + audio: { + url?: string; + data?: string; + format?: 'wav' | 'pcm' | 'mp3' | 'ogg'; + codec?: 'raw' | 'opus'; // raw / opus,默认为 raw(pcm) 。 + rate?: 8000 | 16000; // 采样率,支持 8000 或 16000,默认为 16000 。 + channel?: 1 | 2; // 声道数,支持 1 或 2,默认为 1。 + }; + + + request?: { + model_name?: string; // 识别模型名称,如 "bigmodel" + enable_words?: boolean; // 是否开启词级别时间戳,默认为 false。 + enable_sentence_info?: boolean; // 是否开启句子级别时间戳,默认为 false。 + enable_utterance_info?: boolean; // 是否开启语句级别时间戳,默认为 true。 + enable_punctuation_prediction?: boolean; // 是否开启标点符号预测,默认为 true。 + enable_inverse_text_normalization?: boolean; // 是否开启文本规范化,默认为 true。 + enable_separate_recognition_per_channel?: boolean; // 是否开启声道分离识别,默认为 false。 + audio_channel_count?: 1 | 2; // 音频声道数,仅在 enable_separate_recognition_per_channel 开启时有效,支持 1 或 2,默认为 1。 + max_sentence_silence?: number; // 句子最大静音时间,仅在 enable_sentence_info 开启时有效,单位为 ms,默认为 800。 + custom_words?: string[]; + enable_channel_split?: boolean; // 是否开启声道分离 + enable_ddc?: boolean; // 是否开启 DDC(双通道降噪) + enable_speaker_info?: boolean; // 是否开启说话人分离 + enable_punc?: boolean; // 是否开启标点符号预测(简写) + enable_itn?: boolean; // 是否开启文本规范化(简写) + vad_segment?: boolean; // 是否开启 VAD 断句 + show_utterances?: boolean; // 是否返回语句级别结果 + corpus?: { + boosting_table_name?: string; + correct_table_name?: string; + context?: string; + }; + }; +} + +// const main = async () => { +// const base64Audio = wavToBase64(audioPath); +// const auc = new Asr({ +// appid: config.VOLCENGINE_AUC_APPID, +// token: config.VOLCENGINE_AUC_TOKEN, +// }); +// const result = await auc.getText({ audio: { data: base64Audio } }); +// console.log(util.inspect(result, { showHidden: false, depth: null, colors: true })) +// } + +// main(); \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..ad97f10 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,30 @@ +export const getConfig = () => { + // 从localStorage获取配置,如果不存在则使用默认值 + const getFromLocalStorage = (key: string, defaultValue: string) => { + try { + return localStorage.getItem(key) || defaultValue; + } catch (error) { + console.warn(`Failed to read ${key} from localStorage:`, error); + return defaultValue; + } + }; + + return { + VOLCENGINE_AUC_APPID: getFromLocalStorage('VOLCENGINE_AUC_APPID', '123456'), + VOLCENGINE_AUC_TOKEN: getFromLocalStorage('VOLCENGINE_AUC_TOKEN', 'passwd'), + }; +}; + +export const setConfig = (config: { VOLCENGINE_AUC_APPID?: string; VOLCENGINE_AUC_TOKEN?: string }) => { + // 将配置保存到localStorage + try { + if (config.VOLCENGINE_AUC_APPID !== undefined) { + localStorage.setItem('VOLCENGINE_AUC_APPID', config.VOLCENGINE_AUC_APPID); + } + if (config.VOLCENGINE_AUC_TOKEN !== undefined) { + localStorage.setItem('VOLCENGINE_AUC_TOKEN', config.VOLCENGINE_AUC_TOKEN); + } + } catch (error) { + console.warn('Failed to save config to localStorage:', error); + } +}; \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 0d0c07b..872866a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,2 +1,538 @@ -document.body.innerHTML = 'Hello World' \ No newline at end of file +import { Asr, type AsrRequest, type AsrResponse } from './auc.ts'; +import { getConfig, setConfig } from './config.ts'; +import { AudioRecorder, RecordingState, type RecorderOptions } from './recorder.ts'; + +// 创建页面结构 +function createPageStructure() { + const html = ` +
+
+

音频转文字工具

+ +
+
+
+
+ + +
+
+ + +
+
+
+
+
+ +
+
+ + +
+
+ + + + `; + document.body.innerHTML = html; +} + +// 全局变量存储选中的文件 +let selectedFile: File | null = null; + +// 全局变量存储录制器和录制状态 +let audioRecorder: AudioRecorder | null = null; +let recordedAudioBlob: Blob | null = null; +let isUsingRecordedAudio = false; + +// 文件上传处理函数 +function setupFileUpload() { + const fileInput = document.getElementById('audioFile') as HTMLInputElement; + const uploadBtn = document.getElementById('uploadBtn') as HTMLButtonElement; + const convertBtn = document.getElementById('convertBtn') as HTMLButtonElement; + const fileInfo = document.getElementById('fileInfo') as HTMLDivElement; + + // 点击上传按钮,触发文件选择 + uploadBtn.addEventListener('click', () => { + fileInput.click(); + }); + + // 文件选择处理 + fileInput.addEventListener('change', (event) => { + const target = event.target as HTMLInputElement; + const file = target.files?.[0]; + + if (file) { + // 验证文件类型 + if (!file.type.startsWith('audio/')) { + alert('请选择音频文件!'); + return; + } + + // 验证文件大小 (例如限制为100MB) + const maxSize = 100 * 1024 * 1024; // 100MB + if (file.size > maxSize) { + alert('文件大小不能超过100MB!'); + return; + } + + selectedFile = file; + + // 清除录制状态 + recordedAudioBlob = null; + isUsingRecordedAudio = false; + + // 显示文件信息 + const fileSizeMB = (file.size / 1024 / 1024).toFixed(2); + fileInfo.innerHTML = ` +
+

已选择文件: ${file.name}

+

文件大小: ${fileSizeMB} MB

+

文件类型: ${file.type}

+
+ `; + + // 启用转换按钮 + convertBtn.disabled = false; + uploadBtn.textContent = '重新选择文件'; + } + }); +} + +// 将文件转换为base64 +function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // 移除data:audio/xxx;base64,前缀,只保留base64数据 + const base64Data = result.split(',')[1]; + resolve(base64Data); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +// 获取音频格式 +function getAudioFormat(file: File): string { + const mimeType = file.type; + if (mimeType.includes('wav')) return 'wav'; + if (mimeType.includes('mp3')) return 'mp3'; + if (mimeType.includes('ogg')) return 'ogg'; + if (mimeType.includes('pcm')) return 'pcm'; + if (mimeType.includes('webm')) return 'webm'; + // 默认返回wav + return 'wav'; +} + +// 从Blob获取音频格式 +function getAudioFormatFromBlob(blob: Blob): string { + const mimeType = blob.type; + if (mimeType.includes('wav')) return 'wav'; + if (mimeType.includes('mp3')) return 'mp3'; + if (mimeType.includes('ogg')) return 'ogg'; + if (mimeType.includes('pcm')) return 'pcm'; + if (mimeType.includes('webm')) return 'webm'; + // 默认返回webm (录制的音频通常是webm格式) + return 'webm'; +} + +// ASR转换功能 +async function convertToText() { + if (!selectedFile && !recordedAudioBlob) { + alert('请先选择音频文件或录制音频!'); + return; + } + + const loadingEl = document.getElementById('loading') as HTMLDivElement; + const resultEl = document.getElementById('result') as HTMLDivElement; + const resultText = document.getElementById('resultText') as HTMLDivElement; + const resultDetails = document.getElementById('resultDetails') as HTMLDivElement; + const convertBtn = document.getElementById('convertBtn') as HTMLButtonElement; + + try { + // 显示加载状态 + loadingEl.style.display = 'flex'; + resultEl.style.display = 'none'; + convertBtn.disabled = true; + convertBtn.textContent = '转换中...'; + + // 获取音频数据 + let base64Data: string; + let audioFormat: string; + + if (isUsingRecordedAudio && recordedAudioBlob) { + // 使用录制的音频 + const recordedFile = AudioRecorder.blobToFile(recordedAudioBlob, 'recording.webm'); + base64Data = await fileToBase64(recordedFile); + audioFormat = getAudioFormatFromBlob(recordedAudioBlob); + } else if (selectedFile) { + // 使用选择的文件 + base64Data = await fileToBase64(selectedFile); + audioFormat = getAudioFormat(selectedFile); + } else { + throw new Error('没有可用的音频数据'); + } + const config = getConfig(); + const VOLCENGINE_AUC_APPID = config.VOLCENGINE_AUC_APPID; + const VOLCENGINE_AUC_TOKEN = config.VOLCENGINE_AUC_TOKEN; + // 配置ASR请求 + // 注意:这里需要提供真实的API密钥 + const asr = new Asr({ + appid: VOLCENGINE_AUC_APPID, // 请替换为真实的APP ID + token: VOLCENGINE_AUC_TOKEN, // 请替换为真实的ACCESS TOKEN + type: 'flash' // 使用flash模式进行快速识别 + }); + + const asrRequest: AsrRequest = { + audio: { + data: base64Data, + format: audioFormat as any, + rate: 16000, + channel: 1 + }, + request: { + enable_words: true, + enable_sentence_info: true, + enable_utterance_info: true, + enable_punctuation_prediction: true, + enable_inverse_text_normalization: true + } + }; + + // 调用ASR API + const response: AsrResponse = await asr.getText(asrRequest); + + // 隐藏加载状态 + loadingEl.style.display = 'none'; + + // 显示结果 + if (response.result && response.result.text) { + resultText.innerHTML = `

${response.result.text}

`; + + // 显示详细信息 + const duration = response.audio_info?.duration ? (response.audio_info.duration / 1000).toFixed(2) + '秒' : '未知'; + let detailsHtml = ` +
+

音频信息:

+

时长: ${duration}

+
+ `; + + // 如果有语句级别的信息,显示时间戳 + if (response.result.utterances && response.result.utterances.length > 0) { + detailsHtml += ` +
+

分句结果:

+ +
+ `; + } + + resultDetails.innerHTML = detailsHtml; + resultEl.style.display = 'block'; + } else { + throw new Error('转换失败:未能获取到文字结果'); + } + + } catch (error) { + console.error('转换出错:', error); + loadingEl.style.display = 'none'; + + let errorMessage = '转换失败,请稍后重试。'; + if (error instanceof Error) { + if (error.message.includes('VOLCENGINE_Asr_APPID') || error.message.includes('VOLCENGINE_Asr_TOKEN')) { + errorMessage = '请配置正确的API密钥(APP ID和Access Token)后重试。'; + } else { + errorMessage = `转换失败: ${error.message}`; + } + } + + alert(errorMessage); + } finally { + convertBtn.disabled = false; + convertBtn.textContent = '转换为文字'; + } +} + +// 设置转换按钮 +function setupConvertButton() { + const convertBtn = document.getElementById('convertBtn') as HTMLButtonElement; + + convertBtn.addEventListener('click', convertToText); +} + +// 设置弹窗功能 +function setupSettingsModal() { + const settingsBtn = document.getElementById('settingsBtn') as HTMLButtonElement; + const settingsModal = document.getElementById('settingsModal') as HTMLDivElement; + const closeModal = document.getElementById('closeModal') as HTMLButtonElement; + const saveSettings = document.getElementById('saveSettings') as HTMLButtonElement; + const cancelSettings = document.getElementById('cancelSettings') as HTMLButtonElement; + const appIdInput = document.getElementById('appId') as HTMLInputElement; + const accessTokenInput = document.getElementById('accessToken') as HTMLInputElement; + + // 打开设置弹窗 + function openSettingsModal() { + // 加载当前配置 + const config = getConfig(); + appIdInput.value = config.VOLCENGINE_AUC_APPID; + accessTokenInput.value = config.VOLCENGINE_AUC_TOKEN; + + settingsModal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; // 防止背景滚动 + } + + // 关闭设置弹窗 + function closeSettingsModal() { + settingsModal.style.display = 'none'; + document.body.style.overflow = 'auto'; // 恢复滚动 + } + + // 保存设置 + function saveSettingsHandler() { + const appId = appIdInput.value.trim(); + const accessToken = accessTokenInput.value.trim(); + + if (!appId || !accessToken) { + alert('请填写完整的APP ID和Access Token'); + return; + } + + // 保存到localStorage + try { + setConfig({ + VOLCENGINE_AUC_APPID: appId, + VOLCENGINE_AUC_TOKEN: accessToken + }); + alert('设置保存成功!'); + closeSettingsModal(); + } catch (error) { + console.error('保存设置失败:', error); + alert('保存设置失败,请重试'); + } + } + + // 绑定事件 + settingsBtn.addEventListener('click', openSettingsModal); + closeModal.addEventListener('click', closeSettingsModal); + cancelSettings.addEventListener('click', closeSettingsModal); + saveSettings.addEventListener('click', saveSettingsHandler); + + // 点击弹窗背景关闭 + settingsModal.addEventListener('click', (e) => { + if (e.target === settingsModal) { + closeSettingsModal(); + } + }); + + // ESC键关闭弹窗 + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && settingsModal.style.display === 'flex') { + closeSettingsModal(); + } + }); +} + +// 录制功能设置 +function setupRecording() { + const recordBtn = document.getElementById('recordBtn') as HTMLButtonElement; + const recordIcon = document.getElementById('recordIcon') as HTMLElement; + const recordText = document.getElementById('recordText') as HTMLSpanElement; + const recordStatus = document.getElementById('recordStatus') as HTMLDivElement; + const recordTime = document.getElementById('recordTime') as HTMLSpanElement; + const fileInfo = document.getElementById('fileInfo') as HTMLDivElement; + const convertBtn = document.getElementById('convertBtn') as HTMLButtonElement; + const uploadBtn = document.getElementById('uploadBtn') as HTMLButtonElement; + + // 检查浏览器支持 + if (!AudioRecorder.isSupported()) { + recordBtn.disabled = true; + recordBtn.title = '当前浏览器不支持录音功能'; + recordText.textContent = '不支持录音'; + return; + } + + // 初始化录制器 + function initRecorder() { + if (audioRecorder) { + audioRecorder.destroy(); + } + + const options: RecorderOptions = { + audioBitsPerSecond: 128000, + mimeType: AudioRecorder.getSupportedMimeTypes()[0] || 'audio/webm' + }; + + audioRecorder = new AudioRecorder(options); + + // 监听状态变化 + audioRecorder.on('stateChange', (state: RecordingState) => { + updateRecordUI(state); + }); + + // 监听时间更新 + audioRecorder.on('timeUpdate', (time: number) => { + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + recordTime.textContent = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + }); + + // 监听录制完成 + audioRecorder.on('dataAvailable', (blob: Blob) => { + recordedAudioBlob = blob; + isUsingRecordedAudio = true; + + // 清除选择的文件 + selectedFile = null; + const fileInput = document.getElementById('audioFile') as HTMLInputElement; + fileInput.value = ''; + + // 显示录制信息 + const fileSizeMB = (blob.size / 1024 / 1024).toFixed(2); + fileInfo.innerHTML = ` +
+

录制完成: 新录音

+

录制时长: ${recordTime.textContent}

+

文件大小: ${fileSizeMB} MB

+

文件类型: ${blob.type}

+
+ `; + + // 启用转换按钮 + convertBtn.disabled = false; + + // 更新上传按钮文字 + uploadBtn.textContent = '选择音频文件'; + }); + + // 监听错误 + audioRecorder.on('error', (error: Error) => { + console.error('录制错误:', error); + alert(`录制失败: ${error.message}`); + updateRecordUI(RecordingState.IDLE); + }); + } + + // 更新录制UI + function updateRecordUI(state: RecordingState) { + switch (state) { + case RecordingState.IDLE: + recordBtn.classList.remove('recording', 'stopping'); + recordBtn.disabled = false; + recordIcon.innerHTML = ''; + recordText.textContent = '录制音频'; + recordStatus.style.display = 'none'; + recordTime.textContent = '00:00'; + break; + + case RecordingState.RECORDING: + recordBtn.classList.add('recording'); + recordBtn.classList.remove('stopping'); + recordBtn.disabled = false; + recordIcon.innerHTML = ''; + recordText.textContent = '停止录制'; + recordStatus.style.display = 'flex'; + break; + + case RecordingState.STOPPED: + recordBtn.classList.remove('recording'); + recordBtn.classList.add('stopping'); + recordBtn.disabled = true; + recordText.textContent = '处理中...'; + recordStatus.style.display = 'none'; + break; + } + } + + // 录制按钮点击事件 + recordBtn.addEventListener('click', async () => { + if (!audioRecorder) { + initRecorder(); + } + + try { + const currentState = audioRecorder!.getState(); + + if (currentState === RecordingState.IDLE) { + // 开始录制 + await audioRecorder!.startRecording(); + } else if (currentState === RecordingState.RECORDING) { + // 停止录制 + audioRecorder!.stopRecording(); + } + } catch (error) { + console.error('录制操作失败:', error); + alert(`操作失败: ${error instanceof Error ? error.message : '未知错误'}`); + } + }); + + // 初始化录制器 + initRecorder(); +} + +// 初始化页面 +createPageStructure(); +setupFileUpload(); +setupConvertButton(); +setupSettingsModal(); +setupRecording(); \ No newline at end of file diff --git a/src/recorder.ts b/src/recorder.ts new file mode 100644 index 0000000..6ee6b4e --- /dev/null +++ b/src/recorder.ts @@ -0,0 +1,274 @@ +// 录音状态枚举 +export enum RecordingState { + IDLE = 'idle', // 空闲状态 + RECORDING = 'recording', // 录制中 + STOPPED = 'stopped' // 已停止 +} + +// 录音事件类型 +export interface RecorderEvents { + 'stateChange': (state: RecordingState) => void; + 'dataAvailable': (data: Blob) => void; + 'error': (error: Error) => void; + 'timeUpdate': (time: number) => void; +} + +// 录音器配置 +export interface RecorderOptions { + audioBitsPerSecond?: number; // 音频比特率 + mimeType?: string; // MIME类型 + timeslice?: number; // 时间切片间隔(ms) +} + +export class AudioRecorder { + private mediaRecorder: MediaRecorder | null = null; + private mediaStream: MediaStream | null = null; + private audioChunks: Blob[] = []; + private state: RecordingState = RecordingState.IDLE; + private startTime: number = 0; + private timeUpdateInterval: number | null = null; + private eventListeners: Map = new Map(); + + constructor(private options: RecorderOptions = {}) { + // 设置默认选项 + this.options = { + audioBitsPerSecond: 128000, + mimeType: 'audio/webm;codecs=opus', + timeslice: 100, + ...options + }; + } + + // 添加事件监听器 + on(event: K, listener: RecorderEvents[K]): void { + if (!this.eventListeners.has(event)) { + this.eventListeners.set(event, []); + } + this.eventListeners.get(event)!.push(listener); + } + + // 移除事件监听器 + off(event: K, listener: RecorderEvents[K]): void { + const listeners = this.eventListeners.get(event); + if (listeners) { + const index = listeners.indexOf(listener); + if (index > -1) { + listeners.splice(index, 1); + } + } + } + + // 触发事件 + private emit(event: K, ...args: Parameters): void { + const listeners = this.eventListeners.get(event); + if (listeners) { + listeners.forEach(listener => { + try { + (listener as any)(...args); + } catch (error) { + console.error('Error in event listener:', error); + } + }); + } + } + + // 检查浏览器支持 + static isSupported(): boolean { + return !!(navigator.mediaDevices && + typeof navigator.mediaDevices.getUserMedia === 'function' && + window.MediaRecorder); + } + + // 请求麦克风权限 + private async requestMicrophonePermission(): Promise { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + sampleRate: 16000 + } + }); + return stream; + } catch (error) { + throw new Error(`无法获取麦克风权限: ${error instanceof Error ? error.message : '未知错误'}`); + } + } + + // 开始录制 + async startRecording(): Promise { + if (this.state !== RecordingState.IDLE) { + throw new Error('录制器当前不在空闲状态'); + } + + if (!AudioRecorder.isSupported()) { + throw new Error('当前浏览器不支持录音功能'); + } + + try { + // 获取媒体流 + this.mediaStream = await this.requestMicrophonePermission(); + + // 创建MediaRecorder实例 + let mimeType = this.options.mimeType!; + + // 检查支持的MIME类型 + if (!MediaRecorder.isTypeSupported(mimeType)) { + // 降级到更通用的格式 + const fallbackTypes = [ + 'audio/webm', + 'audio/mp4', + 'audio/ogg', + 'audio/wav' + ]; + + mimeType = fallbackTypes.find(type => MediaRecorder.isTypeSupported(type)) || 'audio/webm'; + } + + this.mediaRecorder = new MediaRecorder(this.mediaStream, { + audioBitsPerSecond: this.options.audioBitsPerSecond, + mimeType: mimeType + }); + + // 清空之前的录制数据 + this.audioChunks = []; + + // 设置事件监听器 + this.mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + this.audioChunks.push(event.data); + } + }; + + this.mediaRecorder.onstop = () => { + this.setState(RecordingState.STOPPED); + + // 合并所有音频块 + const audioBlob = new Blob(this.audioChunks, { type: mimeType }); + this.emit('dataAvailable', audioBlob); + + // 清理资源 + this.cleanup(); + }; + + this.mediaRecorder.onerror = (event) => { + const error = new Error(`录制出错: ${event}`); + this.emit('error', error); + this.cleanup(); + }; + + // 开始录制 + this.mediaRecorder.start(this.options.timeslice); + this.startTime = Date.now(); + this.setState(RecordingState.RECORDING); + + // 开始时间更新 + this.startTimeUpdate(); + + } catch (error) { + this.cleanup(); + throw error; + } + } + + // 停止录制 + stopRecording(): void { + if (this.state !== RecordingState.RECORDING) { + throw new Error('录制器当前不在录制状态'); + } + + if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') { + this.mediaRecorder.stop(); + } + } + + // 获取当前状态 + getState(): RecordingState { + return this.state; + } + + // 获取录制时长(秒) + getRecordingTime(): number { + if (this.state === RecordingState.RECORDING) { + return (Date.now() - this.startTime) / 1000; + } + return 0; + } + + // 设置状态 + private setState(newState: RecordingState): void { + if (this.state !== newState) { + this.state = newState; + this.emit('stateChange', newState); + } + } + + // 开始时间更新 + private startTimeUpdate(): void { + this.timeUpdateInterval = window.setInterval(() => { + if (this.state === RecordingState.RECORDING) { + const currentTime = this.getRecordingTime(); + this.emit('timeUpdate', currentTime); + } + }, 100); + } + + // 停止时间更新 + private stopTimeUpdate(): void { + if (this.timeUpdateInterval) { + clearInterval(this.timeUpdateInterval); + this.timeUpdateInterval = null; + } + } + + // 清理资源 + private cleanup(): void { + this.stopTimeUpdate(); + + if (this.mediaStream) { + this.mediaStream.getTracks().forEach(track => track.stop()); + this.mediaStream = null; + } + + this.mediaRecorder = null; + } + + // 销毁录制器 + destroy(): void { + if (this.state === RecordingState.RECORDING) { + this.stopRecording(); + } + + this.cleanup(); + this.eventListeners.clear(); + this.setState(RecordingState.IDLE); + } + + // 将Blob转换为File对象 + static blobToFile(blob: Blob, filename: string = 'recording.webm'): File { + return new File([blob], filename, { + type: blob.type, + lastModified: Date.now() + }); + } + + // 获取支持的MIME类型 + static getSupportedMimeTypes(): string[] { + const types = [ + 'audio/webm;codecs=opus', + 'audio/webm', + 'audio/mp4', + 'audio/ogg;codecs=opus', + 'audio/ogg', + 'audio/wav' + ]; + + return types.filter(type => MediaRecorder.isTypeSupported(type)); + } +} + +// 导出默认实例创建函数 +export function createRecorder(options?: RecorderOptions): AudioRecorder { + return new AudioRecorder(options); +} diff --git a/src/style.css b/src/style.css index 14b43b0..a644a98 100644 --- a/src/style.css +++ b/src/style.css @@ -1,8 +1,529 @@ html, body { width: 100%; - - height: 100%; + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + overflow-x: hidden; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 20px; + min-height: 100vh; + box-sizing: border-box; +} + +h1 { + text-align: center; + color: white; + margin-bottom: 40px; + font-size: 2.5rem; + font-weight: 300; + text-shadow: 0 2px 4px rgba(0,0,0,0.3); +} + +.upload-section, .action-section, .result-section { + background: white; + border-radius: 12px; + padding: 30px; + margin-bottom: 20px; + box-shadow: 0 8px 32px rgba(0,0,0,0.1); + backdrop-filter: blur(10px); +} + +/* 输入按钮区域样式 */ +.input-buttons { + display: flex; + gap: 20px; + align-items: stretch; + justify-content: center; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.file-input-wrapper, .record-wrapper { + flex: 1; + min-width: 200px; + text-align: center; +} + +.file-input-wrapper { + display: flex; + align-items: center; + justify-content: center; +} + +.upload-btn, .convert-btn, .stream-convert-btn, .record-btn { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + padding: 15px 30px; + border-radius: 8px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); + margin: 0 10px; + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 48px; +} + +/* 录制按钮特殊样式 */ +.record-btn { + background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%); + box-shadow: 0 4px 15px rgba(255, 107, 107, 0.4); + position: relative; overflow: hidden; +} + +.record-btn.recording { + background: linear-gradient(135deg, #ff4757 0%, #c44569 100%); + animation: recordingPulse 1.5s ease-in-out infinite; +} + +.record-btn.stopping { + background: linear-gradient(135deg, #ffa502 0%, #ff6348 100%); + cursor: not-allowed; +} + +@keyframes recordingPulse { + 0%, 100% { + box-shadow: 0 4px 15px rgba(255, 107, 107, 0.4); + } + 50% { + box-shadow: 0 4px 25px rgba(255, 107, 107, 0.8), 0 0 20px rgba(255, 107, 107, 0.3); + } +} + +.stream-convert-btn { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + box-shadow: 0 4px 15px rgba(240, 147, 251, 0.4); +} + +.upload-btn:hover, .convert-btn:hover:not(:disabled), .stream-convert-btn:hover:not(:disabled), .record-btn:hover:not(:disabled):not(.recording):not(.stopping) { + transform: translateY(-2px); +} + +.record-btn:hover:not(:disabled):not(.recording):not(.stopping) { + box-shadow: 0 6px 20px rgba(255, 107, 107, 0.6); +} + +.convert-btn:hover:not(:disabled) { + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6); +} + +.stream-convert-btn:hover:not(:disabled) { + box-shadow: 0 6px 20px rgba(240, 147, 251, 0.6); +} + +.convert-btn:disabled, .stream-convert-btn:disabled { + background: #ccc; + cursor: not-allowed; + box-shadow: none; + transform: none; +} + +.file-info { + margin-top: 20px; +} + +.file-selected, .file-recorded { + border-radius: 8px; + padding: 20px; + text-align: center; +} + +.file-selected { + background: #f8f9ff; + border: 2px dashed #667eea; +} + +.file-recorded { + background: #fff8f8; + border: 2px dashed #ff6b6b; +} + +.file-selected p, .file-recorded p { + margin: 8px 0; + color: #444; +} + +/* 录制状态指示器 */ +.record-status { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 10px; + padding: 8px 16px; + background: rgba(255, 107, 107, 0.1); + border-radius: 20px; + color: #ff4757; + font-size: 14px; + font-weight: 500; +} + +.recording-indicator { + width: 8px; + height: 8px; + background: #ff4757; + border-radius: 50%; + animation: recordingBlink 1s ease-in-out infinite; +} + +@keyframes recordingBlink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0.3; } +} + +/* 录制区域样式 */ +.record-wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.action-section { + text-align: center; +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + gap: 15px; + color: #667eea; + font-size: 18px; + font-weight: 500; +} + +.spinner { + width: 24px; + height: 24px; + border: 3px solid #f3f3f3; + border-top: 3px solid #667eea; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.result h3 { + color: #333; + margin-bottom: 20px; + font-size: 1.5rem; +} + +.result-text { + background: #f8f9ff; + border-left: 4px solid #667eea; + padding: 20px; + margin-bottom: 20px; + border-radius: 4px; + font-size: 16px; + line-height: 1.6; +} + +.audio-info h4, .utterances h4 { + color: #333; + margin-bottom: 15px; + font-size: 1.2rem; +} + +.audio-info p { + margin: 8px 0; + color: #666; +} + +.utterances ul { + list-style: none; + padding: 0; margin: 0; } + +.utterances li { + background: #f5f5f5; + margin-bottom: 10px; + padding: 15px; + border-radius: 6px; + border-left: 3px solid #667eea; +} + +.timestamp { + display: inline-block; + background: #667eea; + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + margin-right: 10px; +} + +.text { + color: #333; + line-height: 1.5; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .container { + padding: 15px; + } + + h1 { + font-size: 2rem; + margin-bottom: 30px; + } + + .upload-section, .action-section, .result-section { + padding: 20px; + margin-bottom: 15px; + } + + .upload-btn, .convert-btn, .stream-convert-btn, .record-btn { + padding: 12px 24px; + font-size: 14px; + margin: 5px; + display: inline-flex; + width: auto; + min-width: 120px; + } + + .input-buttons { + flex-direction: column; + gap: 10px; + } + + .file-input-wrapper, .record-wrapper { + min-width: auto; + width: 100%; + } +} + +/* 头部样式 */ +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 40px; +} + +.header h1 { + margin: 0; +} + +.settings-btn { + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s ease; + backdrop-filter: blur(10px); +} + +.settings-btn:hover { + background: rgba(255, 255, 255, 0.3); + transform: rotate(15deg); +} + +.settings-btn svg { + color: white; +} + +/* 弹窗样式 */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(5px); +} + +.modal-content { + background: white; + border-radius: 12px; + width: 90%; + max-width: 500px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: modalSlideIn 0.3s ease-out; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-50px) scale(0.9); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px 24px 0; + border-bottom: 1px solid #eee; + margin-bottom: 0; + padding-bottom: 16px; +} + +.modal-header h2 { + margin: 0; + color: #333; + font-size: 1.5rem; + font-weight: 500; +} + +.close-btn { + background: none; + border: none; + font-size: 28px; + cursor: pointer; + color: #999; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s ease; +} + +.close-btn:hover { + background: #f5f5f5; + color: #666; +} + +.modal-body { + padding: 24px; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + color: #333; + font-weight: 500; + font-size: 14px; +} + +.form-group input { + width: 100%; + padding: 12px 16px; + border: 2px solid #e1e5e9; + border-radius: 8px; + font-size: 14px; + transition: border-color 0.3s ease; + box-sizing: border-box; + background: #fafbfc; +} + +.form-group input:focus { + outline: none; + border-color: #667eea; + background: white; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.form-group input::placeholder { + color: #999; +} + +.modal-footer { + padding: 0 24px 24px; + display: flex; + gap: 12px; + justify-content: flex-end; +} + +.save-btn, .cancel-btn { + padding: 12px 24px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + border: none; +} + +.save-btn { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); +} + +.save-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6); +} + +.cancel-btn { + background: #f8f9fa; + color: #666; + border: 1px solid #e1e5e9; +} + +.cancel-btn:hover { + background: #e9ecef; +} + +/* 响应式设计扩展 */ +@media (max-width: 768px) { + .header { + flex-direction: column; + gap: 20px; + text-align: center; + } + + .settings-btn { + position: absolute; + top: 20px; + right: 20px; + } + + .modal-content { + width: 95%; + margin: 20px; + } + + .modal-header, .modal-body, .modal-footer { + padding-left: 16px; + padding-right: 16px; + } + + .modal-footer { + flex-direction: column-reverse; + } + + .save-btn, .cancel-btn { + width: 100%; + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..69fb8e8 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import basicSsl from '@vitejs/plugin-basic-ssl' + +export default defineConfig({ + base: "./", + plugins: [basicSsl()], + server: { + https: true, + host: true, + port: 3000, + proxy: { + "/api": { + target: "https://kevisual.xiongxiao.me", + changeOrigin: true, + ws: true, + rewrite: (path) => path.replace(/^\/api/, "/api") + } + } + } +}) \ No newline at end of file