This commit is contained in:
zpc 2025-03-28 01:38:44 +08:00
parent f761157a1c
commit 067b3d11bc
6 changed files with 552 additions and 402 deletions

View File

@ -73,35 +73,23 @@ namespace ShengShengBuXi.Hubs
/// <summary>
/// 监控文本队列的持久化文件路径
/// </summary>
private static readonly string _monitorTextQueueFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "monitor_text_queue.json");
// 预设句子
private static readonly string[] _presetSentences = new string[]
{
"记得每到夏天傍晚,您就摇着蒲扇坐在藤椅里,把切好的西瓜最甜那块硬塞给我,自己却啃着靠近皮的白瓤,还笑着说:外公就爱这口,清爽。",
"女儿啊 花开了 你否到了吗?",
"外公,你在那边过的还好么大家都很想称,记得常回家看看",
"外公,今天窗台上的茉莉开了,白盈盈的,就像以前您总别在中山装口袋上的那朵。",
"外公,巷口那家老茶馆拆了,您最爱坐的靠窗位置再也找不到了,就像再也找不到您一样。",
"整理旧物时发现您用红绳缠好的象棋,每一处磨损都藏着您教我'马走日'时手心的温度。",
"今早闻到槐花香突然站住——您总说这味道像极了老家后山的夏天,现在我才懂得什么叫'睹物思人'。",
"菜场看见卖菱角的老伯,想起您总把最嫩的剥好放我碗里,自己却嚼着发苦的老根。",
"暴雨天膝盖又疼了吧?记得您总在这时候熬姜汤,说'老寒腿最懂天气预报'。",
"您养的那盆君子兰今年抽了七支花箭,比您走那年还多两枝,定是替您来看我的。",
"小满那天不自觉煮了两人份的腊肉饭,盛完才想起再没人把肥肉挑走给我留精瘦的。",
"儿童节路过小学,梧桐树下空荡荡的——再没有举着冰糖葫芦等我的驼背身影了。",
"冬至包饺子时手一抖,捏出您教的麦穗花边,滚水冒的蒸汽突然就迷了眼睛。",
"昨夜梦见您穿着洗白的蓝布衫,在晒谷场对我笑,醒来枕巾湿了半边。",
"蝉鸣最响的午后,恍惚听见竹椅吱呀声,转头却只看见墙上相框里的您。",
"您走后,再没人把枇杷核仔细包进手帕,笑着说'留着明年给囡囡种'。",
"整理遗物时数出38张火车票全是往返我读书城市的票根都磨出了毛边。",
"清明雨把墓碑冲洗得发亮,就像您当年总把搪瓷缸擦得能照见人影。",
"今天教女儿念'慈母手中线',她突然问:'太外公的诗集能读给我听吗?'",
"翻到您留下的老照片,那件洗得发白的中山装上还别着茉莉花,仿佛还能闻到淡淡的清香。",
"傍晚的蝉鸣声里,总错觉能听到您哼着那首走了调的小曲,在巷子口唤我回家吃饭。",
"您种的葡萄藤今年结了好多串,可再没有人像您那样,把最紫的摘下来悄悄塞进我口袋。",
"下雨天膝盖隐隐作痛时,总会想起您泡的姜茶,热气氤氲中您笑着说'老了才知道疼'。",
"整理书房时,发现您用毛笔在旧日历背面写的家训,墨迹晕染处都是您颤抖的手印。"
};
private static readonly string _monitorTextQueueFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config/monitor_text_queue.json");
/// <summary>
/// 预设句子文件路径
/// </summary>
private static readonly string _sentencesFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config/sentences.txt");
/// <summary>
/// 预设句子列表
/// </summary>
private static List<string> _presetSentences = new List<string>();
/// <summary>
/// 已显示过的预设句子队列及显示时间,用于防止短时间内重复显示
/// </summary>
private static readonly Queue<(string Text, DateTime DisplayTime)> _recentlyDisplayedSentences = new Queue<(string, DateTime)>();
/// <summary>
/// 防止重复显示的句子数量
/// </summary>
private static readonly int _sentenceHistoryLimit = 20;
/// <summary>
/// 初始化音频Hub
@ -136,6 +124,9 @@ namespace ShengShengBuXi.Hubs
_cleanupTimer = new Timer(CleanupOldProcessedPaths, null, TimeSpan.FromMinutes(30), TimeSpan.FromMinutes(30));
}
// 从文件加载预设句子
LoadPresetSentencesFromFile();
// 从文件加载监控文本队列
LoadMonitorTextQueueFromFile();
@ -552,7 +543,7 @@ namespace ShengShengBuXi.Hubs
/// <returns>显示文本列表</returns>
public async Task<List<DisplayText>> GetDisplayList()
{
var result = _displayTextQueue.Select(kvp => kvp.Value).Where(it=>it.IsRealUser).OrderBy(it => it.Timestamp).ToList();
var result = _displayTextQueue.Select(kvp => kvp.Value).Where(it => it.IsRealUser).OrderBy(it => it.Timestamp).ToList();
// 转换文件路径为URL路径
foreach (var item in result)
@ -815,278 +806,117 @@ namespace ShengShengBuXi.Hubs
/// </summary>
private void AddFakeTextToQueue()
{
// 从预设句子中随机选择一条
string randomText = _presetSentences[new Random().Next(_presetSentences.Length)];
try
{
// 确保预设句子列表不为空
if (_presetSentences.Count == 0)
{
LoadPresetSentencesFromFile();
// 如果加载后仍然为空,使用默认句子
if (_presetSentences.Count == 0)
{
_presetSentences.Add("记得每到夏天傍晚,您就摇着蒲扇坐在藤椅里,把切好的西瓜最甜那块硬塞给我。");
_presetSentences.Add("时光匆匆流逝,思念却越来越深。");
}
}
// 清理超过24小时的历史记录
while (_recentlyDisplayedSentences.Count > 0 && DateTime.Now.Subtract(_recentlyDisplayedSentences.Peek().DisplayTime).TotalHours > 24)
{
_recentlyDisplayedSentences.Dequeue();
}
// 获取当前已显示过的句子集合
var recentTexts = _recentlyDisplayedSentences.Select(item => item.Text).ToHashSet();
// 筛选出未最近显示过的句子
var availableSentences = _presetSentences.Where(s => !recentTexts.Contains(s)).ToList();
// 如果可用句子为空(全部句子都显示过了),则使用全部句子但优先选择最早显示过的
if (availableSentences.Count == 0)
{
_logger.LogInformation("所有预设句子都已最近显示过,将选择最早显示的句子");
// 从队列中获取并移除最早显示的句子
var oldestSentence = _recentlyDisplayedSentences.Dequeue().Text;
AddRecognizedTextToDisplay(oldestSentence, false);
// 将这个句子重新添加到队列末尾,记录当前时间
_recentlyDisplayedSentences.Enqueue((oldestSentence, DateTime.Now));
}
else
{
// 从未最近显示过的句子中随机选择一条
string randomText = availableSentences[new Random().Next(availableSentences.Count)];
AddRecognizedTextToDisplay(randomText, false);
}
/// <summary>
/// 大屏客户端请求开始接收显示文本
/// </summary>
/// <returns>处理任务</returns>
public async Task StartReceivingDisplayText()
// 添加到已显示队列
_recentlyDisplayedSentences.Enqueue((randomText, DateTime.Now));
// 如果队列超出限制,移除最早的记录
while (_recentlyDisplayedSentences.Count > _sentenceHistoryLimit)
{
if (!_clients.TryGetValue(Context.ConnectionId, out var clientInfo))
{
_logger.LogWarning($"未注册的客户端尝试接收显示文本: {Context.ConnectionId}");
await Clients.Caller.SendAsync("Error", "请先注册客户端");
return;
}
if (clientInfo.ClientType != ClientType.Display)
{
_logger.LogWarning($"非显示端客户端尝试接收显示文本: {Context.ConnectionId}");
await Clients.Caller.SendAsync("Error", "只有显示端可以接收显示文本");
return;
}
_logger.LogInformation($"显示端开始接收文本: {Context.ConnectionId}");
// 确保定时器已初始化
InitializeDisplayTextTimer();
// 如果队列为空,添加一条初始文本
if (_displayTextQueue.IsEmpty)
{
AddFakeTextToQueue();
_recentlyDisplayedSentences.Dequeue();
}
}
/// <summary>
/// 音频文件保存事件处理
/// </summary>
/// <param name="sender">发送者</param>
/// <param name="e">文件路径</param>
private async void OnAudioSavedToFile(object sender, string e)
{
try
{
// 快速检查路径是否已处理过,如果已处理过则直接返回
if (string.IsNullOrEmpty(e) || _globalProcessedPaths.ContainsKey(e))
{
return;
}
// 记录处理时间
_globalProcessedPaths[e] = DateTime.Now;
_logger.LogInformation($"音频保存到文件: {e}");
// 设置语音识别服务的录音文件路径
if (sender is IAudioProcessingService audioService)
{
// 查找对应的会话ID
var clientId = _clients.Keys.FirstOrDefault(id => audioService.GetRecordingFilePath(id) == e);
if (!string.IsNullOrEmpty(clientId))
{
// 生成会话文件路径的唯一标识符
string key = $"{clientId}:{e}";
// 添加到本地实例缓存 - 为双重保险
if (!_processedRecordingPaths.Contains(key))
{
_processedRecordingPaths.Add(key);
// 设置语音识别服务的录音文件路径
if (_speechToTextService is SpeechToTextService speechService)
{
_logger.LogInformation($"设置会话 {clientId} 的录音文件路径: {e}");
speechService.SetSessionRecordingPath(clientId, e);
}
}
}
}
// 使用_hubContext替代Clients
await _hubContext.Clients.Group("webadmin").SendAsync("AudioSavedToFile", e);
_logger.LogInformation($"当前已显示过的句子数量: {_recentlyDisplayedSentences.Count}/{_sentenceHistoryLimit}");
}
catch (Exception ex)
{
_logger.LogError($"处理音频保存事件时出错: {ex.Message}");
_logger.LogError($"添加预设文本失败: {ex.Message}");
// 使用一个默认句子防止程序崩溃
AddRecognizedTextToDisplay("时光匆匆流逝,思念却越来越深。", false);
}
}
/// <summary>
/// 配置变更事件处理
/// 从文件加载预设句子
/// </summary>
/// <param name="sender">发送者</param>
/// <param name="e">配置</param>
private async void OnConfigurationChanged(object sender, PhoneBoothConfig e)
private void LoadPresetSentencesFromFile()
{
try
{
_logger.LogInformation("配置已更新");
_presetSentences.Clear();
// 使用_hubContext替代Clients
await _hubContext.Clients.Group("controllers").SendAsync("ReceiveConfiguration", e);
await _hubContext.Clients.Group("webadmin").SendAsync("ReceiveConfiguration", e);
// 检查文件是否存在
if (!File.Exists(_sentencesFilePath))
{
_logger.LogWarning($"预设句子文件不存在: {_sentencesFilePath},将创建默认文件");
// 创建目录(如果不存在)
Directory.CreateDirectory(Path.GetDirectoryName(_sentencesFilePath));
// 写入默认的预设句子
File.WriteAllLines(_sentencesFilePath, new string[] {
"记得每到夏天傍晚,您就摇着蒲扇坐在藤椅里,把切好的西瓜最甜那块硬塞给我,自己却啃着靠近皮的白瓤,还笑着说:外公就爱这口,清爽。",
"女儿啊 花开了 你否到了吗?",
"外公,你在那边过的还好么大家都很想称,记得常回家看看",
"外公,今天窗台上的茉莉开了,白盈盈的,就像以前您总别在中山装口袋上的那朵。",
"外公,巷口那家老茶馆拆了,您最爱坐的靠窗位置再也找不到了,就像再也找不到您一样。"
});
}
// 读取文件中的每一行作为一个预设句子
string[] lines = File.ReadAllLines(_sentencesFilePath);
foreach (string line in lines)
{
// 忽略空行
if (!string.IsNullOrWhiteSpace(line))
{
_presetSentences.Add(line.Trim());
}
}
_presetSentences = _presetSentences.OrderBy(x => Guid.NewGuid()).ToList();
_logger.LogInformation($"成功从文件加载预设句子: {_presetSentences.Count} 条");
}
catch (Exception ex)
{
_logger.LogError($"处理配置变更事件时出错: {ex.Message}");
_logger.LogError($"加载预设句子失败: {ex.Message}");
}
}
/// <summary>
/// 开始监听音频
/// </summary>
/// <returns>处理任务</returns>
public async Task StartMonitoringAudio()
{
if (!_clients.TryGetValue(Context.ConnectionId, out var clientInfo))
{
_logger.LogWarning($"未注册的客户端尝试开始监听音频: {Context.ConnectionId}");
await Clients.Caller.SendAsync("Error", "请先注册客户端");
return;
}
if (clientInfo.ClientType != ClientType.Monitor && clientInfo.ClientType != ClientType.WebAdmin)
{
_logger.LogWarning($"非显示端或管理端客户端尝试开始监听音频: {Context.ConnectionId}, 类型: {clientInfo.ClientType}");
await Clients.Caller.SendAsync("Error", "只有显示端或管理端客户端可以监听音频");
return;
}
_logger.LogInformation($"开始监听音频: {Context.ConnectionId}");
// 将客户端标记为监听状态
clientInfo.IsMonitoring = true;
_clients[Context.ConnectionId] = clientInfo;
// 如果有正在进行的通话,通知客户端
var activeControllers = _clients.Values.Where(c => c.ClientType == ClientType.Controller && c.IsAudioStreaming).ToList();
if (activeControllers.Any())
{
await Clients.Caller.SendAsync("CallStateChanged", true);
}
else
{
await Clients.Caller.SendAsync("CallStateChanged", false);
}
}
/// <summary>
/// 停止监听音频
/// </summary>
/// <returns>处理任务</returns>
public async Task StopMonitoringAudio()
{
if (!_clients.TryGetValue(Context.ConnectionId, out var clientInfo))
{
_logger.LogWarning($"未注册的客户端尝试停止监听音频: {Context.ConnectionId}");
return;
}
_logger.LogInformation($"停止监听音频: {Context.ConnectionId}");
// 将客户端标记为非监听状态
clientInfo.IsMonitoring = false;
_clients[Context.ConnectionId] = clientInfo;
await Clients.Caller.SendAsync("CallStateChanged", false);
}
/// <summary>
/// 从监控端发送消息
/// </summary>
/// <param name="sender">发送者姓名</param>
/// <param name="message">消息内容</param>
/// <returns>处理任务</returns>
public async Task SendMonitorMessage(string sender, string message)
{
if (!_clients.TryGetValue(Context.ConnectionId, out var clientInfo))
{
_logger.LogWarning($"未注册的客户端尝试发送消息: {Context.ConnectionId}");
await Clients.Caller.SendAsync("Error", "请先注册客户端");
return;
}
if (clientInfo.ClientType != ClientType.Monitor && clientInfo.ClientType != ClientType.WebAdmin)
{
_logger.LogWarning($"非显示端或管理端客户端尝试发送消息: {Context.ConnectionId}, 类型: {clientInfo.ClientType}");
await Clients.Caller.SendAsync("Error", "只有显示端或管理端可以发送消息");
return;
}
_logger.LogInformation($"发送消息: {Context.ConnectionId}, 发送者: {sender}, 内容: {message}");
// 广播消息给所有显示端和管理端
await Clients.Groups(new[] { "displays", "webadmin", "monitor" }).SendAsync("ReceiveMessage", sender, message);
}
/// <summary>
/// 更新显示模式类型
/// </summary>
/// <param name="displayType">显示类型0表示识别立即显示1表示手动显示</param>
/// <returns>处理任务</returns>
public async Task UpdateDisplayType(int displayType)
{
if (!_clients.TryGetValue(Context.ConnectionId, out var clientInfo))
{
_logger.LogWarning($"未注册的客户端尝试更新显示模式: {Context.ConnectionId}");
await Clients.Caller.SendAsync("Error", "请先注册客户端");
return;
}
if (clientInfo.ClientType != ClientType.Monitor && clientInfo.ClientType != ClientType.WebAdmin)
{
_logger.LogWarning($"非监控或管理端客户端尝试更新显示模式: {Context.ConnectionId}, 类型: {clientInfo.ClientType}");
await Clients.Caller.SendAsync("Error", "只有监控或管理端客户端可以更新显示模式");
return;
}
if (displayType < 0 || displayType > 2)
{
_logger.LogWarning($"客户端尝试设置无效的显示模式: {Context.ConnectionId}, 模式: {displayType}");
await Clients.Caller.SendAsync("Error", "无效的显示模式值");
return;
}
_logger.LogInformation($"更新显示模式: {Context.ConnectionId}, 新模式: {displayType}");
// 更新配置
_configurationService.CurrentConfig.DisplayType = displayType;
// 通知其他客户端显示模式已更改
await Clients.Groups(new[] { "monitor", "webadmin" }).SendAsync("DisplayTypeChanged", displayType);
// 保存配置到文件
try
{
var success = _configurationService.SaveConfiguration();
if (success)
{
await Clients.Caller.SendAsync("DisplayTypeUpdated", true, $"显示模式已更新为: {displayType}");
}
else
{
_logger.LogError("保存配置失败");
await Clients.Caller.SendAsync("DisplayTypeUpdated", false, "更新显示模式成功,但保存配置失败");
}
}
catch (Exception ex)
{
_logger.LogError($"保存配置到文件时出错: {ex.Message}");
await Clients.Caller.SendAsync("DisplayTypeUpdated", false, "更新显示模式成功,但保存配置失败");
}
}
/// <summary>
/// 获取当前显示模式
/// </summary>
/// <returns>当前显示模式</returns>
public async Task<int> GetDisplayType()
{
if (!_clients.TryGetValue(Context.ConnectionId, out var clientInfo))
{
_logger.LogWarning($"未注册的客户端尝试获取显示模式: {Context.ConnectionId}");
throw new HubException("请先注册客户端");
}
_logger.LogInformation($"客户端获取当前显示模式: {Context.ConnectionId}, 类型: {clientInfo.ClientType}");
return _configurationService.CurrentConfig.DisplayType;
}
/// <summary>
/// 从文件加载监控文本队列
/// </summary>
@ -1096,8 +926,9 @@ namespace ShengShengBuXi.Hubs
{
if (!File.Exists(_monitorTextQueueFilePath))
{
_logger.LogInformation($"监控文本队列文件不存在: {_monitorTextQueueFilePath}");
return;
File.Create(_monitorTextQueueFilePath).Close();
_logger.LogInformation($"监控文本队列文件不存在: {_monitorTextQueueFilePath},手动创建");
//return;
}
string json = File.ReadAllText(_monitorTextQueueFilePath);
@ -1261,5 +1092,240 @@ namespace ShengShengBuXi.Hubs
base.Dispose();
GC.SuppressFinalize(this);
}
/// <summary>
/// 配置变更事件处理
/// </summary>
/// <param name="sender">发送者</param>
/// <param name="e">配置</param>
private async void OnConfigurationChanged(object sender, PhoneBoothConfig e)
{
try
{
_logger.LogInformation("配置已更新");
// 使用_hubContext替代Clients
await _hubContext.Clients.Group("controllers").SendAsync("ReceiveConfiguration", e);
await _hubContext.Clients.Group("webadmin").SendAsync("ReceiveConfiguration", e);
}
catch (Exception ex)
{
_logger.LogError($"处理配置变更事件时出错: {ex.Message}");
}
}
/// <summary>
/// 音频文件保存事件处理
/// </summary>
/// <param name="sender">发送者</param>
/// <param name="e">文件路径</param>
private async void OnAudioSavedToFile(object sender, string e)
{
try
{
// 快速检查路径是否已处理过,如果已处理过则直接返回
if (string.IsNullOrEmpty(e) || _globalProcessedPaths.ContainsKey(e))
{
return;
}
// 记录处理时间
_globalProcessedPaths[e] = DateTime.Now;
_logger.LogInformation($"音频保存到文件: {e}");
// 设置语音识别服务的录音文件路径
if (sender is IAudioProcessingService audioService)
{
// 查找对应的会话ID
var clientId = _clients.Keys.FirstOrDefault(id => audioService.GetRecordingFilePath(id) == e);
if (!string.IsNullOrEmpty(clientId))
{
// 生成会话文件路径的唯一标识符
string key = $"{clientId}:{e}";
// 添加到本地实例缓存 - 为双重保险
if (!_processedRecordingPaths.Contains(key))
{
_processedRecordingPaths.Add(key);
// 设置语音识别服务的录音文件路径
if (_speechToTextService is SpeechToTextService speechService)
{
_logger.LogInformation($"设置会话 {clientId} 的录音文件路径: {e}");
speechService.SetSessionRecordingPath(clientId, e);
}
}
}
}
// 使用_hubContext替代Clients
await _hubContext.Clients.Group("webadmin").SendAsync("AudioSavedToFile", e);
}
catch (Exception ex)
{
_logger.LogError($"处理音频保存事件时出错: {ex.Message}");
}
}
/// <summary>
/// 开始监听音频
/// </summary>
/// <returns>处理任务</returns>
public async Task StartMonitoringAudio()
{
if (!_clients.TryGetValue(Context.ConnectionId, out var clientInfo))
{
_logger.LogWarning($"未注册的客户端尝试开始监听音频: {Context.ConnectionId}");
await Clients.Caller.SendAsync("Error", "请先注册客户端");
return;
}
if (clientInfo.ClientType != ClientType.Monitor && clientInfo.ClientType != ClientType.WebAdmin)
{
_logger.LogWarning($"非显示端或管理端客户端尝试开始监听音频: {Context.ConnectionId}, 类型: {clientInfo.ClientType}");
await Clients.Caller.SendAsync("Error", "只有显示端或管理端客户端可以监听音频");
return;
}
_logger.LogInformation($"开始监听音频: {Context.ConnectionId}");
// 将客户端标记为监听状态
clientInfo.IsMonitoring = true;
_clients[Context.ConnectionId] = clientInfo;
// 如果有正在进行的通话,通知客户端
var activeControllers = _clients.Values.Where(c => c.ClientType == ClientType.Controller && c.IsAudioStreaming).ToList();
if (activeControllers.Any())
{
await Clients.Caller.SendAsync("CallStateChanged", true);
}
else
{
await Clients.Caller.SendAsync("CallStateChanged", false);
}
}
/// <summary>
/// 停止监听音频
/// </summary>
/// <returns>处理任务</returns>
public async Task StopMonitoringAudio()
{
if (!_clients.TryGetValue(Context.ConnectionId, out var clientInfo))
{
_logger.LogWarning($"未注册的客户端尝试停止监听音频: {Context.ConnectionId}");
return;
}
_logger.LogInformation($"停止监听音频: {Context.ConnectionId}");
// 将客户端标记为非监听状态
clientInfo.IsMonitoring = false;
_clients[Context.ConnectionId] = clientInfo;
await Clients.Caller.SendAsync("CallStateChanged", false);
}
/// <summary>
/// 从监控端发送消息
/// </summary>
/// <param name="sender">发送者姓名</param>
/// <param name="message">消息内容</param>
/// <returns>处理任务</returns>
public async Task SendMonitorMessage(string sender, string message)
{
if (!_clients.TryGetValue(Context.ConnectionId, out var clientInfo))
{
_logger.LogWarning($"未注册的客户端尝试发送消息: {Context.ConnectionId}");
await Clients.Caller.SendAsync("Error", "请先注册客户端");
return;
}
if (clientInfo.ClientType != ClientType.Monitor && clientInfo.ClientType != ClientType.WebAdmin)
{
_logger.LogWarning($"非显示端或管理端客户端尝试发送消息: {Context.ConnectionId}, 类型: {clientInfo.ClientType}");
await Clients.Caller.SendAsync("Error", "只有显示端或管理端可以发送消息");
return;
}
_logger.LogInformation($"发送消息: {Context.ConnectionId}, 发送者: {sender}, 内容: {message}");
// 广播消息给所有显示端和管理端
await Clients.Groups(new[] { "displays", "webadmin", "monitor" }).SendAsync("ReceiveMessage", sender, message);
}
/// <summary>
/// 更新显示模式类型
/// </summary>
/// <param name="displayType">显示类型0表示识别立即显示1表示手动显示</param>
/// <returns>处理任务</returns>
public async Task UpdateDisplayType(int displayType)
{
if (!_clients.TryGetValue(Context.ConnectionId, out var clientInfo))
{
_logger.LogWarning($"未注册的客户端尝试更新显示模式: {Context.ConnectionId}");
await Clients.Caller.SendAsync("Error", "请先注册客户端");
return;
}
if (clientInfo.ClientType != ClientType.Monitor && clientInfo.ClientType != ClientType.WebAdmin)
{
_logger.LogWarning($"非监控或管理端客户端尝试更新显示模式: {Context.ConnectionId}, 类型: {clientInfo.ClientType}");
await Clients.Caller.SendAsync("Error", "只有监控或管理端客户端可以更新显示模式");
return;
}
if (displayType < 0 || displayType > 2)
{
_logger.LogWarning($"客户端尝试设置无效的显示模式: {Context.ConnectionId}, 模式: {displayType}");
await Clients.Caller.SendAsync("Error", "无效的显示模式值");
return;
}
_logger.LogInformation($"更新显示模式: {Context.ConnectionId}, 新模式: {displayType}");
// 更新配置
_configurationService.CurrentConfig.DisplayType = displayType;
// 通知其他客户端显示模式已更改
await Clients.Groups(new[] { "monitor", "webadmin" }).SendAsync("DisplayTypeChanged", displayType);
// 保存配置到文件
try
{
var success = _configurationService.SaveConfiguration();
if (success)
{
await Clients.Caller.SendAsync("DisplayTypeUpdated", true, $"显示模式已更新为: {displayType}");
}
else
{
_logger.LogError("保存配置失败");
await Clients.Caller.SendAsync("DisplayTypeUpdated", false, "更新显示模式成功,但保存配置失败");
}
}
catch (Exception ex)
{
_logger.LogError($"保存配置到文件时出错: {ex.Message}");
await Clients.Caller.SendAsync("DisplayTypeUpdated", false, "更新显示模式成功,但保存配置失败");
}
}
/// <summary>
/// 获取当前显示模式
/// </summary>
/// <returns>当前显示模式</returns>
public async Task<int> GetDisplayType()
{
if (!_clients.TryGetValue(Context.ConnectionId, out var clientInfo))
{
_logger.LogWarning($"未注册的客户端尝试获取显示模式: {Context.ConnectionId}");
throw new HubException("请先注册客户端");
}
_logger.LogInformation($"客户端获取当前显示模式: {Context.ConnectionId}, 类型: {clientInfo.ClientType}");
return _configurationService.CurrentConfig.DisplayType;
}
}
}

View File

@ -131,7 +131,7 @@
const CONFIG = {
// 左侧容器配置
leftContainer: {
turnPageHeight: 0.7, // 左侧容器翻页的高度比例
turnPageHeight: 0.55, // 左侧容器翻页的高度比例
fontSize: '16px', // 左侧列表文字大小
typewriterSpeed: 50 // 左侧文字打字机速度(毫秒)
},
@ -140,41 +140,24 @@
fontSize: '40px', // 右侧文字大小
fontWeight: '700', // 右侧文字粗细
fontStyle: 'italic', // 右侧文字样式
typewriterSpeed: 50 // 右侧文字打字机速度(毫秒)
typewriterSpeed: 250 // 右侧文字打字机速度(毫秒)减慢到1/5
},
// 水波纹效果配置
waterEffect: {
enabled: true, // 是否开启水波纹
minInterval: 800, // 最小触发间隔(毫秒)
maxInterval: 5000, // 最大触发间隔(毫秒)
simultaneousDrops: 3 // 同时触发的波纹数量
minInterval: 1600, // 最小触发间隔(毫秒),增加时间
maxInterval: 8000, // 最大触发间隔(毫秒),增加时间
simultaneousDrops: 2, // 同时触发的波纹数量减少到2个
fadeOutSpeed: 2000, // 文字渐隐效果的速度(毫秒)原速度的2倍
centerBias: 0.6, // 涟漪靠近中心区域的偏好值(0-1)0为随机1为只在中心区域
largeDrop: {
probability: 0.2, // 大涟漪出现的概率降低到0.2
size: 80 // 大涟漪的大小
}
},
// 预设的中文句子数组
sentences: [
"记得每到夏天傍晚,您就摇着蒲扇坐在藤椅里,把切好的西瓜最甜那块硬塞给我,自己却啃着靠近皮的白瓤,还笑着说:外公就爱这口,清爽。",
"女儿啊 花开了 你否到了吗?",
"外公,你在那边过的还好么大家都很想称,记得常回家看看",
"外公,今天窗台上的茉莉开了,白盈盈的,就像以前您总别在中山装口袋上的那朵。",
"外公,巷口那家老茶馆拆了,您最爱坐的靠窗位置再也找不到了,就像再也找不到您一样。",
"整理旧物时发现您用红绳缠好的象棋,每一处磨损都藏着您教我'马走日'时手心的温度。",
"今早闻到槐花香突然站住——您总说这味道像极了老家后山的夏天,现在我才懂得什么叫'睹物思人'。",
"菜场看见卖菱角的老伯,想起您总把最嫩的剥好放我碗里,自己却嚼着发苦的老根。",
"暴雨天膝盖又疼了吧?记得您总在这时候熬姜汤,说'老寒腿最懂天气预报'。",
"您养的那盆君子兰今年抽了七支花箭,比您走那年还多两枝,定是替您来看我的。",
"小满那天不自觉煮了两人份的腊肉饭,盛完才想起再没人把肥肉挑走给我留精瘦的。",
"儿童节路过小学,梧桐树下空荡荡的——再没有举着冰糖葫芦等我的驼背身影了。",
"冬至包饺子时手一抖,捏出您教的麦穗花边,滚水冒的蒸汽突然就迷了眼睛。",
"昨夜梦见您穿着洗白的蓝布衫,在晒谷场对我笑,醒来枕巾湿了半边。",
"蝉鸣最响的午后,恍惚听见竹椅吱呀声,转头却只看见墙上相框里的您。",
"您走后,再没人把枇杷核仔细包进手帕,笑着说'留着明年给囡囡种'。",
"整理遗物时数出38张火车票全是往返我读书城市的票根都磨出了毛边。",
"清明雨把墓碑冲洗得发亮,就像您当年总把搪瓷缸擦得能照见人影。",
"今天教女儿念'慈母手中线',她突然问:'太外公的诗集能读给我听吗?'",
"翻到您留下的老照片,那件洗得发白的中山装上还别着茉莉花,仿佛还能闻到淡淡的清香。",
"傍晚的蝉鸣声里,总错觉能听到您哼着那首走了调的小曲,在巷子口唤我回家吃饭。",
"您种的葡萄藤今年结了好多串,可再没有人像您那样,把最紫的摘下来悄悄塞进我口袋。",
"下雨天膝盖隐隐作痛时,总会想起您泡的姜茶,热气氤氲中您笑着说'老了才知道疼'。",
"整理书房时,发现您用毛笔在旧日历背面写的家训,墨迹晕染处都是您颤抖的手印。"
]
};
@ -201,7 +184,7 @@
* @@param {Function} callback - 渐隐完成后的回调函数
*/
function fadeOutText(selector, callback) {
$(selector).fadeOut(1000, function () {
$(selector).fadeOut(CONFIG.waterEffect.fadeOutSpeed, function () {
$(this).html('');
$(this).show();
if (callback) callback();
@ -454,7 +437,7 @@
} else {
$('.water-effect').ripples({
resolution: 1024,
dropRadius: 3,
dropRadius: 1.5,
perturbance: 0
});
@ -464,9 +447,41 @@
*/
function createRippleTimer() {
return function randomRipple() {
let x1 = Math.random() * $(".water-effect").width();
let y1 = Math.random() * $(".water-effect").height();
triggerRipple(".water-effect", x1, y1);
let width = $(".water-effect").width();
let height = $(".water-effect").height();
// 中心坐标
let centerX = width / 2;
let centerY = height / 2;
// 根据中心偏好生成随机位置
let randomX, randomY;
if (Math.random() > CONFIG.waterEffect.centerBias) {
// 随机位置
randomX = Math.random() * width;
randomY = Math.random() * height;
// 避免位于上1/3区域
if (randomY < height / 3) {
randomY += height / 3;
}
} else {
// 靠近中心位置
let radius = Math.min(width, height) * 0.3; // 中心区域半径
let angle = Math.random() * Math.PI * 2; // 随机角度
let distance = Math.random() * radius; // 随机距离但不超过radius
randomX = centerX + Math.cos(angle) * distance;
randomY = centerY + Math.sin(angle) * distance;
}
// 决定是否创建大涟漪
let dropSize = Math.random() < CONFIG.waterEffect.largeDrop.probability ?
CONFIG.waterEffect.largeDrop.size : 50;
triggerRipple(".water-effect", randomX, randomY, dropSize);
let nextTime = Math.random() * (CONFIG.waterEffect.maxInterval - CONFIG.waterEffect.minInterval) + CONFIG.waterEffect.minInterval;
setTimeout(randomRipple, nextTime);
};
@ -490,10 +505,34 @@
* @@param {string} selector - 目标元素选择器
* @@param {number} x - X坐标
* @@param {number} y - Y坐标
* @@param {number} size - 涟漪大小
*/
function triggerRipple(selector, x, y) {
$(selector).ripples("drop", x, y, 50, 0.3);
function triggerRipple(selector, x, y, size = 50) {
$(selector).ripples("drop", x, y, size, 0.15);
}
// 页面可见性变化检测,用于处理页面切换到后台时暂停涟漪效果
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
// 页面不可见时,停止涟漪生成
console.log("页面切换到后台,停止涟漪生成");
// 可以添加额外逻辑来暂停或减少涟漪效果
} else {
// 页面可见时,可以恢复涟漪生成
console.log("页面回到前台,继续涟漪生成");
// 可以添加额外逻辑来恢复涟漪效果
// 如果需要,可以重置涟漪效果
if (CONFIG.waterEffect.enabled) {
$('.water-effect').ripples("destroy");
$('.water-effect').ripples({
resolution: 1024,
dropRadius: 1.5,
perturbance: 0
});
}
}
});
</script>
</body>

View File

@ -21,8 +21,8 @@
<span id="status-text" class="me-3">未检测到通话</span>
</div>
<!-- 单选按钮组 -->
<div class="btn-group" role="group">
<!-- 单选按钮组 - 隐藏自动识别显示选项 -->
<div class="btn-group" role="group" style="display: none;">
<input type="radio" class="btn-check" name="displayMode" id="displayMode0" value="0"
checked>
<label class="btn btn-outline-primary" for="displayMode0">自动识别显示</label>
@ -31,13 +31,18 @@
<label class="btn btn-outline-primary" for="displayMode1">手动处理显示</label>
</div>
<!-- 音频传输开关 -->
<!-- 音频传输开关 - 隐藏关闭音频传输选项 -->
<div class="btn-group ms-3" role="group">
<input type="radio" class="btn-check" name="audioStreaming" id="audioStreaming1" value="1">
<input type="radio" class="btn-check" name="audioStreaming" id="audioStreaming1" value="1" checked>
<label class="btn btn-outline-success" for="audioStreaming1">开启音频传输</label>
<input type="radio" class="btn-check" name="audioStreaming" id="audioStreaming0" value="0" checked>
<label class="btn btn-outline-danger" for="audioStreaming0">关闭音频传输</label>
<input type="radio" class="btn-check" name="audioStreaming" id="audioStreaming0" value="0" style="display: none;">
<label class="btn btn-outline-danger" for="audioStreaming0" style="display: none;">关闭音频传输</label>
<!-- 音量控制滑块 -->
<div class="ms-3 d-flex align-items-center">
<label for="volumeControl" class="me-2"><i class="bi bi-volume-up"></i></label>
<input type="range" class="form-range" min="0" max="1" step="0.1" value="1.0" id="volumeControl" style="width: 100px;">
</div>
</div>
</div>
@ -98,7 +103,7 @@
</div>
</div>
<!-- 按钮组区域 -->
<!-- 按钮组区域 - 隐藏添加并移除按钮 -->
<div id="action-buttons-area" class="mt-3" style="height: 20%;">
<div class="card h-100">
<div class="card-body d-flex justify-content-center align-items-center">
@ -107,7 +112,7 @@
<i class="bi bi-plus-circle"></i> 添加到显示
</button>
<button id="add-and-remove-btn" class="btn btn-warning" data-bs-toggle="tooltip"
title="将文本添加到显示队列,并从监控列表中移除当前选中项" onclick="addDisplayTextAndRemoveMonitor()">
title="将文本添加到显示队列,并从监控列表中移除当前选中项" onclick="addDisplayTextAndRemoveMonitor()" style="display: none;">
<i class="bi bi-arrow-right-circle"></i> 添加并移除
</button>
</div>
@ -178,6 +183,8 @@
let audioContext = null;
let audioStreamSource = null;
let isAudioStreamEnabled = false;
let audioGainNode = null;
let currentVolume = 1.0; // 默认音量为1.0 (100%)
// 调试日志
function log(message) {
@ -206,7 +213,7 @@
}
// 显示消息提示
function showMessage(message, type = 'info') {
function showMessage(message, type = 'info', duration = 5000) {
const messageArea = document.getElementById("message-area");
const alert = document.createElement("div");
alert.className = `alert alert-${type} alert-dismissible fade show`;
@ -216,11 +223,11 @@
`;
messageArea.appendChild(alert);
// 5秒后自动移除
// 自动移除
setTimeout(() => {
alert.classList.remove('show');
setTimeout(() => alert.remove(), 150);
}, 5000);
}, duration);
}
// 更新连接状态
@ -585,6 +592,9 @@
// 创建新的音频元素
const audioElement = new Audio(path);
// 应用当前音量设置
audioElement.volume = currentVolume;
// 设置当前播放的音频和按钮
currentAudio = audioElement;
currentPlayButton = button;
@ -600,7 +610,7 @@
});
audioElement.addEventListener('loadeddata', () => {
log(`音频已加载,开始播放: ${path}`);
log(`音频已加载,开始播放: ${path}, 音量: ${currentVolume * 100}%`);
});
audioElement.addEventListener('ended', () => {
@ -794,8 +804,23 @@
// 设置显示模式和音频流监听器
setupDisplayModeListeners();
// 设置音量控制监听器
setupVolumeControl();
// 初始化工具提示
setTimeout(initTooltips, 1000);
// 默认设置为手动显示模式
document.getElementById("displayMode1").checked = true;
// 默认开启音频传输
document.getElementById("audioStreaming1").checked = true;
isAudioStreamEnabled = true;
// 显示当前数据处理模式的提示消息
setTimeout(() => {
showMessage("当前为手动处理数据模式,需要您手动审核并添加文本到显示队列", "info", 10000);
}, 1500);
});
// 页面卸载前清理资源
@ -1079,43 +1104,46 @@
log(`UI音频流设置已更新为: ${enabled ? "开启" : "关闭"}`);
}
// 设置音量控制监听器
function setupVolumeControl() {
const volumeControl = document.getElementById('volumeControl');
if (volumeControl) {
volumeControl.addEventListener('input', function(e) {
currentVolume = parseFloat(e.target.value);
log(`音量已调整为: ${currentVolume * 100}%`);
// 如果已经创建了增益节点,立即应用音量设置
if (audioGainNode) {
audioGainNode.gain.value = currentVolume;
}
// 同时调整已播放的录音音量
if (currentAudio) {
currentAudio.volume = currentVolume;
}
});
}
}
// 初始化音频上下文
function initAudioContext() {
try {
if (audioContext) {
// 如果已存在音频上下文,检查其状态
if (audioContext.state === 'suspended') {
audioContext.resume().then(() => {
log("音频上下文已恢复");
}).catch(e => {
log("恢复音频上下文失败: " + e);
});
return;
} else if (audioContext.state === 'running') {
// 已经在运行,无需重新创建
log("音频上下文已在运行中");
return;
} else {
// 如果状态是closed或其他异常状态尝试关闭并重新创建
try {
audioContext.close();
} catch (e) {
log("关闭旧音频上下文失败: " + e);
}
}
}
if (audioContext) return; // 避免重复初始化
// 创建音频上下文,使用较低延迟选项
window.AudioContext = window.AudioContext || window.webkitAudioContext;
const options = {
latencyHint: 'interactive',
sampleRate: 16000 // 与服务器端配置匹配
};
audioContext = new AudioContext(options);
try {
// 创建音频上下文
const AudioContext = window.AudioContext || window.webkitAudioContext;
audioContext = new AudioContext();
// 解决iOS/Safari音频上下文自动暂停问题
// 创建增益节点用于控制音量
audioGainNode = audioContext.createGain();
audioGainNode.gain.value = currentVolume; // 设置初始音量
audioGainNode.connect(audioContext.destination);
log("音频上下文已初始化,创建了增益节点,初始音量: " + currentVolume);
// 如果音频上下文处于挂起状态,需要用户交互来激活
if (audioContext.state === 'suspended') {
const resumeAudio = function() {
const resumeAudio = function () {
audioContext.resume().then(() => {
log("用户交互已激活音频上下文");
document.removeEventListener('click', resumeAudio);
@ -1269,11 +1297,18 @@
// 创建音频源并连接到音频输出
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination);
// 连接到增益节点而不是直接连接到输出
source.connect(audioGainNode);
// 确保音量设置被应用
if (audioGainNode) {
audioGainNode.gain.value = currentVolume;
}
// 播放
source.start(0);
log("实时音频播放中...");
log(`实时音频播放中...音量: ${currentVolume * 100}%`);
} catch (e) {
log("处理实时音频失败: " + e);
}

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@ -30,4 +30,14 @@
<PackageReference Include="System.Net.WebSockets.Client" Version="4.3.2" />
</ItemGroup>
<ItemGroup>
<Folder Include="config\" />
</ItemGroup>
<ItemGroup>
<None Update="config\sentences.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -18,8 +18,8 @@
"recordingDeviceNumber": 0,
"sampleRate": 16000,
"channels": 1,
"bufferMilliseconds": 100,
"silenceThreshold": 0.02,
"bufferMilliseconds": 50,
"silenceThreshold": 0.1,
"silenceTimeoutSeconds": 30,
"allowUserHangup": true,
"uploadRecordingToServer": false,
@ -31,19 +31,19 @@
"callFlow": {
"waitForPickupMinSeconds": 3,
"waitForPickupMaxSeconds": 6,
"playPickupProbability": 0.15
"playPickupProbability": 0.5
},
"network": {
"serverUrl": "http://localhost:5140/audiohub",
"reconnectAttempts": 3,
"serverUrl": "http://115.159.44.16/audiohub",
"reconnectAttempts": 30,
"reconnectDelayMs": 2000,
"enableSpeechToText": true,
"enableAudioStreaming": true,
"heartbeatIntervalSeconds": 30,
"heartbeatIntervalSeconds": 15,
"tencentCloudASR": {
"appId": "1320384962",
"secretId": "AKIDX0LVSJBqIWWJsEMOQH6qQn7FAsPtAFB7",
"secretKey": "rdMuSpzvd8PTKyWq45uX1JNDrIWWIuIT",
"appId": "",
"secretId": "",
"secretKey": "",
"engineModelType": "16k_zh",
"voiceFormat": "1",
"filterDirty": true,
@ -52,7 +52,7 @@
"needVad": true
}
},
"displayType": 1,
"displayType": 0,
"minDisplayText": 3,
"maxDisplayText": 60
}

Binary file not shown.