diff --git a/astro.config.mjs b/astro.config.mjs index d55582c..f1723d3 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -7,7 +7,7 @@ import tailwindcss from '@tailwindcss/vite'; const isDev = process.env.NODE_ENV === 'development'; -let target = process.env.VITE_API_URL || 'https://localhost:51015'; +let target = process.env.VITE_API_URL || 'http://localhost:4005'; const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' }; let proxy = { '/root/': { diff --git a/package.json b/package.json index 55c6e19..5011d34 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { - "name": "@kevisual/astro-simplate-template", + "name": "@kevisual/aura-center", "version": "0.0.1", "description": "", "main": "index.js", - "basename": "/root/astro-simplate-template", + "basename": "/root/aura-center", "scripts": { "dev": "astro dev", "build": "astro build", "preview": "astro preview", - "pub": "envision deploy ./dist -k astro-simplate-template -v 0.0.1 -u", + "pub": "envision deploy ./dist -k aura-center -v 0.0.1 -u", "sn": "pnpm dlx shadcn@latest add " }, "keywords": [], @@ -34,6 +34,7 @@ "react-dom": "^19.2.0", "react-toastify": "^11.0.5", "tailwind-merge": "^3.3.1", + "wavesurfer.js": "^7.11.0", "zustand": "^5.0.8" }, "publishConfig": { @@ -48,5 +49,10 @@ "tailwindcss": "^4.1.14", "tw-animate-css": "^1.4.0" }, - "packageManager": "pnpm@10.18.3" + "packageManager": "pnpm@10.18.3", + "onlyBuiltDependencies": [ + "@tailwindcss/oxide", + "esbuild", + "sharp" + ] } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee097bd..acc97a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: tailwind-merge: specifier: ^3.3.1 version: 3.3.1 + wavesurfer.js: + specifier: ^7.11.0 + version: 7.11.0 zustand: specifier: ^5.0.8 version: 5.0.8(@types/react@19.2.2)(react@19.2.0) @@ -2273,6 +2276,9 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + wavesurfer.js@7.11.0: + resolution: {integrity: sha512-LOGdIBIKv/roYuQYClhoqhwbIdQL1GfobLnS2vx0heoLD9lu57OUHWE2DIsCNXBvCsmmbkUvJq9W8bPLPbikGw==} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -4918,6 +4924,8 @@ snapshots: void-elements@3.1.0: {} + wavesurfer.js@7.11.0: {} + web-namespaces@2.0.1: {} webidl-conversions@3.0.1: {} diff --git a/src/apps/record/Wavesurfer.tsx b/src/apps/record/Wavesurfer.tsx new file mode 100644 index 0000000..54c047d --- /dev/null +++ b/src/apps/record/Wavesurfer.tsx @@ -0,0 +1,3 @@ +export const Wavesurfer = () => { + return
Wavesurfer
; +} \ No newline at end of file diff --git a/src/apps/record/components/Wavesurfer.tsx b/src/apps/record/components/Wavesurfer.tsx new file mode 100644 index 0000000..5506766 --- /dev/null +++ b/src/apps/record/components/Wavesurfer.tsx @@ -0,0 +1,99 @@ +import { useEffect, useRef } from "react"; +import WaveSurfer from "wavesurfer.js"; + +interface WavesurferProps { + audioUrl?: string; + isPlaying?: boolean; + onPlayStateChange?: (isPlaying: boolean) => void; +} + +export const Wavesurfer = ({ + audioUrl, + isPlaying = false, + onPlayStateChange +}: WavesurferProps) => { + const waveformRef = useRef(null); + const wavesurferRef = useRef(null); + + // 播放模式的波形 + useEffect(() => { + if (audioUrl && waveformRef.current && !wavesurferRef.current) { + wavesurferRef.current = WaveSurfer.create({ + container: waveformRef.current, + waveColor: '#10b981', + progressColor: '#059669', + cursorColor: '#047857', + barWidth: 2, + barRadius: 3, + height: 60, + normalize: true, + }); + + wavesurferRef.current.load(audioUrl); + + // 监听播放状态 + wavesurferRef.current.on('play', () => onPlayStateChange?.(true)); + wavesurferRef.current.on('pause', () => onPlayStateChange?.(false)); + wavesurferRef.current.on('finish', () => onPlayStateChange?.(false)); + } + + return () => { + if (wavesurferRef.current) { + wavesurferRef.current.destroy(); + wavesurferRef.current = null; + } + }; + }, [audioUrl, onPlayStateChange]); + + // 外部播放控制 + useEffect(() => { + if (wavesurferRef.current) { + if (isPlaying) { + wavesurferRef.current.play(); + } else { + wavesurferRef.current.pause(); + } + } + }, [isPlaying]); + + // 清理函数 + useEffect(() => { + return () => { + if (wavesurferRef.current) { + wavesurferRef.current.destroy(); + } + }; + }, []); + + // 暴露播放控制方法 + const togglePlayback = () => { + if (wavesurferRef.current) { + if (wavesurferRef.current.isPlaying()) { + wavesurferRef.current.pause(); + } else { + wavesurferRef.current.play(); + } + } + }; + + // 只在有音频URL时显示 + if (!audioUrl) { + return null; + } + + return ( +
+
+
+
+ 点击波形或使用播放按钮控制播放 +
+
+
+ ); +}; + +// 暴露控制方法的接口 +export interface WavesurferRef { + togglePlayback: () => void; +} \ No newline at end of file diff --git a/src/apps/record/index.tsx b/src/apps/record/index.tsx new file mode 100644 index 0000000..cd1b03c --- /dev/null +++ b/src/apps/record/index.tsx @@ -0,0 +1,295 @@ +import { useState, useRef } from "react"; +import { getText } from "./modules/get-text"; +import { Wavesurfer } from "./components/Wavesurfer"; + +interface TranscriptionResult { + id: string; + text: string; + timestamp: Date; +} + +export const RecordApp = () => { + const [isRecording, setIsRecording] = useState(false); + const [recordingTime, setRecordingTime] = useState(0); + const [audioBlob, setAudioBlob] = useState(null); + const [audioUrl, setAudioUrl] = useState(""); + const [isPlaying, setIsPlaying] = useState(false); + const [showWaveform, setShowWaveform] = useState(false); + const [isTranscribing, setIsTranscribing] = useState(false); + const [transcriptions, setTranscriptions] = useState([]); + const [currentText, setCurrentText] = useState(""); + + const mediaRecorderRef = useRef(null); + const audioRef = useRef(null); + const intervalRef = useRef(null); + + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mediaRecorder = new MediaRecorder(stream); + mediaRecorderRef.current = mediaRecorder; + + const chunks: BlobPart[] = []; + + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + chunks.push(event.data); + } + }; + + mediaRecorder.onstop = () => { + const blob = new Blob(chunks, { type: "audio/webm" }); + setAudioBlob(blob); + setAudioUrl(URL.createObjectURL(blob)); + stream.getTracks().forEach(track => track.stop()); + }; + + mediaRecorder.start(); + setIsRecording(true); + setRecordingTime(0); + + // 开始计时 + intervalRef.current = setInterval(() => { + setRecordingTime(prev => prev + 1); + }, 1000); + } catch (error) { + console.error("录音失败:", error); + } + }; + + const stopRecording = () => { + if (mediaRecorderRef.current && isRecording) { + mediaRecorderRef.current.stop(); + setIsRecording(false); + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + } + }; + + const playAudio = () => { + if (audioRef.current && audioUrl) { + if (isPlaying) { + audioRef.current.pause(); + setIsPlaying(false); + } else { + audioRef.current.play(); + setIsPlaying(true); + setShowWaveform(true); // 开始播放时显示波形 + } + } + }; + + const handlePlayStateChange = (playing: boolean) => { + setIsPlaying(playing); + // 如果播放结束,隐藏波形 + if (!playing) { + setShowWaveform(false); + } + }; + + const transcribeAudio = async () => { + if (!audioBlob) return; + + setIsTranscribing(true); + try { + // 将音频转换为base64 + const reader = new FileReader(); + reader.onloadend = async () => { + const base64Audio = (reader.result as string).split(',')[1]; + + try { + const result = await getText(base64Audio); + const text = typeof result === 'string' ? result : (result as any)?.data || '转录失败'; + setCurrentText(text); + + // 添加到转录历史 + const newTranscription: TranscriptionResult = { + id: Date.now().toString(), + text, + timestamp: new Date() + }; + setTranscriptions(prev => [newTranscription, ...prev]); + } catch (error) { + console.error("转录失败:", error); + setCurrentText("转录失败,请重试"); + } finally { + setIsTranscribing(false); + } + }; + reader.readAsDataURL(audioBlob); + } catch (error) { + console.error("处理音频失败:", error); + setIsTranscribing(false); + } + }; + + const copyText = (text: string) => { + navigator.clipboard.writeText(text); + }; + + const refreshTranscriptions = () => { + setTranscriptions([]); + setCurrentText(""); + }; + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }; + + return ( +
+ {/* 标题 */} +
+

