This commit is contained in:
2025-10-14 23:50:09 +08:00
parent 647d008dbd
commit 5dd4a6767a
9 changed files with 1575 additions and 5 deletions

View File

@@ -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
View 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
View File

@@ -0,0 +1,4 @@
# 上传音频转文字
火山语音转文字,一句话识别技术
https://www.volcengine.com/docs/6561/192519

136
src/auc.ts Normal file
View 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
View 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);
}
};

View File

@@ -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">&times;</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
View 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);
}

View File

@@ -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
View 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")
}
}
}
})