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 += `
+
+
分句结果:
+
+ `;
+ response.result.utterances.forEach((utterance, index) => {
+ const startTime = (utterance.start_time / 1000).toFixed(2);
+ const endTime = (utterance.end_time / 1000).toFixed(2);
+ detailsHtml += `
+ -
+ ${startTime}s - ${endTime}s:
+ ${utterance.text}
+
+ `;
+ });
+ 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