|
@ -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"
|
"serve": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@pixi/devtools": "^2.0.1",
|
||||||
"easy-live2d": "^0.4.0-1",
|
"easy-live2d": "^0.4.0-1",
|
||||||
|
"recorder-js": "^1.0.7",
|
||||||
"vue": "^3.2.8"
|
"vue": "^3.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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 |
369
src/App.vue
|
@ -1,115 +1,354 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onUnmounted, ref } from 'vue'
|
||||||
import { Config, Live2DSprite, LogLevel, Priority } from 'easy-live2d'
|
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 { 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 canvasRef = ref<HTMLCanvasElement>()
|
||||||
const app = new Application()
|
let width=0
|
||||||
|
let height=0
|
||||||
// 设置 Config 默认配置
|
// 设置 Config 默认配置
|
||||||
Config.MotionGroupIdle = 'Idle' // 设置默认的空闲动作组
|
Config.MotionGroupIdle = 'Idle' // 设置默认的空闲动作组
|
||||||
Config.MouseFollow = true // 禁用鼠标跟随
|
Config.MouseFollow = true // 禁用鼠标跟随
|
||||||
Config.CubismLoggingLevel = LogLevel.LogLevel_Off // 设置日志级别
|
Config.CubismLoggingLevel = LogLevel.LogLevel_Verbose // 设置日志级别
|
||||||
const x=ref(30)
|
const gradient = new FillGradient({
|
||||||
const y=ref(0)
|
colorStops: [
|
||||||
const wavFile=ref('/wav/introduce_self.wav')
|
{ offset: 0, color: 'blue' },
|
||||||
///models/hiyori_free_t08/hiyori_free_t08.model3.json
|
{ offset: 1, color: 'red' },
|
||||||
const model=ref('models/UG/ugofficial.model3.json')
|
],
|
||||||
|
|
||||||
|
|
||||||
// // 创建Live2D精灵 并初始化
|
|
||||||
const live2DSprite = new Live2DSprite()
|
|
||||||
live2DSprite.init({
|
|
||||||
modelPath: model.value,
|
|
||||||
ticker: Ticker.shared
|
|
||||||
});
|
});
|
||||||
|
const text=ref("")
|
||||||
|
const showQues=ref(false)
|
||||||
|
|
||||||
// 监听点击事件
|
|
||||||
live2DSprite.onLive2D('hit', ({ hitAreaName, x, y }) => {
|
const handleClick = () => {
|
||||||
console.log('hit', hitAreaName, x, y);
|
talk(resourceMap.get('start'))
|
||||||
})
|
.then(() => {
|
||||||
|
startStreaming()
|
||||||
|
text.value="请简单阐述mysql的索引机制,请简单阐述mysql的索引机制请简单阐述mysql的索引机制请简单阐述mysql的索引机制请简单阐述mysql的索引机制"
|
||||||
|
Ques()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
onMounted(async () => {
|
const onPreloadComplete = async() => {
|
||||||
// 你同时又可以直接这样初始化
|
if (!canvasRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app = new Application();
|
||||||
await app.init({
|
await app.init({
|
||||||
view: canvasRef.value,
|
view: canvasRef.value,
|
||||||
backgroundAlpha: 0, // 如果需要透明,可以设置alpha为0
|
backgroundAlpha: 0.5, // 如果需要透明,可以设置alpha为0
|
||||||
|
resizeTo: window, // 自动调整画布大小
|
||||||
|
preference: 'webgl',
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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) {
|
if (canvasRef.value) {
|
||||||
|
|
||||||
// Live2D精灵大小坐标设置
|
// Live2D精灵大小坐标设置
|
||||||
live2DSprite.x = x.value
|
live2DSprite.x = initInfo.x
|
||||||
live2DSprite.y = y.value
|
live2DSprite.y = initInfo.y
|
||||||
live2DSprite.width = canvasRef.value.clientWidth * window.devicePixelRatio
|
live2DSprite.width = width+200
|
||||||
live2DSprite.height = canvasRef.value.clientHeight * window.devicePixelRatio-200
|
live2DSprite.height = height-800
|
||||||
|
live2DSprite.alpha = 0
|
||||||
app.stage.addChild(live2DSprite);
|
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,会把当前正在播放的声音停止并立即播放新的声音
|
|
||||||
})
|
|
||||||
}, 10000)
|
|
||||||
|
|
||||||
// 设置动作
|
|
||||||
live2DSprite.startMotion({
|
live2DSprite.startMotion({
|
||||||
group: '4OAO',
|
group: 'loop',
|
||||||
no: 0,
|
no: 0,
|
||||||
priority: 3,
|
priority: 3,
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
// 释放实例
|
console.log("释放实例")
|
||||||
|
|
||||||
live2DSprite.destroy()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="test">
|
|
||||||
</div>
|
<view class="logo-container">
|
||||||
<canvas
|
<image
|
||||||
ref="canvasRef"
|
class="logo"
|
||||||
id="live2d"
|
: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>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
#live2d {
|
#live2d {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0%;
|
top: 0%;
|
||||||
right: 0%;
|
right: 0%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
background: linear-gradient(45deg, #ff9a9e, #fad0c4, #fbc2eb);
|
||||||
}
|
}
|
||||||
|
|
||||||
.test {
|
|
||||||
display: inline-block;
|
|
||||||
position: absolute;
|
.start {
|
||||||
width: 100%;
|
bottom: 9rem;
|
||||||
height: 70%;
|
|
||||||
background-color: pink;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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>
|
</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 { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()]
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 3000, // 可选:指定端口
|
||||||
|
},
|
||||||
})
|
})
|