语音录制与转文字

+
+ + {/* 录音控制区域 */} +
+
+ {/* 录音/停止按钮 */} + + + {/* 播放按钮 - 录制时隐藏 */} + {!isRecording && ( + + )} + + {/* 转文字按钮 - 录制时隐藏 */} + {!isRecording && audioUrl && ( + + )} +
+ + {/* 录音状态显示 */} + {isRecording && ( +
+
+
+ 录音中: {formatTime(recordingTime)} +
+
+ )} + + {/* 播放波形显示 - 只在需要显示且有音频时显示 */} + {showWaveform && audioUrl && ( + + )} + + {/* 隐藏的音频元素 */} + {audioUrl && ( +
+ + {/* 转录结果区域 */} +
+
+

最近转录

+
+ + +
+
+ + {/* 当前转录文本 */} +
+ {isTranscribing ? ( +
+
+ 正在转录音频... +
+ ) : currentText ? ( +

{currentText}

+ ) : ( +

转录结果将显示在这里

+ )} +
+
+ + {/* 历史转录记录 */} + {transcriptions.length > 0 && ( +
+

历史记录

+
+ {transcriptions.map((item) => ( +
+
+

{item.text}

+

+ {item.timestamp.toLocaleString()} +

+
+ +
+ ))} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/apps/record/modules/get-text.ts b/src/apps/record/modules/get-text.ts new file mode 100644 index 0000000..8cf41b1 --- /dev/null +++ b/src/apps/record/modules/get-text.ts @@ -0,0 +1,38 @@ +import { query } from '../../../modules/query' + +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 const getText = async (base64Audio: string) => { + const res = await query.post({ + path: 'asr', + key: 'text', + base64Audio + }) + if (res.code !== 200) { + return '转录失败' + } + return res.data?.result?.text || '转录失败' +} \ No newline at end of file diff --git a/src/modules/query.ts b/src/modules/query.ts new file mode 100644 index 0000000..f0e3560 --- /dev/null +++ b/src/modules/query.ts @@ -0,0 +1,3 @@ +import { Query } from '@kevisual/query'; + +export const query = new Query(); \ No newline at end of file diff --git a/src/pages/record.astro b/src/pages/record.astro new file mode 100644 index 0000000..f9e9558 --- /dev/null +++ b/src/pages/record.astro @@ -0,0 +1,17 @@ +--- +import { RecordApp } from '../apps/record/index'; +import '../styles/global.css'; +--- + + + + + + 语音录制与转文字 + + +
+ +
+ + \ No newline at end of file