|
@ -0,0 +1,55 @@
|
|||
let blackBoard: Graphics | null = null;
|
||||
const createBlackBoard=()=>{
|
||||
const container = new Container();
|
||||
|
||||
const newBlackboard = new Graphics()
|
||||
.roundRect(0, 0, width, height/2,80)
|
||||
.fill({
|
||||
color: 'white',
|
||||
alpha: 1,
|
||||
});
|
||||
container.addChild(newBlackboard);
|
||||
container.position.set(
|
||||
0 ,
|
||||
300
|
||||
);
|
||||
|
||||
app.stage.addChild(container);
|
||||
blackBoard=newBlackboard
|
||||
}
|
||||
|
||||
const createBlackBoardText=()=>{
|
||||
|
||||
|
||||
const html = new HTMLText({
|
||||
text: '<textarea id="output" rows="6" cols="50" readonly>asdasdsadsadsa</textarea>',
|
||||
style: {
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 24,
|
||||
fill: '#ff1010',
|
||||
align: 'center',
|
||||
},
|
||||
});
|
||||
blackBoard.addChild(html)
|
||||
}
|
||||
|
||||
|
||||
const creatBackGround=()=>{
|
||||
const container = new Container();
|
||||
|
||||
const background = new Graphics()
|
||||
.rect(0, 0, width, height)
|
||||
.fill({
|
||||
fill:gradient,
|
||||
color: 'yellow',
|
||||
alpha: 0.5,
|
||||
});
|
||||
container.addChild(background);
|
||||
container.position.set(
|
||||
0 , // 居中(宽度一半)
|
||||
0
|
||||
);
|
||||
|
||||
container.addChild(background);
|
||||
app.stage.addChild(container); // 直接添加到舞台
|
||||
}
|
|
@ -7,7 +7,9 @@
|
|||
"serve": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pixi/devtools": "^2.0.1",
|
||||
"easy-live2d": "^0.4.0-1",
|
||||
"recorder-js": "^1.0.7",
|
||||
"vue": "^3.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
class AudioProcessor extends AudioWorkletProcessor {
|
||||
process(inputs, outputs, parameters) {
|
||||
const input = inputs[0];
|
||||
if (input.length === 0) return true;
|
||||
|
||||
const channelData = input[0]; // 获取第一个声道的数据
|
||||
const int16Data = new Int16Array(channelData.length);
|
||||
|
||||
// 转换为 16-bit PCM
|
||||
for (let i = 0; i < channelData.length; i++) {
|
||||
int16Data[i] = Math.max(-1, Math.min(1, channelData[i])) * 32767;
|
||||
}
|
||||
|
||||
// 发送到主线程
|
||||
this.port.postMessage(int16Data.buffer, [int16Data.buffer]);
|
||||
|
||||
return true; // 保持处理器运行
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("audio-processor", AudioProcessor);
|
After Width: | Height: | Size: 8.8 KiB |
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
Before Width: | Height: | Size: 470 KiB After Width: | Height: | Size: 470 KiB |
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.7 MiB |
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
399
src/App.vue
|
@ -1,115 +1,354 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import { Config, Live2DSprite, LogLevel, Priority } from 'easy-live2d'
|
||||
import { Application, Ticker } from 'pixi.js'
|
||||
import { Application, Ticker,Graphics,Container,FillGradient,HTMLText} from 'pixi.js'
|
||||
import { initDevtools } from '@pixi/devtools'
|
||||
import AudioStreamer from "./components/AudioStreamer.vue";
|
||||
import AppPreloader from "./components/AppPreloader.vue";
|
||||
import { resourceMap } from './utils/resource.js';
|
||||
import TypewriterTextBox from "./components/TypewriterTextBox.vue";
|
||||
|
||||
const log = resourceMap.get("log")
|
||||
const showPreloader = ref(true)
|
||||
let app: Application | null = null;
|
||||
const live2DSprite = new Live2DSprite()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const app = new Application()
|
||||
|
||||
let width=0
|
||||
let height=0
|
||||
// 设置 Config 默认配置
|
||||
Config.MotionGroupIdle = 'Idle' // 设置默认的空闲动作组
|
||||
Config.MouseFollow = true // 禁用鼠标跟随
|
||||
Config.CubismLoggingLevel = LogLevel.LogLevel_Off // 设置日志级别
|
||||
const x=ref(30)
|
||||
const y=ref(0)
|
||||
const wavFile=ref('/wav/introduce_self.wav')
|
||||
///models/hiyori_free_t08/hiyori_free_t08.model3.json
|
||||
const model=ref('models/UG/ugofficial.model3.json')
|
||||
|
||||
|
||||
// // 创建Live2D精灵 并初始化
|
||||
const live2DSprite = new Live2DSprite()
|
||||
live2DSprite.init({
|
||||
modelPath: model.value,
|
||||
ticker: Ticker.shared
|
||||
Config.CubismLoggingLevel = LogLevel.LogLevel_Verbose // 设置日志级别
|
||||
const gradient = new FillGradient({
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'blue' },
|
||||
{ offset: 1, color: 'red' },
|
||||
],
|
||||
});
|
||||
|
||||
// 监听点击事件
|
||||
live2DSprite.onLive2D('hit', ({ hitAreaName, x, y }) => {
|
||||
console.log('hit', hitAreaName, x, y);
|
||||
})
|
||||
const text=ref("")
|
||||
const showQues=ref(false)
|
||||
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
// 你同时又可以直接这样初始化
|
||||
|
||||
await app.init({
|
||||
view: canvasRef.value,
|
||||
backgroundAlpha: 0, // 如果需要透明,可以设置alpha为0
|
||||
})
|
||||
|
||||
if (canvasRef.value) {
|
||||
|
||||
// Live2D精灵大小坐标设置
|
||||
live2DSprite.x = x.value
|
||||
live2DSprite.y = y.value
|
||||
live2DSprite.width = canvasRef.value.clientWidth * window.devicePixelRatio
|
||||
live2DSprite.height = canvasRef.value.clientHeight * window.devicePixelRatio-200
|
||||
app.stage.addChild(live2DSprite);
|
||||
|
||||
// 设置表情
|
||||
live2DSprite.setExpression({
|
||||
expressionId: '4OAO',//normal
|
||||
})
|
||||
|
||||
// 播放声音
|
||||
live2DSprite.playVoice({
|
||||
// 当前音嘴同步 仅支持wav格式
|
||||
voicePath: wavFile.value,
|
||||
})
|
||||
|
||||
// 停止声音
|
||||
// live2DSprite.stopVoice()
|
||||
|
||||
setTimeout(() => {
|
||||
// 播放声音
|
||||
live2DSprite.playVoice({
|
||||
voicePath: wavFile.value,
|
||||
immediate: true // 是否立即播放: 默认为true,会把当前正在播放的声音停止并立即播放新的声音
|
||||
const handleClick = () => {
|
||||
talk(resourceMap.get('start'))
|
||||
.then(() => {
|
||||
startStreaming()
|
||||
text.value="请简单阐述mysql的索引机制,请简单阐述mysql的索引机制请简单阐述mysql的索引机制请简单阐述mysql的索引机制请简单阐述mysql的索引机制"
|
||||
Ques()
|
||||
})
|
||||
}, 10000)
|
||||
};
|
||||
const overClick = () => {
|
||||
app.ticker.stop()
|
||||
//stopStreaming()
|
||||
};
|
||||
|
||||
|
||||
function ansTran(delta){
|
||||
live2DSprite.x += 8;
|
||||
live2DSprite.y += -15;
|
||||
}
|
||||
|
||||
const Ques=()=>{
|
||||
app.ticker.add(delta =>ansTran(delta))
|
||||
setTimeout(() => {
|
||||
app.ticker.stop()
|
||||
showQues.value=true
|
||||
}, 1000);
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const onPreloadComplete = async() => {
|
||||
if (!canvasRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
app = new Application();
|
||||
await app.init({
|
||||
view: canvasRef.value,
|
||||
backgroundAlpha: 0.5, // 如果需要透明,可以设置alpha为0
|
||||
resizeTo: window, // 自动调整画布大小
|
||||
preference: 'webgl',
|
||||
|
||||
// 设置动作
|
||||
live2DSprite.startMotion({
|
||||
group: '4OAO',
|
||||
no: 0,
|
||||
priority: 3,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
width=canvasRef.value.clientWidth * window.devicePixelRatio
|
||||
height=canvasRef.value.clientHeight * window.devicePixelRatio
|
||||
console.log('预加载完成,开始初始化模型');
|
||||
showPreloader.value = false;
|
||||
app.renderer.resize(width, height);
|
||||
try {
|
||||
|
||||
|
||||
await modelInit(resourceMap.get('model'));
|
||||
|
||||
} catch (error) {
|
||||
console.error('初始化失败:', error);
|
||||
showPreloader.value = true; // 失败时重新显示加载界面
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const initInfo={
|
||||
x:-100,
|
||||
y:300,
|
||||
vx:50,
|
||||
vy:0,
|
||||
}
|
||||
|
||||
const modelInit=async (modelPath)=>{
|
||||
|
||||
if (!modelPath) throw new Error('模型路径未找到');
|
||||
// 确保 canvas 存在
|
||||
|
||||
|
||||
live2DSprite.init({
|
||||
modelPath: modelPath,
|
||||
ticker: Ticker.shared
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
if (canvasRef.value) {
|
||||
// Live2D精灵大小坐标设置
|
||||
live2DSprite.x = initInfo.x
|
||||
live2DSprite.y = initInfo.y
|
||||
live2DSprite.width = width+200
|
||||
live2DSprite.height = height-800
|
||||
live2DSprite.alpha = 0
|
||||
app.stage.addChild(live2DSprite);
|
||||
|
||||
|
||||
//设置动作
|
||||
live2DSprite.startMotion({
|
||||
group: 'loop',
|
||||
no: 0,
|
||||
priority: 3,
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
onUnmounted(() => {
|
||||
// 释放实例
|
||||
console.log("释放实例")
|
||||
|
||||
live2DSprite.destroy()
|
||||
})
|
||||
|
||||
const talk = async (voicePath) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const tempAudio = new Audio(voicePath);
|
||||
tempAudio.onloadedmetadata = () => {
|
||||
const duration = tempAudio.duration * 1000; // 转换为毫秒
|
||||
// 2. 播放语音
|
||||
live2DSprite.playVoice({ voicePath, immediate: true });
|
||||
// 3. 使用 setTimeout 模拟播放结束监听
|
||||
setTimeout(() => {
|
||||
console.log("语音播放完成(估算时间)");
|
||||
resolve(); // 通知调用者播放完成
|
||||
}, duration);
|
||||
};
|
||||
// 4. 处理加载错误
|
||||
tempAudio.onerror = () => {
|
||||
reject(new Error("语音加载失败"));
|
||||
};
|
||||
} catch (error) {
|
||||
reject(error); // 捕获其他异常
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
const isStreaming = ref(false);
|
||||
const status = ref("");
|
||||
|
||||
const startStreaming = () => {
|
||||
isStreaming.value = true;
|
||||
status.value = "Initializing audio stream...";
|
||||
};
|
||||
|
||||
const stopStreaming = () => {
|
||||
isStreaming.value = false;
|
||||
};
|
||||
|
||||
const handleStatusUpdate = (message) => {
|
||||
status.value = message;
|
||||
};
|
||||
|
||||
const handleError = (error) => {
|
||||
status.value = `Error: ${error}`;
|
||||
isStreaming.value = false;
|
||||
};
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="test">
|
||||
</div>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
id="live2d"
|
||||
/>
|
||||
|
||||
<view class="logo-container">
|
||||
<image
|
||||
class="logo"
|
||||
:src="log"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<AppPreloader v-if="showPreloader" @loaded="onPreloadComplete" />
|
||||
<view class="main-content">
|
||||
<canvas ref="canvasRef" id="live2d"/>
|
||||
<button class="cute-button start" @click="handleClick">
|
||||
<span>开始 🎉</span>
|
||||
</button>
|
||||
<button class="cute-button over" @click="overClick">
|
||||
<span>结束 🎉</span>
|
||||
</button>
|
||||
</view>
|
||||
<view class="textbox-layer" v-show="showQues">
|
||||
<TypewriterTextBox
|
||||
|
||||
:text="text"
|
||||
:speed="50"
|
||||
@typingComplete=""
|
||||
/>
|
||||
</view>
|
||||
<AudioStreamer
|
||||
|
||||
:is-streaming="isStreaming"
|
||||
@update-status="handleStatusUpdate"
|
||||
@streaming-error="handleError"
|
||||
/>
|
||||
<p v-if="status">{{ status }}</p>
|
||||
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
#live2d {
|
||||
position: absolute;
|
||||
top: 0%;
|
||||
right: 0%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(45deg, #ff9a9e, #fad0c4, #fbc2eb);
|
||||
}
|
||||
|
||||
.test {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 70%;
|
||||
background-color: pink;
|
||||
|
||||
|
||||
.start {
|
||||
bottom: 9rem;
|
||||
}
|
||||
|
||||
.over {
|
||||
bottom: 5rem;
|
||||
}
|
||||
|
||||
.cute-button {
|
||||
position: fixed;
|
||||
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 14px 28px;
|
||||
background: linear-gradient(45deg, #ff9a9e, #fad0c4, #fbc2eb); /* 多色渐变 */
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
box-shadow: 0 4px 15px rgba(255, 154, 158, 0.4);
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.27, 1.55); /* 弹性动画 */
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cute-button:hover {
|
||||
transform: translateX(-50%) scale(1.1);
|
||||
box-shadow: 0 8px 25px rgba(255, 154, 158, 0.6);
|
||||
}
|
||||
|
||||
.cute-button:active {
|
||||
transform: translateX(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
/* 点击时加一个波纹效果 */
|
||||
.cute-button::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
transition: transform 0.6s;
|
||||
}
|
||||
|
||||
.cute-button:active::after {
|
||||
transform: translate(-50%, -50%) scale(20);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 按钮文字图标 */
|
||||
.cute-button span::after {
|
||||
content: "🐾";
|
||||
margin-left: 8px;
|
||||
animation: paw 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes paw {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
50% { transform: rotate(15deg); }
|
||||
}
|
||||
|
||||
.main-content {
|
||||
|
||||
|
||||
opacity: 1;
|
||||
animation: fadeIn 0.5s ease 0.3s forwards;
|
||||
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.textbox-layer {
|
||||
position: relative; /* 或 absolute/fixed,取决于需求 */
|
||||
z-index:1000; /* 文本框在上层 */
|
||||
|
||||
}
|
||||
|
||||
/* 添加样式确保图片居中 */
|
||||
.logo-container {
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
padding: 20px 0; /* 根据需要调整上下间距 */
|
||||
width: 100%;
|
||||
z-index:1000; /* 文本框在上层 */
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block; /* 确保图片是块级元素 */
|
||||
margin: 0 auto; /* 水平居中 */
|
||||
border: 2px solid red; /* 检查图片容器是否存在 */
|
||||
}
|
||||
|
||||
</style>
|
Before Width: | Height: | Size: 6.7 KiB |
|
@ -0,0 +1,92 @@
|
|||
<!-- src/components/AppPreloader.vue -->
|
||||
<template>
|
||||
<div v-if="isLoading" class="preloader-overlay">
|
||||
<div class="preloader-content">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress"
|
||||
:style="{ width: progress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="loading-text">
|
||||
正在加载资源... {{ progress.toFixed(0) }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { preloadResources } from '../utils/preload'
|
||||
import { resourceMap } from '../utils/resource'
|
||||
|
||||
const emit = defineEmits(['loaded'])
|
||||
const isLoading = ref(true)
|
||||
const progress = ref(0)
|
||||
|
||||
onMounted(async () => {
|
||||
await startPreloading()
|
||||
})
|
||||
|
||||
const startPreloading = async () => {
|
||||
return new Promise((resolve) => {
|
||||
// 模拟进度更新
|
||||
const interval = setInterval(() => {
|
||||
if (progress.value < 95) {
|
||||
progress.value += Math.random() * 5
|
||||
}
|
||||
}, 300)
|
||||
|
||||
// 实际预加载资源
|
||||
preloadResources(resourceMap).finally(() => {
|
||||
clearInterval(interval)
|
||||
progress.value = 100
|
||||
setTimeout(() => {
|
||||
isLoading.value = false
|
||||
emit('loaded')
|
||||
resolve()
|
||||
}, 500)
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preloader-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f5f7fa;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.preloader-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 300px;
|
||||
height: 6px;
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
background: #409eff;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,127 @@
|
|||
<template>
|
||||
<div>
|
||||
<p v-if="status">{{ status }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, watch, onUnmounted } from "vue";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
isStreaming: Boolean,
|
||||
},
|
||||
emits: ["update-status", "streaming-error"],
|
||||
setup(props, { emit }) {
|
||||
const status = ref("");
|
||||
let audioContext = null;
|
||||
let mediaStream = null;
|
||||
let socket = null;
|
||||
let audioWorkletNode = null;
|
||||
|
||||
const SAMPLE_RATE = 16000;
|
||||
const CHANNELS = 1;
|
||||
|
||||
// 统一资源清理方法
|
||||
const cleanupResources = () => {
|
||||
if (audioWorkletNode) {
|
||||
audioWorkletNode.disconnect();
|
||||
audioWorkletNode.port.onmessage = null;
|
||||
audioWorkletNode = null;
|
||||
}
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach(track => track.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
if (socket) {
|
||||
socket.onopen = null;
|
||||
socket.onclose = null;
|
||||
socket.onmessage = null;
|
||||
socket.onerror = null;
|
||||
if (socket.readyState === WebSocket.OPEN) socket.close();
|
||||
socket = null;
|
||||
}
|
||||
if (audioContext) {
|
||||
audioContext.close().catch(() => {});
|
||||
audioContext = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startStreaming = async () => {
|
||||
try {
|
||||
// 先清理旧资源
|
||||
cleanupResources();
|
||||
|
||||
// 1. 初始化 WebSocket
|
||||
socket = new WebSocket("ws://localhost:8000/ws");
|
||||
socket.binaryType = "arraybuffer";
|
||||
|
||||
socket.onopen = () => {
|
||||
emit("update-status", "WebSocket connected");
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
emit("update-status", "WebSocket disconnected");
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
emit("streaming-error", `WebSocket error: ${error.message}`);
|
||||
cleanupResources();
|
||||
};
|
||||
|
||||
// 2. 初始化 AudioContext(必须全新创建)
|
||||
audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||
|
||||
// 3. 获取麦克风输入
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||
|
||||
// 4. 加载 AudioWorklet 处理器
|
||||
await audioContext.audioWorklet.addModule("/audio-processor.js");
|
||||
|
||||
// 5. 创建新的 AudioWorkletNode
|
||||
audioWorkletNode = new AudioWorkletNode(audioContext, "audio-processor");
|
||||
|
||||
// 6. 监听音频数据并发送到 WebSocket
|
||||
audioWorkletNode.port.onmessage = (event) => {
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
// 7. 连接音频流
|
||||
source.connect(audioWorkletNode);
|
||||
audioWorkletNode.connect(audioContext.destination);
|
||||
|
||||
emit("update-status", "Streaming started");
|
||||
} catch (error) {
|
||||
cleanupResources();
|
||||
emit("streaming-error", error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const stopStreaming = () => {
|
||||
cleanupResources();
|
||||
emit("update-status", "Streaming stopped");
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.isStreaming,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
startStreaming();
|
||||
} else {
|
||||
stopStreaming();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopStreaming();
|
||||
});
|
||||
|
||||
return { status };
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -1,38 +0,0 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
msg: String
|
||||
})
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<p>
|
||||
Welcome:
|
||||
<a href="https://hx.dcloud.net.cn/" target="_blank">HBuilderX</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://vitejs.dev/guide/features.html" target="_blank">
|
||||
Vite Documentation
|
||||
</a>
|
||||
|
|
||||
<a href="https://v3.vuejs.org/" target="_blank">Vue 3 Documentation</a>
|
||||
</p>
|
||||
|
||||
<button type="button" @click="count++">count is: {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test hot module replacement.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
a {
|
||||
color: #42b983;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,169 @@
|
|||
<template>
|
||||
<div class="typewriter-container">
|
||||
<!-- 响应式文本框 -->
|
||||
<div
|
||||
class="typewriter-box"
|
||||
:style="{
|
||||
padding: padding,
|
||||
borderRadius: borderRadius,
|
||||
border: border,
|
||||
backgroundColor: backgroundColor,
|
||||
color: textColor,
|
||||
fontSize: fontSize,
|
||||
textAlign: textAlign,
|
||||
boxShadow: boxShadow,
|
||||
minHeight: minHeight,
|
||||
}"
|
||||
>
|
||||
<!-- 逐字显示的文本 -->
|
||||
<span v-for="(char, index) in displayedText" :key="index">
|
||||
{{ char }}
|
||||
</span>
|
||||
<!-- 光标效果(可选) -->
|
||||
<span v-if="isTyping" class="cursor">|</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "TypewriterTextBox",
|
||||
props: {
|
||||
// 输入的完整文本(支持动态变量)
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
// 打字速度(ms)
|
||||
speed: {
|
||||
type: Number,
|
||||
default: 100,
|
||||
},
|
||||
// 样式配置
|
||||
padding: {
|
||||
type: String,
|
||||
default: "12px 16px",
|
||||
},
|
||||
borderRadius: {
|
||||
type: String,
|
||||
default: "8px",
|
||||
},
|
||||
border: {
|
||||
type: String,
|
||||
default: "1px solid #ddd",
|
||||
},
|
||||
backgroundColor: {
|
||||
type: String,
|
||||
default: "white", // 白色背景
|
||||
},
|
||||
textColor: {
|
||||
type: String,
|
||||
default: "#333",
|
||||
},
|
||||
fontSize: {
|
||||
type: String,
|
||||
default: "16px",
|
||||
},
|
||||
textAlign: {
|
||||
type: String,
|
||||
default: "left",
|
||||
},
|
||||
boxShadow: {
|
||||
type: String,
|
||||
default: "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||
},
|
||||
minHeight: {
|
||||
type: String,
|
||||
default: "auto",
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
displayedText: "", // 当前显示的文本
|
||||
isTyping: false, // 是否正在打字
|
||||
typingInterval: null, // 定时器
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
// 监听 text 变化,自动重新打字
|
||||
text(newText) {
|
||||
this.resetTyping();
|
||||
this.startTyping();
|
||||
},
|
||||
// 监听 speed 变化
|
||||
speed() {
|
||||
this.resetTyping();
|
||||
this.startTyping();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.startTyping();
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.clearTypingInterval();
|
||||
},
|
||||
methods: {
|
||||
// 开始逐字显示
|
||||
startTyping() {
|
||||
this.clearTypingInterval();
|
||||
this.isTyping = true;
|
||||
this.displayedText = ""; // 清空旧内容
|
||||
|
||||
let currentIndex = 0;
|
||||
const text = this.text || "";
|
||||
|
||||
this.typingInterval = setInterval(() => {
|
||||
if (currentIndex < text.length) {
|
||||
this.displayedText += text[currentIndex];
|
||||
currentIndex++;
|
||||
} else {
|
||||
this.isTyping = false;
|
||||
this.clearTypingInterval();
|
||||
this.$emit("typingComplete"); // 打字完成事件
|
||||
}
|
||||
}, this.speed);
|
||||
},
|
||||
// 清除定时器
|
||||
clearTypingInterval() {
|
||||
if (this.typingInterval) {
|
||||
clearInterval(this.typingInterval);
|
||||
this.typingInterval = null;
|
||||
}
|
||||
},
|
||||
// 重置打字效果
|
||||
resetTyping() {
|
||||
this.clearTypingInterval();
|
||||
this.displayedText = "";
|
||||
this.isTyping = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.typewriter-container {
|
||||
width: 90%;
|
||||
max-width: 90%;
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
.typewriter-box {
|
||||
width: 90%;
|
||||
min-height: 1em;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
background-color: #333;
|
||||
animation: blink 0.8s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,47 @@
|
|||
// src/utils/preload.js
|
||||
|
||||
|
||||
export const preloadResources = (resourceMap) => {
|
||||
return new Promise((resolve) => {
|
||||
let loadedCount = 0;
|
||||
const total = resourceMap.size;
|
||||
|
||||
if (total === 0) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
const checkComplete = () => {
|
||||
loadedCount++;
|
||||
if (window.updatePreloadProgress) {
|
||||
window.updatePreloadProgress((loadedCount / total) * 100);
|
||||
}
|
||||
if (loadedCount === total) {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
// 遍历 Map 并预加载
|
||||
resourceMap.forEach((url) => {
|
||||
if (/\.(jpg|jpeg|png|gif|svg|webp)$/i.test(url)) {
|
||||
const img = new Image();
|
||||
img.onload = checkComplete;
|
||||
img.onerror = checkComplete;
|
||||
img.src = url;
|
||||
} else if (/\.(json)$/i.test(url)) {
|
||||
fetch(url)
|
||||
.then(() => checkComplete())
|
||||
.catch(() => checkComplete());
|
||||
} else if (/\.(wav|mp3|ogg|aac)$/i.test(url)) {
|
||||
const audio = new Audio();
|
||||
audio.oncanplaythrough = checkComplete;
|
||||
audio.onerror = checkComplete;
|
||||
audio.src = url;
|
||||
audio.load();
|
||||
} else {
|
||||
fetch(url, { method: 'HEAD' })
|
||||
.then(() => checkComplete())
|
||||
.catch(() => checkComplete());
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
// src/utils/resource.js
|
||||
|
||||
export const resourceMap = new Map([
|
||||
['start', '/resource/sounds/start.wav'],
|
||||
['model', '/resource/models/UG/ugofficial.model3.json'],
|
||||
['log', '/resource/img/log.png'],
|
||||
// 其他资源...
|
||||
]);
|
|
@ -1,7 +1,9 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()]
|
||||
})
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 3000, // 可选:指定端口
|
||||
},
|
||||
})
|