generated from template/vite-3d-template
update
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
45
public/b.html
Normal file
45
public/b.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>网页</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
sdf
|
||||
<script type="module">
|
||||
import { QueryClient } from 'https://esm.xiongxiao.me/@kevisual/query'
|
||||
const url = 'https://kevisual.xiongxiao.me/api/router'
|
||||
const devUrl = 'http://localhost:4002/api/router'
|
||||
const query = new QueryClient({ io: true, url: devUrl })
|
||||
console.log(query)
|
||||
query.qws.listenConnect(() => {
|
||||
console.log('Connected to WebSocket server');
|
||||
const test = {
|
||||
path: "test",
|
||||
key: "test"
|
||||
}
|
||||
const me = {
|
||||
path: 'user',
|
||||
key: 'me'
|
||||
}
|
||||
// query.qws.send({
|
||||
// type: 'router', data: {
|
||||
// ...me,
|
||||
// token: 'st_9lpn3uy6qtso7346qqey0w0623mxpfsi'
|
||||
// }
|
||||
// });
|
||||
query.qws.send('ping')
|
||||
query.qws.send({
|
||||
type: 'router',
|
||||
id: "123",
|
||||
data: test
|
||||
})
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
4
readme.md
Normal file
4
readme.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# 上传音频转文字
|
||||
|
||||
火山语音转文字,一句话识别技术
|
||||
https://www.volcengine.com/docs/6561/192519
|
||||
136
src/auc.ts
Normal file
136
src/auc.ts
Normal file
@@ -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();
|
||||
30
src/config.ts
Normal file
30
src/config.ts
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
538
src/main.ts
538
src/main.ts
@@ -1,2 +1,538 @@
|
||||
|
||||
document.body.innerHTML = 'Hello World'
|
||||
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 = `
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>音频转文字工具</h1>
|
||||
<button id="settingsBtn" class="settings-btn" title="设置">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1 1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="upload-section">
|
||||
<div class="input-buttons">
|
||||
<div class="file-input-wrapper">
|
||||
<input type="file" id="audioFile" accept="audio/*" style="display: none;">
|
||||
<button id="uploadBtn" class="upload-btn">选择音频文件</button>
|
||||
</div>
|
||||
<div class="record-wrapper">
|
||||
<button id="recordBtn" class="record-btn" title="录制音频">
|
||||
<svg id="recordIcon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
<span id="recordText">录制音频</span>
|
||||
</button>
|
||||
<div class="record-status" id="recordStatus" style="display: none;">
|
||||
<div class="recording-indicator"></div>
|
||||
<span id="recordTime">00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-info" id="fileInfo"></div>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<button id="convertBtn" class="convert-btn" disabled>转换为文字</button>
|
||||
</div>
|
||||
<div class="result-section">
|
||||
<div class="loading" id="loading" style="display: none;">
|
||||
<div class="spinner"></div>
|
||||
<span>正在转换中...</span>
|
||||
</div>
|
||||
<div class="result" id="result" style="display: none;">
|
||||
<h3>转换结果:</h3>
|
||||
<div class="result-text" id="resultText"></div>
|
||||
<div class="result-details" id="resultDetails"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置弹窗 -->
|
||||
<div id="settingsModal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>配置设置: <a href="https://www.volcengine.com/docs/6561/192519" target="_blank">火山:录音文件识别极速版</a></h2>
|
||||
<button id="closeModal" class="close-btn">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="settingsForm">
|
||||
<div class="form-group">
|
||||
<label for="appId">APP ID:</label>
|
||||
<input type="text" id="appId" name="appId" placeholder="请输入火山引擎ASR的APP ID" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="accessToken">Access Token:</label>
|
||||
<input type="password" id="accessToken" name="accessToken" placeholder="请输入火山引擎ASR的Access Token" required>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="saveSettings" class="save-btn">保存</button>
|
||||
<button id="cancelSettings" class="cancel-btn">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="file-selected">
|
||||
<p><strong>已选择文件:</strong> ${file.name}</p>
|
||||
<p><strong>文件大小:</strong> ${fileSizeMB} MB</p>
|
||||
<p><strong>文件类型:</strong> ${file.type}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 启用转换按钮
|
||||
convertBtn.disabled = false;
|
||||
uploadBtn.textContent = '重新选择文件';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 将文件转换为base64
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
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 = `<p>${response.result.text}</p>`;
|
||||
|
||||
// 显示详细信息
|
||||
const duration = response.audio_info?.duration ? (response.audio_info.duration / 1000).toFixed(2) + '秒' : '未知';
|
||||
let detailsHtml = `
|
||||
<div class="audio-info">
|
||||
<h4>音频信息:</h4>
|
||||
<p><strong>时长:</strong> ${duration}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 如果有语句级别的信息,显示时间戳
|
||||
if (response.result.utterances && response.result.utterances.length > 0) {
|
||||
detailsHtml += `
|
||||
<div class="utterances">
|
||||
<h4>分句结果:</h4>
|
||||
<ul>
|
||||
`;
|
||||
response.result.utterances.forEach((utterance, index) => {
|
||||
const startTime = (utterance.start_time / 1000).toFixed(2);
|
||||
const endTime = (utterance.end_time / 1000).toFixed(2);
|
||||
detailsHtml += `
|
||||
<li>
|
||||
<span class="timestamp">${startTime}s - ${endTime}s:</span>
|
||||
<span class="text">${utterance.text}</span>
|
||||
</li>
|
||||
`;
|
||||
});
|
||||
detailsHtml += `
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="file-recorded">
|
||||
<p><strong>录制完成:</strong> 新录音</p>
|
||||
<p><strong>录制时长:</strong> ${recordTime.textContent}</p>
|
||||
<p><strong>文件大小:</strong> ${fileSizeMB} MB</p>
|
||||
<p><strong>文件类型:</strong> ${blob.type}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 启用转换按钮
|
||||
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 = '<circle cx="12" cy="12" r="3"/>';
|
||||
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 = '<rect x="6" y="6" width="12" height="12" rx="2"/>';
|
||||
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();
|
||||
274
src/recorder.ts
Normal file
274
src/recorder.ts
Normal file
@@ -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<string, Function[]> = new Map();
|
||||
|
||||
constructor(private options: RecorderOptions = {}) {
|
||||
// 设置默认选项
|
||||
this.options = {
|
||||
audioBitsPerSecond: 128000,
|
||||
mimeType: 'audio/webm;codecs=opus',
|
||||
timeslice: 100,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
// 添加事件监听器
|
||||
on<K extends keyof RecorderEvents>(event: K, listener: RecorderEvents[K]): void {
|
||||
if (!this.eventListeners.has(event)) {
|
||||
this.eventListeners.set(event, []);
|
||||
}
|
||||
this.eventListeners.get(event)!.push(listener);
|
||||
}
|
||||
|
||||
// 移除事件监听器
|
||||
off<K extends keyof RecorderEvents>(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<K extends keyof RecorderEvents>(event: K, ...args: Parameters<RecorderEvents[K]>): 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<MediaStream> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
525
src/style.css
525
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%;
|
||||
}
|
||||
}
|
||||
|
||||
20
vite.config.ts
Normal file
20
vite.config.ts
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user