Compare commits

..

3 Commits

Author SHA1 Message Date
8a0c1d6876 status 2025-07-29 09:53:02 +08:00
f0bf3b6184 优化衔接处bug 2025-07-29 02:50:08 +08:00
a96fc86d42 处理结尾判断 2025-07-29 01:36:40 +08:00
51 changed files with 1069 additions and 2772 deletions

View File

@ -1,24 +0,0 @@
apiVersion: v2
name: homeland
description: A Helm chart for Kubernetes
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.16.0"

View File

@ -1,22 +0,0 @@
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "homeland.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "homeland.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "homeland.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "homeland.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}

View File

@ -1,62 +0,0 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "homeland.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "homeland.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "homeland.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "homeland.labels" -}}
helm.sh/chart: {{ include "homeland.chart" . }}
{{ include "homeland.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "homeland.selectorLabels" -}}
app.kubernetes.io/name: {{ include "homeland.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "homeland.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "homeland.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@ -1,61 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "homeland.fullname" . }}
labels:
{{- include "homeland.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "homeland.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "homeland.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "homeland.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /
port: http
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@ -1,32 +0,0 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "homeland.fullname" . }}
labels:
{{- include "homeland.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "homeland.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}

View File

@ -1,61 +0,0 @@
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "homeland.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
{{- include "homeland.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
pathType: {{ .pathType }}
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}
port:
number: {{ $svcPort }}
{{- else }}
serviceName: {{ $fullName }}
servicePort: {{ $svcPort }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "homeland.fullname" . }}
labels:
{{- include "homeland.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "homeland.selectorLabels" . | nindent 4 }}

View File

@ -1,12 +0,0 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "homeland.serviceAccountName" . }}
labels:
{{- include "homeland.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@ -1,15 +0,0 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "homeland.fullname" . }}-test-connection"
labels:
{{- include "homeland.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "homeland.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never

View File

@ -1,84 +0,0 @@
# Default values for homeland.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1
image:
repository: 172.16.54.94:5000/homeland
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: "29"
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
podAnnotations: {}
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
hosts:
- host: homeland.local
paths:
- path: /
pathType: ImplementationSpecific
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 100
targetCPUUtilizationPercentage: 80
# targetMemoryUtilizationPercentage: 80
nodeSelector: {
kubernetes.io/hostname: vm10-1-0-12
}
tolerations: []
affinity: {}

View File

@ -1,41 +0,0 @@
name: Gitea Actions Demo
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
on:
push:
branches:
- 'kehu237'
env:
BUILD: staging
jobs:
Explore-Gitea-Actions:
runs-on: yantoo-ci
steps:
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
- run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
- name: Check out repository code
uses: https://gitea.yantootech.com/neil/checkout@v4
- run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
- name: List files in the repository
run: |
whoami
uname -a
pwd
ls ${{ gitea.workspace }}
- name: Build and push
uses: https://gitea.yantootech.com/neil/build-push-action@v6
with:
push: true
tags: 14.103.114.237:30005/emotion-male-app:${{ gitea.run_id }}
- name: Install
run: |
helm upgrade --install emotion-male-app ./.gitea/charts \
--namespace emotion-human \
--create-namespace \
--set image.repository=14.103.114.237:30005/emotion-male-app \
--set image.tag=${{ gitea.run_id }}
- run: echo "🍏 This job's status is ${{ job.status }}."

View File

@ -1,24 +0,0 @@
# 使用官方Node.js运行时作为基础镜像
FROM node:18-alpine
# 设置工作目录
WORKDIR /app
# 复制package.json和yarn.lock
COPY package.json yarn.lock* ./
# 安装项目依赖
RUN yarn install
# 复制项目文件
COPY . .
# 设置环境变量
ENV HOST=0.0.0.0
ENV PORT=80
# 暴露端口
EXPOSE 80
# 启动项目
CMD ["yarn", "dev"]

View File

@ -1,129 +0,0 @@
# 默认视频播放问题修复
## 问题描述
在性能优化过程中,发现默认视频 `d-3s.mp4``s-1.mp4` 没有正常播放的问题。
## 问题原因
1. **缓存策略过于激进**: 将缓存数量从3个减少到2个导致重要视频被过早清理
2. **缺少默认视频启动调用**: 在 `startCall()` 方法中没有调用 `startDefaultVideoStream()`
3. **重要视频保护不足**: 没有区分重要视频和普通视频的清理策略
## 修复措施
### 1. 优化缓存策略
```javascript
// 修复前
if (this.videoStreams.size >= 2) { // 缓存数量过少
const firstKey = this.videoStreams.keys().next().value;
// 直接清理第一个视频,可能包括重要视频
}
// 修复后
if (this.videoStreams.size >= 4) { // 增加缓存数量
const importantVideos = [this.defaultVideo, 's-1.mp4', 'd-3s.mp4'];
const videoToRemove = cachedVideos.find(video => !importantVideos.includes(video));
// 只清理非重要视频
}
```
### 2. 添加默认视频启动调用
```javascript
async startCall() {
// ... 其他代码 ...
// 启动默认视频流
await this.startDefaultVideoStream();
// 通知服务器通话开始
this.socket.emit('call-started');
}
```
### 3. 改进预加载策略
```javascript
async preloadCommonVideos() {
const videosToPreload = new Set([]);
// 添加重要视频(默认视频和常用视频)
videosToPreload.add(this.defaultVideo); // 默认视频
videosToPreload.add('s-1.mp4'); // 常用视频
videosToPreload.add('d-3s.mp4'); // 默认视频的另一个版本
// 添加视频映射中的所有视频
Object.values(this.videoMapping).forEach(video => {
videosToPreload.add(video);
});
}
```
### 4. 优化性能监控清理
```javascript
// 如果缓存过多,清理一些(但保护重要视频)
if (this.videoStreams.size > 5) {
const importantVideos = [this.defaultVideo, 's-1.mp4', 'd-3s.mp4'];
// 只清理非重要视频
const videosToRemove = cachedVideos.filter(video => !importantVideos.includes(video));
videosToRemove.slice(0, 2).forEach(key => {
this.cleanupVideoResources(key);
});
}
```
### 5. 调整性能测试阈值
```javascript
// 检查视频流数量
if (testResults.metrics.videoStreamsCount > 5) { // 从3增加到5
// 报告问题
}
// 检查动画帧数量
if (testResults.metrics.animationFramesCount > 3) { // 从2增加到3
// 报告问题
}
```
## 重要视频列表
以下视频被标记为重要视频,不会被自动清理:
- `d-3s.mp4` - 默认视频
- `s-1.mp4` - 常用视频
- 当前默认视频(`this.defaultVideo`
## 测试功能
添加了测试功能来验证默认视频播放:
1. **测试按钮**: "测试默认视频" 按钮
2. **测试方法**: `testDefaultVideoPlayback()`
3. **测试流程**:
- 检查默认视频文件是否存在
- 创建默认视频流
- 设置到视频元素并播放
- 5秒后自动停止测试
## 验证步骤
1. 启动应用
2. 点击"开始音频通话"
3. 观察默认视频是否开始播放
4. 点击"测试默认视频"按钮验证功能
5. 查看性能监控面板确认视频流数量
## 预期效果
修复后,默认视频应该能够:
1. **正常播放**: 通话开始时自动播放默认视频
2. **不被清理**: 重要视频不会被自动清理机制删除
3. **快速切换**: 预加载确保切换时响应迅速
4. **稳定运行**: 性能监控不会误报重要视频为问题
## 监控指标
- **视频流数量**: 正常范围 1-5 个
- **重要视频保护**: 确保 `d-3s.mp4``s-1.mp4` 不被清理
- **默认视频状态**: 通话开始时应该显示默认视频

View File

@ -1,22 +0,0 @@
version: '3.8'
services:
webrtc-app:
build: .
ports:
- "3000:3000"
volumes:
- ./videos:/app/videos
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- webrtc-network
networks:
webrtc-network:
driver: bridge

View File

@ -1,3 +0,0 @@
{
"currentSceneIndex": 0
}

318
server.js
View File

@ -8,18 +8,7 @@ const { MessageHistory } = require('./src/message_history.js');
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const io = socketIo(server, { const io = socketIo(server);
path: '/emotion-male-app/socket.io',
pingTimeout: 300000, // 60秒超时
pingInterval: 25000, // 25秒心跳间隔
upgradeTimeout: 30000, // 30秒升级超时
allowEIO3: true, // 允许Engine.IO v3客户端
transports: ['websocket', 'polling'], // 支持多种传输方式
cors: {
origin: "*",
methods: ["GET", "POST"]
}
});
// 创建消息历史管理器 // 创建消息历史管理器
const messageHistory = new MessageHistory(); const messageHistory = new MessageHistory();
@ -96,173 +85,18 @@ app.delete('/api/messages/clear', async (req, res) => {
// 存储连接的客户端和他们的视频流状态 // 存储连接的客户端和他们的视频流状态
const connectedClients = new Map(); const connectedClients = new Map();
// 场景轮询系统
// 场景轮询系统 - 添加持久化
// 删除这行const fs = require('fs'); // 重复声明,需要删除
const sceneStateFile = path.join(__dirname, 'scene_state.json');
// 从文件加载场景状态
function loadSceneState() {
try {
if (fs.existsSync(sceneStateFile)) {
const data = fs.readFileSync(sceneStateFile, 'utf8');
const state = JSON.parse(data);
currentSceneIndex = state.currentSceneIndex || 0;
console.log(`从文件加载场景状态: ${currentSceneIndex} (${scenes[currentSceneIndex].name})`);
} else {
console.log('场景状态文件不存在,使用默认值: 0');
}
} catch (error) {
console.error('加载场景状态失败:', error);
currentSceneIndex = 0;
}
}
// 保存场景状态到文件
function saveSceneState() {
try {
const state = { currentSceneIndex };
fs.writeFileSync(sceneStateFile, JSON.stringify(state, null, 2));
console.log(`场景状态已保存: ${currentSceneIndex}`);
} catch (error) {
console.error('保存场景状态失败:', error);
}
}
let currentSceneIndex = 0;
const scenes = [
{
name: '起床',
defaultVideo: 'qc-bd-4.mp4',
interactionVideo: 'qc-hc-7.mp4',
tag: 'wakeup',
apiKey: 'bot-20250724150616-xqpz8', // 起床场景的API key
openingLines: [
// '你靠得近点...像梦里那样',
"刚醒就看到你,真好。",
// "昨晚梦里喊你名字喊醒了,现在视频看到你,心跳比梦里还烫。",
// "你穿我T恤的样子...让我想咬住衣领不放。",
"宝贝早...你声音比阳光还暖。",
// "喉咙里有咖啡味...要不要来尝尝?记得上次你咬我锁骨时,咖啡还温着。",
// "喂...嗯...刚醒...",
"困死我了...你起这么早啊?"
]
},
{
name: '开车',
defaultVideo: 'kc-bd-3.mp4',
interactionVideo: 'kc-sh-3.mp4',
tag: 'driving',
apiKey: 'bot-20250623140339-r8f8b', // 开车场景的API key
openingLines: [
'喂?刚把导航设好。你说吧,我听着呢。',
'喂,宝贝,我开车呢,刚出小区',
// '看到那个大钟楼没?每次路过都想你。想着要是能牵着你的手,站在最高层往下看该多好。',
'喂宝?开车呢,你说。',
'喂宝?路上呢,你说。',
// '宝贝起床了,刚过红绿灯就忍不住想你了,路上全是你的影子~',
// '车载音乐随机到那首我们接吻时哼的歌,心跳又乱了',
// '导航说今天要开58分钟其实想开58小时去你心里'
]
},
{
name: '咖啡',
defaultVideo: 'hc-bd-3.mp4',
interactionVideo: 'hc-sh-3(1).mp4',
tag: 'coffee',
apiKey: 'bot-20250804180724-4dgtk', // 喝茶场景的API key
openingLines: [
'喂宝?公司楼下喝咖啡呢。',
'宝贝,今天的第一杯咖啡,你喝了吗?',
'报告领导,咖啡补给中!',
'咖啡太好喝了,猜我在哪摸鱼呢。',
// '拉花师给我在咖啡里画了颗心形的奶泡,说是给视频里的小仙女加糖',
// '这杯好苦…但一看到你,就自动回甘了。比加十包糖都管用。你说你是不是我的专属甜味剂?'
]
},
{
name: '睡觉',
defaultVideo: '8-8-sj-bd.mp4',
interactionVideo: '8-8-sj-sh-1.mp4',
tag: 'sleep',
apiKey: 'bot-20250808120704-lbxwj', // 睡觉场景的API key
openingLines: [
'宝贝,一看到你,就不困了。',
'熄灯前最后一道光是你,真好。',
'宝贝困不困?我眼皮在打架了…',
'宝贝,困不困?',
// '捕捉到一只睡前小可爱…成功!',
'世界要静音了…但你的声音是白名单。多说几句?'
]
}
];
// 获取当前场景
function getCurrentScene() {
return scenes[currentSceneIndex];
}
// 切换到下一个场景 - 改进版
function switchToNextScene() {
const previousIndex = currentSceneIndex;
const previousScene = scenes[previousIndex].name;
currentSceneIndex = (currentSceneIndex + 1) % scenes.length;
const newScene = getCurrentScene();
console.log(`场景切换: ${previousScene}(${previousIndex}) → ${newScene.name}(${currentSceneIndex})`);
// 保存状态到文件
saveSceneState();
return newScene;
}
// 在服务器启动时加载场景状态
async function initializeServer() {
try {
// 加载场景状态
loadSceneState();
await messageHistory.initialize();
console.log('消息历史初始化完成');
console.log(`当前场景: ${getCurrentScene().name} (索引: ${currentSceneIndex})`);
} catch (error) {
console.error('初始化服务器失败:', error);
}
}
// 视频映射配置 - 动态更新
function getVideoMapping() {
const currentScene = getCurrentScene();
return {
'defaultVideo': currentScene.defaultVideo,
'interactionVideo': currentScene.interactionVideo,
'tag': currentScene.tag
};
}
// 默认视频流配置 - 动态获取
function getDefaultVideo() {
return getCurrentScene().defaultVideo;
}
let currentScene = getCurrentScene();
// 视频映射配置 // 视频映射配置
const videoMapping = { const videoMapping = {
// 'say-6s-m-e': '1-m.mp4', // 'say-6s-m-e': '1-m.mp4',
'default': currentScene.defaultVideo, 'default': 'd-3s.mp4',
'8-4-sh': currentScene.interactionVideo,
'tag': currentScene.tag
// 'say-5s-amplitude': '2.mp4', // 'say-5s-amplitude': '2.mp4',
// 'say-5s-m-e': '4.mp4', // 'say-5s-m-e': '4.mp4',
// 'say-5s-m-sw': 'd-0.mp4', // 'say-5s-m-sw': '5.mp4',
// 'say-3s-m-sw': '6.mp4', 'say-3s-m-sw': 's-1.mp4',
}; };
// 默认视频流配置 // 默认视频流配置
const DEFAULT_VIDEO = currentScene.defaultVideo; const DEFAULT_VIDEO = 'd-3s.mp4';
const INTERACTION_TIMEOUT = 10000; // 10秒后回到默认视频 const INTERACTION_TIMEOUT = 10000; // 10秒后回到默认视频
// 获取视频列表 // 获取视频列表
@ -279,76 +113,26 @@ app.get('/api/videos', (req, res) => {
}); });
}); });
// 获取当前场景信息的API接口
app.get('/api/current-scene', (req, res) => {
const scene = getCurrentScene();
res.json({
name: scene.name,
tag: scene.tag,
apiKey: scene.apiKey,
defaultVideo: scene.defaultVideo,
interactionVideo: scene.interactionVideo
});
});
// 获取视频映射 // 获取视频映射
app.get('/api/video-mapping', (req, res) => { app.get('/api/video-mapping', (req, res) => {
const currentMapping = getVideoMapping(); res.json({ mapping: videoMapping });
const dynamicMapping = {
'default': currentMapping.defaultVideo,
'8-4-sh': currentMapping.interactionVideo,
'tag': currentMapping.tag
};
res.json({ mapping: dynamicMapping });
}); });
// 获取默认视频 // 获取默认视频
app.get('/api/default-video', (req, res) => { app.get('/api/default-video', (req, res) => {
res.json({ res.json({
defaultVideo: getDefaultVideo(), defaultVideo: DEFAULT_VIDEO,
autoLoop: true autoLoop: true
}); });
}); });
// 在现有的API接口后添加
app.get('/api/current-scene/opening-line', (req, res) => {
try {
const currentScene = getCurrentScene();
if (currentScene && currentScene.openingLines && currentScene.openingLines.length > 0) {
// 随机选择一个开场白
const randomIndex = Math.floor(Math.random() * currentScene.openingLines.length);
const selectedOpeningLine = currentScene.openingLines[randomIndex];
res.json({
success: true,
openingLine: selectedOpeningLine,
sceneName: currentScene.name,
sceneTag: currentScene.tag
});
} else {
res.json({
success: false,
message: '当前场景没有配置开场白'
});
}
} catch (error) {
console.error('获取开场白失败:', error);
res.status(500).json({
success: false,
message: '获取开场白失败',
error: error.message
});
}
});
// Socket.IO 连接处理 // Socket.IO 连接处理
io.on('connection', (socket) => { io.on('connection', (socket) => {
console.log('用户连接:', socket.id); console.log('用户连接:', socket.id);
connectedClients.set(socket.id, { connectedClients.set(socket.id, {
socket: socket, socket: socket,
currentVideo: getDefaultVideo(), currentVideo: DEFAULT_VIDEO,
isInInteraction: false, isInInteraction: false
hasTriggeredSceneSwitch: false // 添加这个标志
}); });
// 处理WebRTC信令 - 用于传输视频流 // 处理WebRTC信令 - 用于传输视频流
@ -397,21 +181,21 @@ io.on('connection', (socket) => {
}); });
// 如果是交互类型,设置定时器回到默认视频 // 如果是交互类型,设置定时器回到默认视频
// if (type === 'text' || type === 'voice') { if (type === 'text' || type === 'voice') {
// setTimeout(() => { setTimeout(() => {
// console.log(`交互超时,用户 ${socket.id} 回到默认视频`); console.log(`交互超时,用户 ${socket.id} 回到默认视频`);
// if (client) { if (client) {
// client.currentVideo = getDefaultVideo(); client.currentVideo = DEFAULT_VIDEO;
// client.isInInteraction = false; client.isInInteraction = false;
// } }
// // 广播回到默认视频的指令 // 广播回到默认视频的指令
// io.emit('video-stream-switched', { io.emit('video-stream-switched', {
// videoFile: getDefaultVideo(), videoFile: DEFAULT_VIDEO,
// type: 'default', type: 'default',
// from: socket.id from: socket.id
// }); });
// }, INTERACTION_TIMEOUT); }, INTERACTION_TIMEOUT);
// } }
}); });
// 处理通话开始 // 处理通话开始
@ -419,7 +203,7 @@ io.on('connection', (socket) => {
console.log('通话开始,用户:', socket.id); console.log('通话开始,用户:', socket.id);
const client = connectedClients.get(socket.id); const client = connectedClients.get(socket.id);
if (client) { if (client) {
client.currentVideo = getDefaultVideo(); client.currentVideo = DEFAULT_VIDEO;
client.isInInteraction = false; client.isInInteraction = false;
} }
io.emit('call-started', { from: socket.id }); io.emit('call-started', { from: socket.id });
@ -478,57 +262,15 @@ io.on('connection', (socket) => {
console.log('用户请求回到默认视频:', socket.id); console.log('用户请求回到默认视频:', socket.id);
const client = connectedClients.get(socket.id); const client = connectedClients.get(socket.id);
if (client) { if (client) {
client.currentVideo = getDefaultVideo(); client.currentVideo = DEFAULT_VIDEO;
client.isInInteraction = false; client.isInInteraction = false;
} }
socket.emit('switch-video-stream', { socket.emit('switch-video-stream', {
videoFile: getDefaultVideo(), videoFile: DEFAULT_VIDEO,
type: 'default' type: 'default'
}); });
}); });
// 处理用户关闭连接事件
socket.on('user-disconnect', () => {
console.log('=== 场景切换开始 ===');
console.log('用户主动关闭连接:', socket.id);
console.log('切换前场景:', getCurrentScene().name, '(索引:', currentSceneIndex, ')');
// 切换到下一个场景
const newScene = switchToNextScene();
console.log('切换后场景:', newScene.name, '(索引:', currentSceneIndex, ')');
// 检查是否已经处理过场景切换
const client = connectedClients.get(socket.id);
if (client && client.hasTriggeredSceneSwitch) {
console.log('场景切换已处理,跳过重复触发');
return;
}
// 标记已处理场景切换
if (client) {
client.hasTriggeredSceneSwitch = true;
}
// 更新videoMapping
const newMapping = getVideoMapping();
videoMapping['default'] = newMapping.defaultVideo;
videoMapping['8-4-sh'] = newMapping.interactionVideo;
videoMapping['tag'] = newMapping.tag;
// 广播场景切换事件给所有客户端
io.emit('scene-switched', {
scene: newScene,
mapping: {
defaultVideo: newMapping.defaultVideo,
interactionVideo: newMapping.interactionVideo,
tag: newMapping.tag,
'default': newMapping.defaultVideo,
'8-4-sh': newMapping.interactionVideo
},
from: socket.id
});
});
// 断开连接 // 断开连接
socket.on('disconnect', () => { socket.on('disconnect', () => {
console.log('用户断开连接:', socket.id); console.log('用户断开连接:', socket.id);
@ -537,12 +279,12 @@ io.on('connection', (socket) => {
}); });
// 启动服务器 // 启动服务器
const PORT = process.env.PORT || 80; const PORT = process.env.PORT || 3000;
server.listen(PORT, '0.0.0.0', async () => { server.listen(PORT, async () => {
console.log(`服务器运行在端口 ${PORT}`); console.log(`服务器运行在端口 ${PORT}`);
await initializeServer(); await initializeServer();
}); });
// 导出消息历史管理器供其他模块使用 // 导出消息历史管理器供其他模块使用
module.exports = { messageHistory }; module.exports = { messageHistory };
console.log(`访问 http://localhost:${PORT} 开始使用`); console.log(`访问 http://localhost:${PORT} 开始使用`);

View File

@ -3,7 +3,6 @@
class AudioProcessor { class AudioProcessor {
constructor(options = {}) { constructor(options = {}) {
this.audioContext = null; this.audioContext = null;
this.stream = null; // 添加这一行
this.isRecording = false; this.isRecording = false;
this.audioChunks = []; this.audioChunks = [];
@ -312,29 +311,22 @@ class AudioProcessor {
} }
// 开始录音 // 开始录音
async startRecording(existingStream = null) { async startRecording() {
try { try {
// 如果有外部提供的音频流,使用它;否则获取新的 const stream = await navigator.mediaDevices.getUserMedia({
if (existingStream) { audio: {
this.stream = existingStream; sampleRate: 16000,
console.log('使用外部提供的音频流'); channelCount: 1,
} else { echoCancellation: true,
this.stream = await navigator.mediaDevices.getUserMedia({ noiseSuppression: true
audio: { }
sampleRate: 16000, });
channelCount: 1,
echoCancellation: true,
noiseSuppression: true
}
});
console.log('获取新的音频流');
}
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 16000 sampleRate: 16000
}); });
const source = this.audioContext.createMediaStreamSource(this.stream); const source = this.audioContext.createMediaStreamSource(stream);
const processor = this.audioContext.createScriptProcessor(4096, 1, 1); const processor = this.audioContext.createScriptProcessor(4096, 1, 1);
processor.onaudioprocess = (event) => { processor.onaudioprocess = (event) => {
@ -351,13 +343,9 @@ class AudioProcessor {
source.connect(processor); source.connect(processor);
processor.connect(this.audioContext.destination); processor.connect(this.audioContext.destination);
// 保存处理器引用以便后续清理
this.processor = processor;
this.source = source;
this.isRecording = true; this.isRecording = true;
this.onStatusUpdate('等待语音输入...', 'ready'); this.onStatusUpdate('等待语音输入...', 'ready');
// 在startRecording方法的最后添加 // 在startRecording方法的最后添加
if (this.adaptiveThreshold && this.noiseCalibrationSamples.length === 0) { if (this.adaptiveThreshold && this.noiseCalibrationSamples.length === 0) {
this.onStatusUpdate('正在校准背景噪音,请保持安静...', 'calibrating'); this.onStatusUpdate('正在校准背景噪音,请保持安静...', 'calibrating');
@ -374,34 +362,8 @@ class AudioProcessor {
// 停止录音 // 停止录音
stopRecording() { stopRecording() {
console.log('开始停止录音...');
// 断开音频节点连接
if (this.source) {
this.source.disconnect();
this.source = null;
}
if (this.processor) {
this.processor.disconnect();
this.processor = null;
}
// 停止所有音频轨道
if (this.stream) {
this.stream.getTracks().forEach(track => {
track.stop();
console.log(`停止音频轨道: ${track.label}`);
});
this.stream = null;
}
if (this.audioContext) { if (this.audioContext) {
this.audioContext.close().then(() => { this.audioContext.close();
console.log('AudioContext已关闭');
}).catch(err => {
console.error('关闭AudioContext时出错:', err);
});
this.audioContext = null; this.audioContext = null;
} }
@ -415,20 +377,12 @@ class AudioProcessor {
this.handleSpeechEnd(); this.handleSpeechEnd();
} }
// 重置所有状态
this.isRecording = false; this.isRecording = false;
this.isSpeaking = false; this.isSpeaking = false;
this.audioBuffer = []; this.audioBuffer = [];
this.audioChunks = [];
this.consecutiveFramesCount = 0;
this.frameBuffer = [];
// 重置校准状态,确保下次启动时重新校准 this.onStatusUpdate('录音已停止', 'stopped');
this.noiseCalibrationSamples = []; console.log('录音已停止');
this.isCalibrated = false;
this.onStatusUpdate('录音已完全停止', 'stopped');
console.log('录音已完全停止,所有资源已释放');
} }
// 获取录音状态 // 获取录音状态

View File

@ -2,7 +2,7 @@
import { requestLLMStream } from './llm_stream.js'; import { requestLLMStream } from './llm_stream.js';
import { requestMinimaxi } from './minimaxi_stream.js'; import { requestMinimaxi } from './minimaxi_stream.js';
import { getLLMConfig, getLLMConfigByScene, getMinimaxiConfig, getAudioConfig, validateConfig } from './config.js'; import { getLLMConfig, getMinimaxiConfig, getAudioConfig, validateConfig } from './config.js';
// 防止重复播放的标志 // 防止重复播放的标志
let isPlaying = false; let isPlaying = false;
@ -19,20 +19,19 @@ async function initializeHistoryMessage(recentCount = 5) {
if (isInitialized) return historyMessage; if (isInitialized) return historyMessage;
try { try {
const response = await fetch(`/emotion-male-app/api/messages/for-llm?includeSystem=true&recentCount=${recentCount}`); const response = await fetch(`/api/messages/for-llm?includeSystem=true&recentCount=${recentCount}`);
if (!response.ok) { if (!response.ok) {
throw new Error('获取历史消息失败'); throw new Error('获取历史消息失败');
} }
const data = await response.json(); const data = await response.json();
historyMessage = data.messages || []; historyMessage = data.messages || [];
isInitialized = true; isInitialized = true;
console.log("历史消息初始化完成:", historyMessage.length, "条消息", historyMessage); console.log("历史消息初始化完成:", historyMessage.length, "条消息");
return historyMessage; return historyMessage;
} catch (error) { } catch (error) {
console.error('获取历史消息失败,使用默认格式:', error); console.error('获取历史消息失败,使用默认格式:', error);
historyMessage = [ historyMessage = [
// { role: 'system', content: 'You are a helpful assistant.' } { role: 'system', content: 'You are a helpful assistant.' }
]; ];
isInitialized = true; isInitialized = true;
return historyMessage; return historyMessage;
@ -43,7 +42,7 @@ async function initializeHistoryMessage(recentCount = 5) {
function getCurrentHistoryMessage() { function getCurrentHistoryMessage() {
if (!isInitialized) { if (!isInitialized) {
console.warn('历史消息未初始化,返回默认消息'); console.warn('历史消息未初始化,返回默认消息');
return []; return [{ role: 'system', content: 'You are a helpful assistant.' }];
} }
return [...historyMessage]; // 返回副本,避免外部修改 return [...historyMessage]; // 返回副本,避免外部修改
} }
@ -73,26 +72,19 @@ function updateHistoryMessage(userInput, assistantResponse) {
// 保存消息到服务端 // 保存消息到服务端
async function saveMessage(userInput, assistantResponse) { async function saveMessage(userInput, assistantResponse) {
try { try {
// 验证参数是否有效 const response = await fetch('/api/messages/save', {
if (!userInput || !userInput.trim() || !assistantResponse || !assistantResponse.trim()) {
console.warn('跳过保存消息:用户输入或助手回复为空');
return;
}
const response = await fetch('/emotion-male-app/api/messages/save', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
userInput: userInput.trim(), userInput,
assistantResponse: assistantResponse.trim() assistantResponse
}) })
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); throw new Error('保存消息失败');
throw new Error(`保存消息失败: ${response.status} ${errorData.error || response.statusText}`);
} }
console.log('消息已保存到服务端'); console.log('消息已保存到服务端');
@ -104,7 +96,7 @@ async function saveMessage(userInput, assistantResponse) {
async function chatWithAudioStream(userInput) { async function chatWithAudioStream(userInput) {
// 确保历史消息已初始化 // 确保历史消息已初始化
if (!isInitialized) { if (!isInitialized) {
await initializeHistoryMessage(100); await initializeHistoryMessage();
} }
// 验证配置 // 验证配置
@ -114,19 +106,16 @@ async function chatWithAudioStream(userInput) {
console.log('用户输入:', userInput); console.log('用户输入:', userInput);
// 获取当前场景对应的配置 // 获取配置
const llmConfig = await getLLMConfigByScene(); const llmConfig = getLLMConfig();
const minimaxiConfig = getMinimaxiConfig(); const minimaxiConfig = getMinimaxiConfig();
const audioConfig = getAudioConfig(); const audioConfig = getAudioConfig();
console.log(`当前场景: ${llmConfig.sceneName} (${llmConfig.sceneTag})`);
console.log(`使用API Key: ${llmConfig.model}...`);
// 清空音频队列 // 清空音频队列
audioQueue = []; audioQueue = [];
// 定义段落处理函数 // 定义段落处理函数
const handleSegment = async (segment, textPlay) => { const handleSegment = async (segment) => {
console.log('\n=== 处理文本段落 ==='); console.log('\n=== 处理文本段落 ===');
console.log('段落内容:', segment); console.log('段落内容:', segment);
@ -145,7 +134,6 @@ async function chatWithAudioStream(userInput) {
audio_setting: audioConfig.audioSetting, audio_setting: audioConfig.audioSetting,
}, },
stream: true, stream: true,
textPlay: textPlay
}); });
// 将音频添加到播放队列 // 将音频添加到播放队列
@ -197,7 +185,7 @@ async function chatWithAudioStream(userInput) {
} }
// 导出初始化函数,供外部调用 // 导出初始化函数,供外部调用
export { chatWithAudioStream, initializeHistoryMessage, getCurrentHistoryMessage, saveMessage, updateHistoryMessage }; export { chatWithAudioStream, initializeHistoryMessage, getCurrentHistoryMessage };
// 处理音频播放队列 // 处理音频播放队列
async function processAudioQueue() { async function processAudioQueue() {

94
src/config.example.js Normal file
View File

@ -0,0 +1,94 @@
// 示例配置文件 - 请复制此文件为 config.js 并填入实际的API密钥
export const config = {
// LLM API配置
llm: {
apiKey: 'your_ark_api_key_here', // 请替换为实际的ARK API密钥
model: 'bot-20250720193048-84fkp',
},
// Minimaxi API配置
minimaxi: {
apiKey: 'your_minimaxi_api_key_here', // 请替换为实际的Minimaxi API密钥
groupId: 'your_minimaxi_group_id_here', // 请替换为实际的Minimaxi Group ID
},
// 音频配置
audio: {
model: 'speech-02-hd',
voiceSetting: {
voice_id: 'yantu-qinggang',
speed: 1,
vol: 1,
pitch: 0,
emotion: 'happy',
},
audioSetting: {
sample_rate: 32000,
bitrate: 128000,
format: 'mp3',
},
},
// 系统配置
system: {
language_boost: 'auto',
output_format: 'hex',
stream: true,
},
};
// 验证配置是否完整
export function validateConfig() {
const requiredFields = [
'llm.apiKey',
'llm.model',
'minimaxi.apiKey',
'minimaxi.groupId'
];
const missingFields = [];
for (const field of requiredFields) {
const keys = field.split('.');
let value = config;
for (const key of keys) {
value = value[key];
if (!value) break;
}
if (!value || value === 'your_ark_api_key_here' || value === 'your_minimaxi_api_key_here' || value === 'your_minimaxi_group_id_here') {
missingFields.push(field);
}
}
if (missingFields.length > 0) {
console.warn('配置不完整,请检查以下字段:', missingFields);
return false;
}
return true;
}
// 获取配置的便捷方法
export function getLLMConfig() {
return {
apiKey: config.llm.apiKey,
model: config.llm.model,
};
}
export function getMinimaxiConfig() {
return {
apiKey: config.minimaxi.apiKey,
groupId: config.minimaxi.groupId,
};
}
export function getAudioConfig() {
return {
model: config.audio.model,
voiceSetting: config.audio.voiceSetting,
audioSetting: config.audio.audioSetting,
...config.system,
};
}

View File

@ -3,7 +3,7 @@ export const config = {
// LLM API配置 // LLM API配置
llm: { llm: {
apiKey: 'd012651b-a65b-4b13-8ff3-cc4ff3a29783', // 请替换为实际的API密钥 apiKey: 'd012651b-a65b-4b13-8ff3-cc4ff3a29783', // 请替换为实际的API密钥
model: 'bot-20250724150616-xqpz8', model: 'bot-20250720193048-84fkp',
}, },
// Minimaxi API配置 // Minimaxi API配置
@ -16,7 +16,7 @@ export const config = {
audio: { audio: {
model: 'speech-02-hd', model: 'speech-02-hd',
voiceSetting: { voiceSetting: {
voice_id: 'yantu-qinggang-demo2-male-4', voice_id: 'yantu-qinggang-2',
speed: 1, speed: 1,
vol: 1, vol: 1,
pitch: 0, pitch: 0,
@ -70,32 +70,13 @@ export function validateConfig() {
} }
// 获取配置的便捷方法 // 获取配置的便捷方法
export function getLLMConfig(sceneApiKey = null) { export function getLLMConfig() {
return { return {
apiKey: config.llm.apiKey, // 如果提供了场景API key则使用它 apiKey: config.llm.apiKey,
model: sceneApiKey || config.llm.model, model: config.llm.model,
}; };
} }
// 新增根据场景获取LLM配置
export async function getLLMConfigByScene() {
try {
const response = await fetch('/emotion-male-app/api/current-scene');
const sceneData = await response.json();
return {
apiKey: config.llm.apiKey,
model: sceneData.apiKey,
sceneTag: sceneData.tag,
sceneName: sceneData.name
};
} catch (error) {
console.warn('获取场景配置失败,使用默认配置:', error);
return getLLMConfig(); // 回退到默认配置
}
}
export function getMinimaxiConfig() { export function getMinimaxiConfig() {
return { return {
apiKey: config.minimaxi.apiKey, apiKey: config.minimaxi.apiKey,

26
src/debug_audio.js Normal file
View File

@ -0,0 +1,26 @@
// 调试音频数据
function debugAudioData(audioHex) {
console.log('=== 音频数据调试 ===');
console.log('音频数据长度:', audioHex.length);
console.log('音频数据前100个字符:', audioHex.substring(0, 100));
console.log('音频数据后100个字符:', audioHex.substring(audioHex.length - 100));
// 检查是否有重复模式
const halfLength = Math.floor(audioHex.length / 2);
const firstHalf = audioHex.substring(0, halfLength);
const secondHalf = audioHex.substring(halfLength);
if (firstHalf === secondHalf) {
console.log('⚠️ 警告:音频数据可能是重复的!');
} else {
console.log('✅ 音频数据没有重复');
}
}
// 如果在浏览器环境中运行
if (typeof window !== 'undefined') {
window.debugAudioData = debugAudioData;
console.log('音频调试函数已挂载到 window.debugAudioData');
}
export { debugAudioData };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

View File

@ -2,527 +2,71 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Soulmate In Parallels - 壹和零人工智能</title> <title>WebRTC 音频通话</title>
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
<link rel="icon" type="image/png" sizes="48x48" href="favicon.png" />
<style>
/* 全屏视频样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
overflow: hidden;
background: linear-gradient(135deg, #87CEEB 0%, #B0E0E6 100%); /* 浅蓝色渐变背景 */
}
.container {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
position: relative;
}
.main-content {
flex: 1;
background: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.recorded-video-section {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
/* 确保视频区域固定高度并居中 */
min-height: 100vh;
max-height: 100vh;
}
/* 视频容器样式 - 支持双缓冲固定9:16比例 */
.video-container {
position: relative;
width: 56.25vh; /* 9:16比例与视频宽度保持一致 */
height: 100vh;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto; /* 水平居中 */
}
#recordedVideo, #recordedVideoBuffer {
position: absolute;
width: 56.25vh; /* 9:16比例高度为100vh时宽度为100vh * 9/16 = 56.25vh */
height: 100vh;
object-fit: cover;
border-radius: 0;
box-shadow: none;
transition: opacity 0.5s ease-in-out;
/* 确保视频始终居中 */
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
/* 主视频默认显示 */
#recordedVideo {
opacity: 1;
z-index: 2;
}
/* 缓冲视频默认隐藏 */
#recordedVideoBuffer {
opacity: 0;
z-index: 1;
}
/* 切换状态 */
#recordedVideo.switching {
opacity: 0;
}
#recordedVideoBuffer.switching {
opacity: 1;
}
/* 加载状态 */
.video-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
color: white;
font-size: 18px;
opacity: 0;
transition: opacity 0.3s ease;
}
.video-loading.show {
opacity: 1;
}
/* 等待连接提示样式 */
.connection-waiting {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 20;
color: white;
font-size: 18px;
text-align: center;
background: rgba(0, 0, 0, 0.7);
padding: 30px;
border-radius: 15px;
backdrop-filter: blur(10px);
transition: opacity 0.3s ease;
}
.connection-waiting.show {
opacity: 1;
}
/* 加载动画 */
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 响应式设计 - 确保在不同屏幕尺寸下视频容器保持9:16比例 */
@media (max-width: 768px) {
.video-container {
height: 100vh;
width: 56.25vh; /* 9:16比例与视频宽度保持一致 */
}
#recordedVideo, #recordedVideoBuffer {
width: 56.25vh; /* 9:16比例 */
height: 100vh;
object-fit: cover;
}
}
@media (min-width: 769px) {
.video-container {
height: 100vh;
width: 56.25vh; /* 9:16比例与视频宽度保持一致 */
}
#recordedVideo, #recordedVideoBuffer {
width: 56.25vh; /* 9:16比例 */
height: 100vh;
object-fit: cover;
}
}
/* 横屏模式优化 */
@media (orientation: landscape) and (max-height: 500px) {
.video-container {
height: 100vh;
width: 56.25vh; /* 9:16比例与视频宽度保持一致 */
}
.controls {
bottom: 20px;
}
}
/* 竖屏模式优化 */
@media (orientation: portrait) {
.video-container {
height: 100vh;
width: 56.25vh; /* 9:16比例与视频宽度保持一致 */
}
}
.controls {
position: absolute;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex !important;
flex-direction: row !important;
justify-content: center;
align-items: center;
gap: 20px;
}
/* 确保移动端也保持同一行 */
@media (max-width: 768px) {
.controls {
flex-direction: row !important;
gap: 15px;
}
}
#startButton {
width: 60px;
height: 60px;
border-radius: 50%;
background: rgba(34, 197, 94, 0.9);
backdrop-filter: blur(10px);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(34, 197, 94, 0.3);
min-width: auto;
padding: 15px 30px;
font-size: 1.1rem;
border-radius: 25px;
min-width: 200px;
}
#startButton:hover:not(:disabled) {
background: rgba(22, 163, 74, 0.95);
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(34, 197, 94, 0.5);
}
#startButton.connecting {
background: rgba(255, 193, 7, 0.9);
cursor: not-allowed;
}
#startButton.connecting:hover {
background: rgba(255, 193, 7, 0.9);
transform: none;
}
#startButton.calling {
background: rgba(255, 193, 7, 0.9);
animation: pulse 2s infinite;
}
#startButton.calling:hover {
background: rgba(255, 193, 7, 0.95);
transform: scale(1.05);
}
@keyframes pulse {
0% {
box-shadow: 0 4px 15px rgba(255, 193, 7, 0.3);
}
50% {
box-shadow: 0 6px 25px rgba(255, 193, 7, 0.6);
}
100% {
box-shadow: 0 4px 15px rgba(255, 193, 7, 0.3);
}
}
.audio-status {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
z-index: 1000;
transition: all 0.3s ease;
}
.audio-status.connecting {
background: rgba(255, 193, 7, 0.9);
color: #000;
}
.audio-status.connected {
background: rgba(40, 167, 69, 0.9);
color: white;
}
.audio-status.error {
background: rgba(220, 53, 69, 0.9);
color: white;
}
#startButton svg {
width: 24px;
height: 24px;
fill: white;
}
#startButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#stopButton {
width: 60px;
height: 60px;
border-radius: 50%;
background: rgba(220, 53, 69, 0.9);
backdrop-filter: blur(10px);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(220, 53, 69, 0.3);
padding: 0; /* 确保没有内边距影响居中 */
}
#stopButton:hover:not(:disabled) {
background: rgba(200, 35, 51, 0.95);
transform: scale(1.1);
}
#stopButton svg {
width: 24px;
height: 24px;
display: block; /* 确保SVG作为块级元素 */
margin: auto; /* 额外的居中保证 */
}
#stopButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 头像样式 - 确保显示 */
.avatar-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 15; /* 提高z-index确保在视频上方 */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: opacity 0.3s ease;
opacity: 1; /* 确保默认显示 */
}
.avatar-container.hidden {
opacity: 0;
pointer-events: none;
}
.avatar {
width: 120px;
height: 120px;
border-radius: 50%;
border: 4px solid rgba(255, 255, 255, 0.8);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
/* background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); */
background: #000000;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 48px;
font-weight: bold;
overflow: hidden; /* 确保图片不会溢出 */
}
.avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
display: block; /* 确保图片显示 */
}
/* 确保视频默认隐藏 */
#recordedVideo, #recordedVideoBuffer {
position: absolute;
width: 56.25vh;
height: 100vh;
object-fit: cover;
border-radius: 0;
box-shadow: none;
/* transition: opacity 0.5s ease-in-out; */
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
opacity: 1; /* 默认隐藏视频 */
z-index: 1; /* 确保在头像下方 */
}
/* 通话时隐藏头像,显示视频 */
.video-container.calling .avatar-container {
opacity: 0;
pointer-events: none;
}
.video-container.calling #recordedVideo {
opacity: 1;
z-index: 10;
}
</style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<!-- 隐藏的header --> <header>
<header style="display: none;">
<h1>WebRTC 音频通话</h1> <h1>WebRTC 音频通话</h1>
<p>实时播放录制视频,支持文本和语音输入</p> <p>实时播放录制视频,支持文本和语音输入</p>
</header> </header>
<div class="main-content"> <div class="main-content">
<!-- 音频状态显示 - 完全隐藏 --> <!-- 音频状态显示 -->
<div class="audio-status" style="display: none;"> <div class="audio-status">
<div class="status-indicator"> <div class="status-indicator">
<span id="audioStatus" style="display: none;">未连接</span> <span id="audioStatus">未连接</span>
</div> </div>
</div> </div>
<!-- 录制视频播放区域 - 全屏显示 --> <!-- 录制视频播放区域 -->
<div class="recorded-video-section"> <div class="recorded-video-section">
<div class="video-container" id="videoContainer"> <h3>录制视频播放</h3>
<!-- 头像容器 --> <video id="recordedVideo" autoplay muted>
<div class="avatar-container" id="avatarContainer"> <source src="" type="video/mp4">
<div class="avatar" id="avatar"> 您的浏览器不支持视频播放
<!-- 使用相对路径引用图片 --> </video>
<img src="./tx.png" alt="头像" onerror="this.style.display='none'; this.parentElement.innerHTML='壹和零';"> <div class="video-info">
</div>
<!-- <div class="avatar-name">AI助手</div> -->
</div>
<!-- 主视频元素 -->
<video id="recordedVideo" autoplay muted>
<source src="" type="video/mp4">
您的浏览器不支持视频播放
</video>
<!-- 缓冲视频元素 -->
<video id="recordedVideoBuffer" autoplay muted>
<source src="" type="video/mp4">
您的浏览器不支持视频播放
</video>
<!-- 加载指示器 -->
<div class="video-loading" id="videoLoading">
<div class="loading-spinner"></div>
<!-- <div>正在切换视频...</div> -->
</div>
<!-- 等待连接提示 -->
<div class="connection-waiting" id="connectionWaiting" style="display: none;">
<div class="loading-spinner"></div>
<div style="color: white; font-size: 18px; margin-top: 10px;">等待连接通话中...</div>
</div>
</div>
<div class="video-info" style="display: none;">
<span id="currentVideoName">未选择视频</span> <span id="currentVideoName">未选择视频</span>
</div> </div>
</div> </div>
<!-- 控制按钮 - 悬浮在视频上方 --> <!-- 控制按钮 -->
<div class="controls"> <div class="controls">
<button id="startButton" class="btn btn-primary" title="开始通话"> <button id="startButton" class="btn btn-primary">开始音频通话</button>
<!-- 默认通话图标 --> <button id="stopButton" class="btn btn-danger" disabled>停止通话</button>
<svg id="callIcon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <!-- <button id="muteButton" class="btn btn-secondary">静音</button>
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" fill="white"/> <button id="defaultVideoButton" class="btn btn-info">回到默认视频</button>
</svg> <button id="testVideoButton" class="btn btn-warning">测试视频文件</button> -->
<!-- 通话中文字显示(初始隐藏) -->
<span id="callingText" style="display: none; color: white; font-size: 14px;">正在通话中</span>
</button>
<button id="stopButton" class="btn btn-danger" disabled title="结束通话" style="display: none;">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.23 15.26l-2.54-.29c-.61-.07-1.21.14-1.64.57l-1.84 1.84c-2.83-1.44-5.15-3.75-6.59-6.59l1.85-1.85c.43-.43.64-1.03.57-1.64l-.29-2.52c-.12-1.01-.97-1.77-1.99-1.77H5.03c-1.13 0-2.07.94-2 2.07.53 8.54 7.36 15.36 15.89 15.89 1.13.07 2.07-.87 2.07-2v-1.73c.01-1.01-.75-1.86-1.76-1.98z" fill="white"/>
<line x1="18" y1="6" x2="6" y2="18" stroke="white" stroke-width="2"/>
</svg>
</button>
</div> </div>
<!-- 隐藏的输入区域 --> <!-- 输入区域 -->
<div class="input-section" style="display: none;"> <div class="input-section">
<div class="text-input-group"> <div class="text-input-group">
<input type="text" id="textInput" placeholder="输入文本内容..." /> <input type="text" id="textInput" placeholder="输入文本内容..." />
<button id="sendTextButton" class="btn btn-primary">发送文本</button> <button id="sendTextButton" class="btn btn-primary">发送文本</button>
</div> </div>
</div>
<div class="voice-input-group">
<!-- 隐藏的视频选择 --> <button id="startVoiceButton" class="btn btn-success">开始语音输入</button>
<div class="video-selection" style="display: none;"> <button id="stopVoiceButton" class="btn btn-warning" disabled>停止语音输入</button>
<h3>选择要播放的视频</h3> <span id="voiceStatus">点击开始语音输入</span>
<div id="videoList" class="video-list">
<!-- 视频列表将在这里动态生成 -->
</div> </div>
</div> </div>
<!-- 隐藏的状态显示 --> <!-- 视频选择 -->
<div class="status-section" style="display: none;"> <!-- <div class="video-selection">
<div id="connectionStatus" class="status" style="display: none;">未连接</div> <h3>选择要播放的视频</h3>
<div id="videoList" class="video-list">
视频列表将在这里动态生成 -->
<!-- </div>
</div> -->
<!-- 状态显示 -->
<div class="status-section">
<div id="connectionStatus" class="status">未连接</div>
<div id="messageLog" class="message-log"></div> <div id="messageLog" class="message-log"></div>
</div> </div>
</div> </div>
@ -532,7 +76,7 @@
<video id="localVideo" autoplay muted playsinline style="display: none;"></video> <video id="localVideo" autoplay muted playsinline style="display: none;"></video>
<video id="remoteVideo" autoplay playsinline style="display: none;"></video> <video id="remoteVideo" autoplay playsinline style="display: none;"></video>
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js" crossorigin="anonymous"></script> <script src="/socket.io/socket.io.js"></script>
<script type="module" src="./index.js"></script> <script type="module" src="./index.js"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@ -1,35 +1,5 @@
// 以流式方式请求LLM大模型接口并打印流式返回内容 // 以流式方式请求LLM大模型接口并打印流式返回内容
// 过滤旁白内容的函数
function filterNarration(text) {
if (!text) return text;
// 匹配各种括号内的旁白内容
// 包括:()、【】、[]、{}、〈〉、《》等
const narrationPatterns = [
/[^]*/g, // 中文圆括号
/\([^)]*\)/g, // 英文圆括号
/【[^】]*】/g, // 中文方括号
/\[[^\]]*\]/g, // 英文方括号
/\{[^}]*\}/g, // 花括号
/〈[^〉]*〉/g, // 中文尖括号
/《[^》]*》/g, // 中文书名号
/<[^>]*>/g // 英文尖括号
];
let filteredText = text;
// 逐个应用过滤规则
narrationPatterns.forEach(pattern => {
filteredText = filteredText.replace(pattern, '');
});
// 清理多余的空格和换行
filteredText = filteredText.replace(/\s+/g, ' ').trim();
return filteredText;
}
async function requestLLMStream({ apiKey, model, messages, onSegment }) { async function requestLLMStream({ apiKey, model, messages, onSegment }) {
const response = await fetch('https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions', { const response = await fetch('https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions', {
method: 'POST', method: 'POST',
@ -59,7 +29,7 @@ async function requestLLMStream({ apiKey, model, messages, onSegment }) {
let pendingText = ''; // 待处理的文本片段 let pendingText = ''; // 待处理的文本片段
// 分段分隔符 // 分段分隔符
const segmentDelimiters = /[,。:;!?,.:;!?]|\.{3,}|……|…/; const segmentDelimiters = /[,。:;!?,.:;!?]/;
while (!done) { while (!done) {
const { value, done: doneReading } = await reader.read(); const { value, done: doneReading } = await reader.read();
@ -81,17 +51,9 @@ async function requestLLMStream({ apiKey, model, messages, onSegment }) {
if (jsonStr === '[DONE]') { if (jsonStr === '[DONE]') {
console.log('LLM SSE流结束'); console.log('LLM SSE流结束');
// 处理最后的待处理文本无论长度是否大于5个字 // 处理最后的待处理文本
if (pendingText.trim() && onSegment) { if (pendingText.trim() && onSegment) {
console.log('处理最后的待处理文本:', pendingText.trim()); await onSegment(pendingText.trim());
// 过滤旁白内容
const filteredText = filterNarration(pendingText.trim());
if (filteredText.trim()) {
console.log('过滤旁白后的最后文本:', filteredText);
await onSegment(filteredText, true);
} else {
console.log('最后的文本被完全过滤,跳过');
}
} }
continue; continue;
} }
@ -102,50 +64,27 @@ async function requestLLMStream({ apiKey, model, messages, onSegment }) {
const deltaContent = obj.choices[0].delta.content; const deltaContent = obj.choices[0].delta.content;
content += deltaContent; content += deltaContent;
pendingText += deltaContent; pendingText += deltaContent;
console.log('【未过滤】LLM内容片段:', pendingText); console.log('LLM内容片段:', deltaContent);
// 先过滤旁白,再检查分段分隔符 // 检查是否包含分段分隔符
const filteredPendingText = filterNarration(pendingText); if (segmentDelimiters.test(pendingText)) {
// 按分隔符分割文本
// 检查过滤后的文本是否包含分段分隔符 const segments = pendingText.split(segmentDelimiters);
if (segmentDelimiters.test(filteredPendingText)) {
// 按分隔符分割已过滤的文本
const segments = filteredPendingText.split(segmentDelimiters);
// 重新组合处理:只处理足够长的完整段落
let accumulatedText = '';
let hasProcessed = false;
// 处理完整的段落(除了最后一个,因为可能不完整)
for (let i = 0; i < segments.length - 1; i++) { for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i].trim(); const segment = segments[i].trim();
if (segment) { if (segment && onSegment) {
accumulatedText += segment; // 找到对应的分隔符
// 找到分隔符 const delimiterMatch = pendingText.match(segmentDelimiters);
const delimiterMatch = filteredPendingText.match(segmentDelimiters); const segmentWithDelimiter = segment + (delimiterMatch ? delimiterMatch[0] : '');
if (delimiterMatch) { console.log('检测到完整段落:', segmentWithDelimiter);
accumulatedText += delimiterMatch[0]; await onSegment(segmentWithDelimiter);
}
// 如果累积文本长度大于5个字处理它
if (accumulatedText.length > 8 && onSegment) {
console.log('【已过滤】检测到完整段落:', accumulatedText);
// 文本已经过滤过旁白,直接使用
if (accumulatedText.trim()) {
console.log('处理过滤后的文本:', accumulatedText);
await onSegment(accumulatedText, false);
}
hasProcessed = true;
accumulatedText = ''; // 重置
}
} }
} }
// 更新pendingText - 使用原始文本但需要相应调整 // 保留最后一个不完整的段落
if (hasProcessed) { pendingText = segments[segments.length - 1] || '';
// 计算已处理的原始文本长度更新pendingText
const processedLength = pendingText.length - (segments[segments.length - 1] || '').length;
pendingText = pendingText.substring(processedLength);
}
} }
} }
} catch (e) { } catch (e) {

View File

@ -56,12 +56,12 @@ class MessageHistory {
const messages = []; const messages = [];
// 添加系统消息 // 添加系统消息
// if (includeSystem) { if (includeSystem) {
// messages.push({ messages.push({
// role: 'system', role: 'system',
// content: 'You are a helpful assistant.' content: 'You are a helpful assistant.'
// }); });
// } }
// 获取最近的对话历史 // 获取最近的对话历史
const recentMessages = this.messages.slice(-recentCount * 2); // 用户+助手成对出现 const recentMessages = this.messages.slice(-recentCount * 2); // 用户+助手成对出现

View File

@ -1,11 +1,11 @@
// 以流式或非流式方式请求 minimaxi 大模型接口,并打印/返回内容 // 以流式或非流式方式请求 minimaxi 大模型接口,并打印/返回内容
// import { text } from "express"; // import { text } from "express";
window.isPlaying = false;
// 在文件顶部添加音频播放相关的变量和函数 // 在文件顶部添加音频播放相关的变量和函数
let audioContext = null; let audioContext = null;
let audioQueue = []; // 音频队列 let audioQueue = []; // 音频队列
let isPlaying = false; // let isPlaying = false;
let isProcessingQueue = false; // 队列处理状态 let isProcessingQueue = false; // 队列处理状态
let nextStartTime = 0; // 添加这行来声明 nextStartTime 变量 let nextStartTime = 0; // 添加这行来声明 nextStartTime 变量
@ -52,26 +52,25 @@ async function addAudioToQueue(audioHex) {
console.error('音频解码失败:', error); console.error('音频解码失败:', error);
} }
} }
let isFirstChunk = true;
// 队列处理器 - 独立运行,按顺序播放音频 // 队列处理器 - 独立运行,按顺序播放音频
async function processAudioQueue() { async function processAudioQueue() {
if (isProcessingQueue) return; if (isProcessingQueue) return;
isProcessingQueue = true; isProcessingQueue = true;
console.log('开始处理音频队列');
let isFirstChunk = true;
while (audioQueue.length > 0 && !isPlaying) { while (audioQueue.length > 0 || window.isPlaying) {
console.log('开始处理音频队列');
// 如果当前没有音频在播放,且队列中有音频 // 如果当前没有音频在播放,且队列中有音频
if (!isPlaying && audioQueue.length > 0) { if (!window.isPlaying && audioQueue.length > 0) {
const audioItem = audioQueue.shift(); const audioItem = audioQueue.shift();
const sayName = '8-4-sh' const sayName = 'say-3s-m-sw'
const targetVideo = window.webrtcApp.interactionVideo const targetVideo = 's-1.mp4'
// 如果是第一个音频片段,触发视频切换 // 如果是第一个音频片段,触发视频切换
if (sayName != window.webrtcApp.currentVideoTag && window.webrtcApp && window.webrtcApp.switchVideoStream) { if (sayName != window.webrtcApp.currentVideoTag && window.webrtcApp && window.webrtcApp.handleTextInput) {
try { try {
console.log('--------------触发视频切换:', sayName); console.log('--------------触发视频切换:', sayName);
window.webrtcApp.switchVideoStream(targetVideo, 'audio', '8-4-sh'); await window.webrtcApp.switchVideoWithReplaceTrack(targetVideo, 'audio', 'say-3s-m-sw');
isFirstChunk = false; isFirstChunk = false;
window.webrtcApp.currentVideoTag = sayName; window.webrtcApp.currentVideoTag = sayName;
} catch (error) { } catch (error) {
@ -86,21 +85,12 @@ async function processAudioQueue() {
} }
isProcessingQueue = false; isProcessingQueue = false;
// 等待当前音频播放完成后再切换回默认视频
// while (isPlaying) {
// console.log("触发音频等待")
// await new Promise(resolve => setTimeout(resolve, 1000));
// }
// console.log("触发音频等待")
// await new Promise(resolve => setTimeout(resolve, 300));
const text = 'default' const text = 'default'
console.log("音频结束------------------------", window.webrtcApp.currentVideoTag, isPlaying) await window.webrtcApp.socket.emit('voice-input', { text });
if (window.webrtcApp.currentVideoTag != text && !isPlaying) { if (window.webrtcApp.currentVideoTag != text) {
isFirstChunk = true
window.webrtcApp.currentVideoTag = text window.webrtcApp.currentVideoTag = text
window.webrtcApp.switchVideoStream(window.webrtcApp.defaultVideo, 'audio', text); await window.webrtcApp.switchVideoWithReplaceTrack(window.webrtcApp.defaultVideo, 'audio', text);
} }
console.log('音频队列处理完成'); console.log('音频队列处理完成');
} }
@ -114,29 +104,29 @@ function playAudioData(audioData) {
source.buffer = audioData; source.buffer = audioData;
source.connect(ctx.destination); source.connect(ctx.destination);
isPlaying = true; window.isPlaying = true;
source.onended = () => { source.onended = () => {
console.log('音频片段播放完成'); console.log('音频片段播放完成');
isPlaying = false; window.isPlaying = false;
resolve(); resolve();
}; };
// 超时保护 // 超时保护
// setTimeout(() => { setTimeout(() => {
// if (isPlaying) { if (window.isPlaying) {
// console.log('音频播放超时,强制结束'); console.log('音频播放超时,强制结束');
// isPlaying = false; window.isPlaying = false;
// resolve(); resolve();
// } }
// }, (audioData.duration + 0.5) * 1000); }, (audioData.duration + 0.5) * 1000);
source.start(0); source.start(0);
console.log(`开始播放音频片段,时长: ${audioData.duration}`); console.log(`开始播放音频片段,时长: ${audioData.duration}`);
} catch (error) { } catch (error) {
console.error('播放音频失败:', error); console.error('播放音频失败:', error);
isPlaying = false; window.isPlaying = false;
resolve(); resolve();
} }
}); });
@ -162,10 +152,10 @@ function getQueueStatus() {
// 移除waitForCurrentAudioToFinish函数不再需要 // 移除waitForCurrentAudioToFinish函数不再需要
async function requestMinimaxi({ apiKey, groupId, body, stream = true , textPlay = false}) { async function requestMinimaxi({ apiKey, groupId, body, stream = true }) {
const url = `https://api.minimaxi.com/v1/t2a_v2`; const url = `https://api.minimaxi.com/v1/t2a_v2`;
const reqBody = { ...body, stream }; const reqBody = { ...body, stream };
isPlaying = textPlay
// 添加这两行变量定义 // 添加这两行变量定义
let isFirstChunk = true; let isFirstChunk = true;
// const currentText = body.text; // const currentText = body.text;
@ -232,8 +222,8 @@ async function requestMinimaxi({ apiKey, groupId, body, stream = true , textPlay
// 流式解析每个chunk实时播放音频 // 流式解析每个chunk实时播放音频
if (obj.data && obj.data.audio && obj.data.status === 1) { if (obj.data && obj.data.audio && obj.data.status === 1) {
console.log('收到音频数据片段!', obj.data.audio.length); console.log('收到音频数据片段!', obj.data.audio.length);
// audioHex += obj.data.audio; audioHex += obj.data.audio;
audioHex = obj.data.audio;
// const sayName = 'say-5s-m-sw' // const sayName = 'say-5s-m-sw'
// // 如果是第一个音频片段,触发视频切换 // // 如果是第一个音频片段,触发视频切换
// if (isFirstChunk && sayName != window.webrtcApp.currentVideoName && window.webrtcApp && window.webrtcApp.handleTextInput) { // if (isFirstChunk && sayName != window.webrtcApp.currentVideoName && window.webrtcApp && window.webrtcApp.handleTextInput) {
@ -254,7 +244,7 @@ async function requestMinimaxi({ apiKey, groupId, body, stream = true , textPlay
// const text = 'default' // const text = 'default'
// await window.webrtcApp.socket.emit('text-input', { text }); // await window.webrtcApp.socket.emit('text-input', { text });
// await window.webrtcApp.handleTextInput(text); // await window.webrtcApp.handleTextInput(text);
lastFullResult = null; lastFullResult = obj;
console.log('收到最终状态'); console.log('收到最终状态');
} }
} catch (e) { } catch (e) {
@ -271,7 +261,7 @@ async function requestMinimaxi({ apiKey, groupId, body, stream = true , textPlay
const obj = JSON.parse(line); const obj = JSON.parse(line);
if (obj.data && obj.data.audio) { if (obj.data && obj.data.audio) {
console.log('收到无data:音频数据!', obj.data.audio.length); console.log('收到无data:音频数据!', obj.data.audio.length);
audioHex = obj.data.audio; audioHex += obj.data.audio;
// 立即播放这个音频片段 // 立即播放这个音频片段
await playAudioChunk(obj.data.audio); await playAudioChunk(obj.data.audio);
@ -431,4 +421,4 @@ function generateUUID() {
}); });
} }
export { requestMinimaxi, requestVolcanTTS, addAudioToQueue }; export { requestMinimaxi, requestVolcanTTS };

346
src/new_app.js Normal file
View File

@ -0,0 +1,346 @@
let ASRTEXT = ''
class HttpASRRecognizer {
constructor() {
this.mediaRecorder = null;
this.audioContext = null;
this.isRecording = false;
this.audioChunks = [];
// VAD相关属性
this.isSpeaking = false;
this.silenceThreshold = 0.01;
this.silenceTimeout = 1000;
this.minSpeechDuration = 300;
this.silenceTimer = null;
this.speechStartTime = null;
this.audioBuffer = [];
// API配置
this.apiConfig = {
url: 'https://openspeech.bytedance.com/api/v3/auc/bigmodel/recognize/flash',
headers: {
'X-Api-App-Key': '1988591469',
'X-Api-Access-Key': 'mdEyhgZ59on1-NK3GXWAp3L4iLldSG0r',
'X-Api-Resource-Id': 'volc.bigasr.auc_turbo',
'X-Api-Request-Id': this.generateUUID(),
'X-Api-Sequence': '-1',
'Content-Type': 'application/json'
}
};
this.recordBtn = document.getElementById('startVoiceButton');
this.statusDiv = document.getElementById('status');
this.resultsDiv = document.getElementById('results');
this.initEventListeners();
}
initEventListeners() {
this.recordBtn.addEventListener('click', () => {
if (this.isRecording) {
this.stopRecording();
} else {
this.startRecording();
}
});
}
// 生成UUID
generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// 计算音频能量(音量)
calculateAudioLevel(audioData) {
let sum = 0;
for (let i = 0; i < audioData.length; i++) {
sum += audioData[i] * audioData[i];
}
return Math.sqrt(sum / audioData.length);
}
// 语音活动检测
detectVoiceActivity(audioData) {
const audioLevel = this.calculateAudioLevel(audioData);
const currentTime = Date.now();
if (audioLevel > this.silenceThreshold) {
if (!this.isSpeaking) {
this.isSpeaking = true;
this.speechStartTime = currentTime;
this.audioBuffer = [];
this.updateStatus('检测到语音,开始录音...', 'speaking');
console.log('开始说话');
}
if (this.silenceTimer) {
clearTimeout(this.silenceTimer);
this.silenceTimer = null;
}
return true;
} else {
if (this.isSpeaking && !this.silenceTimer) {
this.silenceTimer = setTimeout(() => {
this.onSpeechEnd();
}, this.silenceTimeout);
}
return this.isSpeaking;
}
}
// 语音结束处理
async onSpeechEnd() {
if (this.isSpeaking) {
const speechDuration = Date.now() - this.speechStartTime;
if (speechDuration >= this.minSpeechDuration) {
console.log(`语音结束,时长: ${speechDuration}ms`);
await this.processAudioBuffer();
// this.updateStatus('语音识别中...', 'processing');
console.log('语音识别中')
} else {
console.log('说话时长太短,忽略');
// this.updateStatus('等待语音输入...', 'ready');
console.log('等待语音输入...')
}
this.isSpeaking = false;
this.speechStartTime = null;
this.audioBuffer = [];
}
if (this.silenceTimer) {
clearTimeout(this.silenceTimer);
this.silenceTimer = null;
}
}
// 处理音频缓冲区并发送到API
async processAudioBuffer() {
if (this.audioBuffer.length === 0) {
return;
}
try {
// 合并所有音频数据
const totalLength = this.audioBuffer.reduce((sum, buffer) => sum + buffer.length, 0);
const combinedBuffer = new Float32Array(totalLength);
let offset = 0;
for (const buffer of this.audioBuffer) {
combinedBuffer.set(buffer, offset);
offset += buffer.length;
}
// 转换为WAV格式并编码为base64
const wavBuffer = this.encodeWAV(combinedBuffer, 16000);
const base64Audio = this.arrayBufferToBase64(wavBuffer);
// 调用ASR API
await this.callASRAPI(base64Audio);
} catch (error) {
console.error('处理音频数据失败:', error);
this.updateStatus('识别失败', 'error');
}
}
// 调用ASR API
async callASRAPI(base64AudioData) {
try {
const requestBody = {
user: {
uid: "1988591469"
},
audio: {
data: base64AudioData
},
request: {
model_name: "bigmodel"
}
};
const response = await fetch(this.apiConfig.url, {
method: 'POST',
headers: this.apiConfig.headers,
body: JSON.stringify(requestBody)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
this.handleASRResponse(result);
} catch (error) {
console.error('ASR API调用失败:', error);
this.updateStatus('API调用失败', 'error');
}
}
// 处理ASR响应
handleASRResponse(response) {
console.log('ASR响应:', response);
if (response && response.data && response.data.result) {
ASRTEXT = response.data.result;
// this.displayResult(text);
// this.updateStatus('识别完成', 'completed');
console.log('识别完成')
} else {
console.log('未识别到文字');
// this.updateStatus('未识别到文字', 'ready');
}
}
// 显示识别结果
displayResult(text) {
const resultElement = document.createElement('div');
resultElement.className = 'result-item';
resultElement.innerHTML = `
<span class="timestamp">${new Date().toLocaleTimeString()}</span>
<span class="text">${text}</span>
`;
this.resultsDiv.appendChild(resultElement);
this.resultsDiv.scrollTop = this.resultsDiv.scrollHeight;
}
// 更新状态显示
updateStatus(message, status) {
this.statusDiv.textContent = message;
this.statusDiv.className = `status ${status}`;
}
// 编码WAV格式
encodeWAV(samples, sampleRate) {
const length = samples.length;
const buffer = new ArrayBuffer(44 + length * 2);
const view = new DataView(buffer);
// WAV文件头
const writeString = (offset, string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
writeString(0, 'RIFF');
view.setUint32(4, 36 + length * 2, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeString(36, 'data');
view.setUint32(40, length * 2, true);
// 写入音频数据
let offset = 44;
for (let i = 0; i < length; i++) {
const sample = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(offset, sample * 0x7FFF, true);
offset += 2;
}
return buffer;
}
// ArrayBuffer转Base64
arrayBufferToBase64(buffer) {
let binary = '';
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
async startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true
}
});
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 16000
});
const source = this.audioContext.createMediaStreamSource(stream);
const processor = this.audioContext.createScriptProcessor(4096, 1, 1);
processor.onaudioprocess = (event) => {
const inputBuffer = event.inputBuffer;
const inputData = inputBuffer.getChannelData(0);
// 语音活动检测
if (this.detectVoiceActivity(inputData)) {
// 如果检测到语音活动,缓存音频数据
this.audioBuffer.push(new Float32Array(inputData));
}
};
source.connect(processor);
processor.connect(this.audioContext.destination);
this.isRecording = true;
this.recordBtn.textContent = '停止录音';
this.recordBtn.className = 'btn recording';
// this.updateStatus('等待语音输入...', 'ready');
} catch (error) {
console.error('启动录音失败:', error);
// this.updateStatus('录音启动失败', 'error');
}
}
stopRecording() {
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
if (this.silenceTimer) {
clearTimeout(this.silenceTimer);
this.silenceTimer = null;
}
// 如果正在说话,处理最后的音频
if (this.isSpeaking) {
this.onSpeechEnd();
}
this.isRecording = false;
this.isSpeaking = false;
this.audioBuffer = [];
this.recordBtn.textContent = '开始录音';
this.recordBtn.className = 'btn';
console.log('录音已停止');
// this.updateStatus('录音已停止', 'stopped');
}
}
// 初始化应用
document.addEventListener('DOMContentLoaded', () => {
const asrRecognizer = new HttpASRRecognizer();
console.log('HTTP ASR识别器已初始化');
});

View File

@ -101,14 +101,6 @@ header p {
.recorded-video-section { .recorded-video-section {
margin-bottom: 30px; margin-bottom: 30px;
text-align: center; text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
/* 确保视频区域固定高度并居中 */
min-height: 100vh;
max-height: 100vh;
width: 100%;
} }
.recorded-video-section h3 { .recorded-video-section h3 {
@ -117,22 +109,14 @@ header p {
} }
#recordedVideo { #recordedVideo {
max-width: 100%;
max-height: 100%;
width: 100%; width: 100%;
height: 100%; max-width: 400px; /* 限制最大宽度 */
border-radius: 0; aspect-ratio: 9/16; /* 固定9:16比例 */
box-shadow: none; border-radius: 10px;
object-fit: cover; /* 覆盖整个容器 */ box-shadow: 0 5px 15px rgba(0,0,0,0.2);
background: transparent; /* 透明背景 */ object-fit: cover; /* 确保视频填充容器 */
background: #000; /* 视频背景色 */
transition: opacity 0.15s; /* 添加透明度过渡效果 */ transition: opacity 0.15s; /* 添加透明度过渡效果 */
margin: 0 auto; /* 左右居中 */
display: block; /* 确保块级显示 */
/* 确保视频始终居中 */
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
} }
/* 视频加载时的样式 */ /* 视频加载时的样式 */
@ -439,50 +423,6 @@ header p {
.video-list { .video-list {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
/* 移动端视频容器优化 */
.video-container {
height: 100vh;
width: 100vw;
}
#recordedVideo {
width: 100%;
height: 100%;
object-fit: cover;
}
}
/* 桌面端视频容器优化 */
@media (min-width: 769px) {
.video-container {
height: 100vh;
width: 100vw;
}
#recordedVideo {
width: 100%;
height: 100%;
object-fit: cover;
}
}
/* 横屏模式优化 */
@media (orientation: landscape) and (max-height: 500px) {
.video-container {
height: 100vh;
}
.controls {
bottom: 20px;
}
}
/* 竖屏模式优化 */
@media (orientation: portrait) {
.video-container {
height: 100vh;
}
} }
/* 动画效果 */ /* 动画效果 */
@ -508,22 +448,42 @@ header p {
} }
#recordedVideo { #recordedVideo {
transition: opacity 0.1s ease-in-out; /* 缩短过渡时间 */ transition: opacity 0.2s ease-in-out;
background-color: #1a1a1a; /* 深灰色背景,避免纯黑 */ background-color: #1a1a1a; /* 深灰色背景,避免纯黑 */
} }
#recordedVideo.loading { #recordedVideo.loading {
opacity: 0.9; /* 提高loading时的透明度减少黑屏感 */ opacity: 0.8; /* 加载时稍微降低透明度,但不完全隐藏 */
} }
#recordedVideo.playing { #recordedVideo.playing {
opacity: 1; opacity: 1;
} }
/* 优化加载指示器 */ /* 添加加载指示器 */
.video-container {
position: relative;
}
.video-container::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
border: 3px solid #333;
border-top: 3px solid #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
opacity: 0;
z-index: 10;
transition: opacity 0.3s;
}
.video-container.loading::before { .video-container.loading::before {
opacity: 0.8; /* 降低加载指示器的透明度 */ opacity: 1;
border-top-color: #667eea; /* 使用主题色 */
} }
@keyframes spin { @keyframes spin {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 MiB

44
src/video_audio_sync.js Normal file
View File

@ -0,0 +1,44 @@
import { requestMinimaxi } from './minimaxi_stream.js';
import { getMinimaxiConfig } from './config.js';
export async function playVideoWithAudio(videoPath, text) {
// 1. 初始化视频播放
const video = document.createElement('video');
video.src = videoPath;
document.body.appendChild(video);
// 2. 启动音频合成流
const minimaxiConfig = getMinimaxiConfig();
const audioStream = await requestMinimaxi({
apiKey: minimaxiConfig.apiKey,
groupId: minimaxiConfig.groupId,
body: {
model: 'speech-02-hd',
text,
output_format: 'hex', // 流式场景必须使用hex
voice_setting: {
voice_id: 'yantu-qinggang',
speed: 1
}
},
stream: true
});
// 3. 将音频hex转换为可播放格式
const audioCtx = new AudioContext();
const audioBuffer = await audioCtx.decodeAudioData(
hexToArrayBuffer(audioStream.data.audio)
);
// 4. 同步播放
const source = audioCtx.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioCtx.destination);
video.play();
source.start(0);
}
function hexToArrayBuffer(hex) {
// ... hex转ArrayBuffer实现
}

BIN
videos/0.mp4 Normal file

Binary file not shown.

BIN
videos/1-m.mp4 Normal file

Binary file not shown.

BIN
videos/2.mp4 Normal file

Binary file not shown.

BIN
videos/4-m.mp4 Normal file

Binary file not shown.

BIN
videos/5.mp4 Normal file

Binary file not shown.

BIN
videos/6.mp4 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
videos/d-3s.mp4 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
videos/s-1.mp4 Normal file

Binary file not shown.