312321
This commit is contained in:
parent
e6816d49b7
commit
ee18fd55a5
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"SignalRHubUrl": "http://localhost:81/audiohub",
|
||||
"SignalRHubUrl": "http://115.159.44.16/audiohub",
|
||||
"ConfigBackupPath": "config.json",
|
||||
"AutoConnectToServer": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,14 +18,14 @@
|
|||
<!-- 通话状态指示器 -->
|
||||
<div class="d-flex align-items-center">
|
||||
<div id="status-indicator" class="rounded-circle me-2"
|
||||
style="width: 24px; height: 24px; background-color: red;"></div>
|
||||
style="width: 24px; height: 24px; background-color: red;"></div>
|
||||
<span id="status-text" class="me-3">未检测到通话</span>
|
||||
</div>
|
||||
|
||||
<!-- 单选按钮组 - 隐藏自动识别显示选项 -->
|
||||
<div class="btn-group" role="group" style="display: none;">
|
||||
<input type="radio" class="btn-check" name="displayMode" id="displayMode0" value="0"
|
||||
checked>
|
||||
checked>
|
||||
<label class="btn btn-outline-primary" for="displayMode0">自动识别显示</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="displayMode" id="displayMode1" value="1">
|
||||
|
|
@ -34,16 +34,20 @@
|
|||
|
||||
<!-- 音频传输开关 - 隐藏关闭音频传输选项 -->
|
||||
<div class="btn-group ms-3" role="group">
|
||||
<input type="radio" class="btn-check" name="audioStreaming" id="audioStreaming1" value="1" checked>
|
||||
<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" style="display: none;">
|
||||
<label class="btn btn-outline-danger" for="audioStreaming0" style="display: none;">关闭音频传输</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;">
|
||||
<input type="range" class="form-range" min="0" max="1" step="0.1" value="1.0"
|
||||
id="volumeControl" style="width: 100px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -61,7 +65,7 @@
|
|||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="monitor-text-list" class="list-group list-group-flush"
|
||||
style="max-height: 75vh; overflow-y: auto;">
|
||||
style="max-height: 75vh; overflow-y: auto;">
|
||||
<!-- 文本列表内容将通过JS动态填充 -->
|
||||
<div class="text-center text-muted p-3">加载中...</div>
|
||||
</div>
|
||||
|
|
@ -95,11 +99,12 @@
|
|||
<div id="input-text-area" style="height: 40%;">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">文本编辑 <span style="color:#6c757d;font-size:12px;">(最多输入30个文字)</span></h5>
|
||||
<h5 class="mb-0">文本编辑 <span
|
||||
style="color:#6c757d;font-size:12px;">(最多输入30个文字)</span></h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<textarea id="text-input" class="form-control h-100"
|
||||
placeholder="请输入要显示的文本..." maxlength="30"></textarea>
|
||||
<textarea id="text-input" class="form-control h-100" placeholder="请输入要显示的文本..."
|
||||
maxlength="30"></textarea>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -110,11 +115,12 @@
|
|||
<div class="card h-100">
|
||||
<div class="card-body d-flex justify-content-center align-items-center">
|
||||
<button id="add-text-btn" class="btn btn-primary me-3" data-bs-toggle="tooltip"
|
||||
title="将文本添加到显示队列,并显示在大屏上" onclick="addDisplayText()">
|
||||
title="将文本添加到显示队列,并显示在大屏上" onclick="addDisplayText()">
|
||||
<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()" style="display: none;">
|
||||
title="将文本添加到显示队列,并从监控列表中移除当前选中项" onclick="addDisplayTextAndRemoveMonitor()"
|
||||
style="display: none;">
|
||||
<i class="bi bi-arrow-right-circle"></i> 添加并移除
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -138,7 +144,7 @@
|
|||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="display-text-list" class="list-group list-group-flush"
|
||||
style="max-height: 75vh; overflow-y: auto;">
|
||||
style="max-height: 75vh; overflow-y: auto;">
|
||||
<!-- 文本列表内容将通过JS动态填充 -->
|
||||
<div class="text-center text-muted p-3">加载中...</div>
|
||||
</div>
|
||||
|
|
@ -169,14 +175,40 @@
|
|||
<!-- 消息区域 -->
|
||||
<div id="message-area" class="position-fixed bottom-0 end-0 p-3" style="z-index: 1050;"></div>
|
||||
|
||||
@section Scripts {
|
||||
<!-- 来电确认对话框 -->
|
||||
<div class="modal fade" id="callConfirmDialog" tabindex="-1" aria-labelledby="callConfirmDialogLabel"
|
||||
aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title" id="callConfirmDialogLabel">
|
||||
<i class="bi bi-telephone-inbound"></i> 来电提醒
|
||||
</h5>
|
||||
</div>
|
||||
<div class="modal-body text-center py-4">
|
||||
<div class="mb-3">
|
||||
<i class="bi bi-telephone-fill text-primary" style="font-size: 3rem;"></i>
|
||||
</div>
|
||||
<h5>检测到新的通话</h5>
|
||||
<p class="text-muted">请点击接听开始播放音频</p>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-center">
|
||||
<button type="button" class="btn btn-primary" onclick="confirmCall()">
|
||||
<i class="bi bi-telephone"></i> 接听
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/lib/microsoft-signalr/signalr.min.js"></script>
|
||||
|
||||
<script>
|
||||
let connection = null;
|
||||
let refreshDisplayInterval = null;
|
||||
let callInProgress = false;
|
||||
|
||||
let isActiveShow = false;
|
||||
// 当前播放的音频元素和按钮引用
|
||||
let currentAudio = null;
|
||||
let currentPlayButton = null;
|
||||
|
|
@ -187,7 +219,7 @@
|
|||
let isAudioStreamEnabled = false;
|
||||
let audioGainNode = null;
|
||||
let currentVolume = 1.0; // 默认音量为1.0 (100%)
|
||||
|
||||
|
||||
// 添加音频缓冲区变量
|
||||
let audioBufferQueue = [];
|
||||
const MAX_BUFFER_SIZE = 15; // 最大缓冲队列大小,调整为15帧以适应网络延迟
|
||||
|
|
@ -228,9 +260,9 @@
|
|||
const alert = document.createElement("div");
|
||||
alert.className = `alert alert-${type} alert-dismissible fade show`;
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
`;
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
`;
|
||||
messageArea.appendChild(alert);
|
||||
|
||||
// 自动移除
|
||||
|
|
@ -365,7 +397,7 @@
|
|||
// 接收实时语音识别结果
|
||||
connection.on("ReceiveSpeechToTextResult", (result) => {
|
||||
log(`接收到实时语音识别结果: ${result.substring(0, 30)}${result.length > 30 ? '...' : ''}`);
|
||||
|
||||
|
||||
// 使用防抖动技术显示实时识别结果,避免频繁更新导致页面卡顿
|
||||
if (window.realtimeTextDebounceTimer) {
|
||||
clearTimeout(window.realtimeTextDebounceTimer);
|
||||
|
|
@ -378,15 +410,15 @@
|
|||
// 接收最终语音识别结果
|
||||
connection.on("ReceiveSpeechToEndTextResult", (result) => {
|
||||
log(`接收到最终语音识别结果: ${result.substring(0, 30)}${result.length > 30 ? '...' : ''}`);
|
||||
|
||||
|
||||
// 清除任何正在进行的实时文本更新定时器
|
||||
if (window.realtimeTextDebounceTimer) {
|
||||
clearTimeout(window.realtimeTextDebounceTimer);
|
||||
}
|
||||
|
||||
|
||||
// 显示最终识别结果
|
||||
showFinalTextResult(result);
|
||||
|
||||
|
||||
// 同时添加到监控列表的顶部(如果不存在于列表中)
|
||||
addToMonitorList(result);
|
||||
});
|
||||
|
|
@ -410,6 +442,10 @@
|
|||
|
||||
// 接收实时音频数据
|
||||
connection.on("ReceiveAudioData", (audioData) => {
|
||||
if (!isActiveShow) {
|
||||
updateCallStatus(true);
|
||||
return;
|
||||
}
|
||||
if (isAudioStreamEnabled && callInProgress) {
|
||||
// 确保音频流开启且通话进行中
|
||||
log("接收到实时音频数据...");
|
||||
|
|
@ -438,48 +474,74 @@
|
|||
|
||||
// 更新通话状态
|
||||
function updateCallStatus(isActive) {
|
||||
callInProgress = isActive;
|
||||
const indicator = document.getElementById("status-indicator");
|
||||
const statusText = document.getElementById("status-text");
|
||||
|
||||
isActiveShow = isActive;
|
||||
if (isActive) {
|
||||
indicator.style.backgroundColor = "green";
|
||||
statusText.textContent = "正在通话中";
|
||||
|
||||
// 初始化音频上下文(如果需要且启用了音频流)
|
||||
if (isAudioStreamEnabled) {
|
||||
// 如果存在音频上下文,则先尝试关闭,然后重新创建
|
||||
if (audioContext) {
|
||||
log("重置音频上下文以确保新的通话正常播放");
|
||||
try {
|
||||
// 释放旧的增益节点
|
||||
if (audioGainNode) {
|
||||
audioGainNode.disconnect();
|
||||
audioGainNode = null;
|
||||
}
|
||||
// 关闭旧的音频上下文
|
||||
audioContext.close().catch(e => log("关闭音频上下文失败: " + e));
|
||||
audioContext = null;
|
||||
} catch (e) {
|
||||
log("重置音频上下文失败: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新的音频上下文
|
||||
initAudioContext();
|
||||
}
|
||||
// 当检测到新通话时,先显示确认对话框
|
||||
const confirmDialog = new bootstrap.Modal(document.getElementById('callConfirmDialog'));
|
||||
confirmDialog.show();
|
||||
return; // 等待用户确认后再继续处理
|
||||
} else {
|
||||
const indicator = document.getElementById("status-indicator");
|
||||
const statusText = document.getElementById("status-text");
|
||||
indicator.style.backgroundColor = "red";
|
||||
statusText.textContent = "未检测到通话";
|
||||
|
||||
|
||||
// 停止缓冲区处理
|
||||
stopBufferProcessing();
|
||||
|
||||
// 如果确认对话框还在显示(用户未点击接听),则自动关闭
|
||||
const confirmDialog = bootstrap.Modal.getInstance(document.getElementById('callConfirmDialog'));
|
||||
if (confirmDialog) {
|
||||
confirmDialog.hide();
|
||||
log("通话已结束,自动关闭确认对话框");
|
||||
}
|
||||
}
|
||||
|
||||
// 有新通话时刷新监控列表
|
||||
loadMonitorTextList();
|
||||
}
|
||||
|
||||
// 用户确认接听通话
|
||||
function confirmCall() {
|
||||
const indicator = document.getElementById("status-indicator");
|
||||
const statusText = document.getElementById("status-text");
|
||||
indicator.style.backgroundColor = "green";
|
||||
statusText.textContent = "正在通话中";
|
||||
callInProgress = true;
|
||||
|
||||
// 初始化音频上下文(如果需要且启用了音频流)
|
||||
if (isAudioStreamEnabled) {
|
||||
// 如果存在音频上下文,则先尝试关闭,然后重新创建
|
||||
if (audioContext) {
|
||||
log("重置音频上下文以确保新的通话正常播放");
|
||||
try {
|
||||
// 释放旧的增益节点
|
||||
if (audioGainNode) {
|
||||
audioGainNode.disconnect();
|
||||
audioGainNode = null;
|
||||
}
|
||||
// 关闭旧的音频上下文
|
||||
audioContext.close().catch(e => log("关闭音频上下文失败: " + e));
|
||||
audioContext = null;
|
||||
} catch (e) {
|
||||
log("重置音频上下文失败: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新的音频上下文
|
||||
initAudioContext();
|
||||
}
|
||||
|
||||
// 关闭确认对话框
|
||||
const confirmDialog = bootstrap.Modal.getInstance(document.getElementById('callConfirmDialog'));
|
||||
if (confirmDialog) {
|
||||
confirmDialog.hide();
|
||||
}
|
||||
|
||||
// 刷新监控列表
|
||||
loadMonitorTextList();
|
||||
}
|
||||
|
||||
// 加载监控文本列表
|
||||
function loadMonitorTextList() {
|
||||
if (!connection || connection.state !== signalR.HubConnectionState.Connected) {
|
||||
|
|
@ -550,22 +612,22 @@
|
|||
const shortText = text.length > 10 ? text.substring(0, 10) : text;
|
||||
|
||||
listItem.innerHTML = `
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<small class="text-muted">【${shortText}】</small>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="playAudio('${item.recordingPath || ''}', this)">
|
||||
<i class="bi bi-play-fill"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="downloadRecording('${item.recordingPath || ''}')">
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="deleteMonitorText('${item.id}')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>${formattedDate}</div>
|
||||
`;
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<small class="text-muted">【${shortText}】</small>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="playAudio('${item.recordingPath || ''}', this)">
|
||||
<i class="bi bi-play-fill"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="downloadRecording('${item.recordingPath || ''}')">
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="deleteMonitorText('${item.id}')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>${formattedDate}</div>
|
||||
`;
|
||||
|
||||
container.appendChild(listItem);
|
||||
});
|
||||
|
|
@ -771,11 +833,11 @@
|
|||
const shortText = text.length > 5 ? text.substring(0, 5) : text;
|
||||
|
||||
listItem.innerHTML = `
|
||||
<div class="mb-1">
|
||||
<small class="text-muted">${formattedDate}</small>
|
||||
</div>
|
||||
<div>【${shortText}】</div>
|
||||
`;
|
||||
<div class="mb-1">
|
||||
<small class="text-muted">${formattedDate}</small>
|
||||
</div>
|
||||
<div>【${shortText}】</div>
|
||||
`;
|
||||
|
||||
container.appendChild(listItem);
|
||||
});
|
||||
|
|
@ -792,10 +854,10 @@
|
|||
// 调用服务器端方法更新显示模式
|
||||
if (connection && connection.state === signalR.HubConnectionState.Connected) {
|
||||
connection.invoke("UpdateDisplayType", mode)
|
||||
.then(() => {
|
||||
.then(() => {
|
||||
showMessage(`已切换为${mode === 0 ? "识别立即显示" : "手动显示"}模式`, "success");
|
||||
})
|
||||
.catch(err => {
|
||||
})
|
||||
.catch(err => {
|
||||
log(`更新显示模式失败: ${err}`);
|
||||
showMessage("切换显示模式失败", "danger");
|
||||
|
||||
|
|
@ -826,12 +888,12 @@
|
|||
}
|
||||
|
||||
// 调用服务器端方法更新音频流设置
|
||||
if (connection && connection.state === signalR.HubConnectionState.Connected) {
|
||||
if (connection && connection.state === signalR.HubConnectionState.Connected) {
|
||||
connection.invoke("UpdateAudioStreaming", enabled)
|
||||
.then(() => {
|
||||
.then(() => {
|
||||
showMessage(`已${enabled ? "开启" : "关闭"}音频传输`, "success");
|
||||
})
|
||||
.catch(err => {
|
||||
})
|
||||
.catch(err => {
|
||||
log(`更新音频流设置失败: ${err}`);
|
||||
showMessage("切换音频传输失败", "danger");
|
||||
|
||||
|
|
@ -1122,8 +1184,8 @@
|
|||
// 刷新右侧显示文本列表
|
||||
loadDisplayTextList();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
})
|
||||
.catch(err => {
|
||||
log(`删除监控文本失败: ${err}`);
|
||||
showMessage("文本已添加到显示队列,但从监控列表移除失败", "warning");
|
||||
|
||||
|
|
@ -1172,7 +1234,7 @@
|
|||
function setupVolumeControl() {
|
||||
const volumeControl = document.getElementById('volumeControl');
|
||||
if (volumeControl) {
|
||||
volumeControl.addEventListener('input', function(e) {
|
||||
volumeControl.addEventListener('input', function (e) {
|
||||
currentVolume = parseFloat(e.target.value);
|
||||
log(`音量已调整为: ${currentVolume * 100}%`);
|
||||
|
||||
|
|
@ -1193,7 +1255,7 @@
|
|||
function initAudioContext() {
|
||||
if (audioContext) {
|
||||
log("音频上下文已存在,使用现有上下文");
|
||||
|
||||
|
||||
// 确保音频上下文已激活
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume().then(() => {
|
||||
|
|
@ -1202,7 +1264,7 @@
|
|||
log("恢复音频上下文失败: " + err);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 启动缓冲处理
|
||||
startBufferProcessing();
|
||||
return;
|
||||
|
|
@ -1237,7 +1299,7 @@
|
|||
}
|
||||
|
||||
log("音频上下文已初始化,状态: " + audioContext.state + ", 采样率: " + audioContext.sampleRate);
|
||||
|
||||
|
||||
// 启动音频缓冲处理
|
||||
startBufferProcessing();
|
||||
} catch (e) {
|
||||
|
|
@ -1292,7 +1354,7 @@
|
|||
const audioData = audioBufferQueue.shift();
|
||||
playBufferedAudio(audioData);
|
||||
isAudioPlaying = true;
|
||||
|
||||
|
||||
// 自适应调整缓冲区大小
|
||||
if (audioBufferQueue.length > MAX_BUFFER_SIZE * 0.8) {
|
||||
log("缓冲区接近上限,增加处理频率");
|
||||
|
|
@ -1359,7 +1421,7 @@
|
|||
try {
|
||||
// 尝试使用更健壮的Base64解码,先规范化字符串格式
|
||||
const base64Str = audioData.trim().replace(/^data:[^;]+;base64,/, '');
|
||||
|
||||
|
||||
// 创建具有适当长度的Uint8Array
|
||||
const binary = atob(base64Str);
|
||||
pcmData = new Uint8Array(binary.length);
|
||||
|
|
@ -1369,7 +1431,7 @@
|
|||
log("已从Base64字符串转换为Uint8Array");
|
||||
} catch (e) {
|
||||
log("Base64转换失败: " + e);
|
||||
|
||||
|
||||
// 尝试直接解码二进制字符串
|
||||
try {
|
||||
pcmData = new Uint8Array(audioData.length);
|
||||
|
|
@ -1417,7 +1479,7 @@
|
|||
log("处理实时音频失败: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 播放缓冲区中的音频
|
||||
async function playBufferedAudio(pcmData) {
|
||||
try {
|
||||
|
|
@ -1532,7 +1594,7 @@
|
|||
// 添加文本到监控列表
|
||||
function addToMonitorList(text) {
|
||||
if (!text || text.trim() === "") return;
|
||||
|
||||
|
||||
// 检查是否已经存在相同内容
|
||||
const existingItems = document.querySelectorAll("#monitor-text-list .list-group-item");
|
||||
for (let i = 0; i < existingItems.length; i++) {
|
||||
|
|
@ -1541,7 +1603,7 @@
|
|||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 创建新的列表项
|
||||
const container = document.getElementById("monitor-text-list");
|
||||
const listItem = document.createElement("div");
|
||||
|
|
@ -1549,7 +1611,7 @@
|
|||
// 使用临时ID,服务器端会分配真正的ID
|
||||
listItem.dataset.id = "temp-" + Date.now();
|
||||
listItem.dataset.fullText = text;
|
||||
|
||||
|
||||
// 添加点击事件
|
||||
listItem.addEventListener('click', function (e) {
|
||||
// 如果点击的是按钮,不触发选择
|
||||
|
|
@ -1569,34 +1631,34 @@
|
|||
// 显示文本
|
||||
displayTextInCenter(this.dataset.fullText);
|
||||
});
|
||||
|
||||
|
||||
// 获取当前时间
|
||||
const date = new Date();
|
||||
const formattedDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`;
|
||||
|
||||
|
||||
// 截取前10个字符,若不足10个则全部显示
|
||||
const shortText = text.length > 10 ? text.substring(0, 10) : text;
|
||||
|
||||
|
||||
listItem.innerHTML = `
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<small class="text-muted">【${shortText}】</small>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="deleteMonitorText('${listItem.dataset.id}')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>${formattedDate} (本地)</div>
|
||||
`;
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<small class="text-muted">【${shortText}】</small>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="deleteMonitorText('${listItem.dataset.id}')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>${formattedDate} (本地)</div>
|
||||
`;
|
||||
|
||||
// 添加到列表顶部
|
||||
if (container.firstChild) {
|
||||
container.insertBefore(listItem, container.firstChild);
|
||||
} else {
|
||||
container.appendChild(listItem);
|
||||
}
|
||||
|
||||
|
||||
log(`已添加文本到监控列表: ${shortText}...`);
|
||||
}
|
||||
</script>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,11 +208,11 @@ public class AudioProcessingService : IAudioProcessingService
|
|||
|
||||
private byte[] ApplyNoiseReductionInternal(
|
||||
byte[] audioData,
|
||||
float noiseThreshold = 0.02f, // 噪声门限值
|
||||
float attackSeconds = 0.01f, // 攻击时间
|
||||
float releaseSeconds = 0.1f, // 释放时间
|
||||
int highPassCutoff = 80, // 高通滤波器截止频率(Hz)
|
||||
float q = 1.0f) // 滤波器Q值
|
||||
float noiseThreshold = 0.015f, // 降低噪声门限值,使其更温和
|
||||
float attackSeconds = 0.05f, // 增加攻击时间,使开启更平滑
|
||||
float releaseSeconds = 0.15f, // 增加释放时间,使关闭更平滑
|
||||
int highPassCutoff = 60, // 降低高通滤波器截止频率,减少声音失真
|
||||
float q = 0.7071f) // 使用更平缓的Q值(巴特沃斯滤波器标准Q值)
|
||||
{
|
||||
// 1. 将字节数组转换为 WaveStream
|
||||
using (var inputStream = new MemoryStream(audioData))
|
||||
|
|
@ -221,24 +221,29 @@ public class AudioProcessingService : IAudioProcessingService
|
|||
// 2. 转换为浮点样本便于处理
|
||||
var sampleProvider = waveStream.ToSampleProvider();
|
||||
|
||||
// 3. 应用噪声门(Noise Gate)
|
||||
var noiseGate = new NoiseGateSampleProvider(sampleProvider)
|
||||
// 3. 应用改进的噪声门,使用平滑过渡
|
||||
var noiseGate = new ImprovedNoiseGate(sampleProvider)
|
||||
{
|
||||
Threshold = noiseThreshold,
|
||||
AttackSeconds = attackSeconds,
|
||||
ReleaseSeconds = releaseSeconds
|
||||
ReleaseSeconds = releaseSeconds,
|
||||
HoldSeconds = 0.1f, // 添加保持时间,防止快速开关
|
||||
SoftKneeDb = 6.0f // 添加软膝,使过渡更平滑
|
||||
};
|
||||
|
||||
// 4. 应用高通滤波器去除低频噪音
|
||||
// 4. 应用高通滤波器去除低频噪音,使用更温和的设置
|
||||
var highPassFilter = new BiQuadFilterSampleProvider(noiseGate);
|
||||
highPassFilter.Filter = BiQuadFilter.HighPassFilter(
|
||||
sampleProvider.WaveFormat.SampleRate,
|
||||
highPassCutoff,
|
||||
q);
|
||||
|
||||
// 5. 处理后的音频转回字节数组
|
||||
// 5. 添加平滑处理器
|
||||
var smoothedProvider = new SmoothingSampleProvider(highPassFilter, 5);
|
||||
|
||||
// 6. 处理后的音频转回字节数组
|
||||
var outputStream = new MemoryStream();
|
||||
WaveFileWriter.WriteWavFileToStream(outputStream, highPassFilter.ToWaveProvider16());
|
||||
WaveFileWriter.WriteWavFileToStream(outputStream, smoothedProvider.ToWaveProvider16());
|
||||
|
||||
return outputStream.ToArray();
|
||||
}
|
||||
|
|
@ -663,182 +668,204 @@ public class BiQuadFilterSampleProvider : ISampleProvider
|
|||
public class ImprovedNoiseGate : ISampleProvider
|
||||
{
|
||||
private readonly ISampleProvider source;
|
||||
private float threshold;
|
||||
private float attackSeconds;
|
||||
private float releaseSeconds;
|
||||
private float holdSeconds;
|
||||
private float envelope;
|
||||
private bool gateOpen;
|
||||
private int holdCountRemaining;
|
||||
|
||||
private float threshold = 0.015f;
|
||||
private float attackSeconds = 0.05f;
|
||||
private float releaseSeconds = 0.15f;
|
||||
private float holdSeconds = 0.1f;
|
||||
private float softKneeDb = 6.0f;
|
||||
private float currentGain = 1.0f;
|
||||
private float envelope = 0.0f;
|
||||
private int holdSamples;
|
||||
private int holdCounter;
|
||||
|
||||
public ImprovedNoiseGate(ISampleProvider source)
|
||||
{
|
||||
this.source = source ?? throw new ArgumentNullException(nameof(source));
|
||||
this.source = source;
|
||||
this.WaveFormat = source.WaveFormat;
|
||||
|
||||
// 默认参数
|
||||
Threshold = 0.015f;
|
||||
AttackSeconds = 0.05f;
|
||||
ReleaseSeconds = 0.3f;
|
||||
HoldSeconds = 0.2f;
|
||||
this.holdSamples = (int)(holdSeconds * WaveFormat.SampleRate);
|
||||
}
|
||||
|
||||
|
||||
public WaveFormat WaveFormat { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 噪声门阈值 (0.0-1.0)
|
||||
/// </summary>
|
||||
|
||||
public float Threshold
|
||||
{
|
||||
get => threshold;
|
||||
set => threshold = Math.Max(0.0f, Math.Min(1.0f, value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动时间 (秒)
|
||||
/// </summary>
|
||||
|
||||
public float AttackSeconds
|
||||
{
|
||||
get => attackSeconds;
|
||||
set => attackSeconds = Math.Max(0.001f, value);
|
||||
set
|
||||
{
|
||||
attackSeconds = Math.Max(0.001f, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放时间 (秒)
|
||||
/// </summary>
|
||||
|
||||
public float ReleaseSeconds
|
||||
{
|
||||
get => releaseSeconds;
|
||||
set => releaseSeconds = Math.Max(0.001f, value);
|
||||
set
|
||||
{
|
||||
releaseSeconds = Math.Max(0.001f, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保持时间 (秒),在信号低于阈值后保持门打开的时间
|
||||
/// </summary>
|
||||
|
||||
public float HoldSeconds
|
||||
{
|
||||
get => holdSeconds;
|
||||
set => holdSeconds = Math.Max(0.0f, value);
|
||||
set
|
||||
{
|
||||
holdSeconds = Math.Max(0.0f, value);
|
||||
holdSamples = (int)(holdSeconds * WaveFormat.SampleRate);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前包络值 (只读)
|
||||
/// </summary>
|
||||
public float CurrentEnvelope => envelope;
|
||||
|
||||
/// <summary>
|
||||
/// 当前门状态 (只读)
|
||||
/// </summary>
|
||||
public bool IsGateOpen => gateOpen;
|
||||
|
||||
|
||||
public float SoftKneeDb
|
||||
{
|
||||
get => softKneeDb;
|
||||
set => softKneeDb = Math.Max(0.0f, value);
|
||||
}
|
||||
|
||||
public int Read(float[] buffer, int offset, int count)
|
||||
{
|
||||
int samplesRead = source.Read(buffer, offset, count);
|
||||
|
||||
// 预计算系数
|
||||
float attackCoeff = CalculateCoefficient(AttackSeconds);
|
||||
float releaseCoeff = CalculateCoefficient(ReleaseSeconds);
|
||||
int holdSamples = (int)(WaveFormat.SampleRate * HoldSeconds);
|
||||
|
||||
|
||||
float attackRate = (float)Math.Exp(-1.0 / (WaveFormat.SampleRate * attackSeconds));
|
||||
float releaseRate = (float)Math.Exp(-1.0 / (WaveFormat.SampleRate * releaseSeconds));
|
||||
|
||||
for (int n = 0; n < samplesRead; n++)
|
||||
{
|
||||
float sample = buffer[offset + n];
|
||||
float absSample = Math.Abs(sample);
|
||||
|
||||
// 更新包络
|
||||
if (absSample > envelope)
|
||||
float inputSample = buffer[offset + n];
|
||||
float absInput = Math.Abs(inputSample);
|
||||
|
||||
// 包络跟踪
|
||||
if (absInput > envelope)
|
||||
{
|
||||
envelope = absSample + (envelope - absSample) * attackCoeff;
|
||||
envelope = absInput + attackRate * (envelope - absInput);
|
||||
}
|
||||
else
|
||||
{
|
||||
envelope = absSample + (envelope - absSample) * releaseCoeff;
|
||||
envelope = absInput + releaseRate * (envelope - absInput);
|
||||
}
|
||||
|
||||
// 更新门状态
|
||||
if (envelope > Threshold)
|
||||
|
||||
// 软膝处理
|
||||
float softKneeStart = threshold - (softKneeDb / 40.0f); // 将dB转换为线性值
|
||||
float softKneeEnd = threshold + (softKneeDb / 40.0f);
|
||||
|
||||
float targetGain;
|
||||
if (envelope < softKneeStart)
|
||||
{
|
||||
gateOpen = true;
|
||||
holdCountRemaining = holdSamples; // 重置保持计数器
|
||||
// 低于软膝起点,完全衰减
|
||||
holdCounter = 0;
|
||||
targetGain = 0.0f;
|
||||
}
|
||||
else if (holdCountRemaining > 0)
|
||||
else if (envelope > softKneeEnd)
|
||||
{
|
||||
holdCountRemaining--;
|
||||
// 高于软膝终点,完全开启
|
||||
holdCounter = holdSamples;
|
||||
targetGain = 1.0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
gateOpen = false;
|
||||
// 在软膝区域内,线性插值
|
||||
float ratio = (envelope - softKneeStart) / (softKneeEnd - softKneeStart);
|
||||
targetGain = ratio;
|
||||
|
||||
// 更新保持计数器
|
||||
if (ratio > 0.5f)
|
||||
{
|
||||
holdCounter = holdSamples;
|
||||
}
|
||||
else if (holdCounter > 0)
|
||||
{
|
||||
holdCounter--;
|
||||
}
|
||||
}
|
||||
|
||||
// 应用增益 (带平滑过渡)
|
||||
float gain = gateOpen ? 1.0f : CalculateSoftGain(envelope);
|
||||
buffer[offset + n] = sample * gain;
|
||||
|
||||
// 如果在保持时间内,保持门开启
|
||||
if (holdCounter > 0)
|
||||
{
|
||||
targetGain = Math.Max(targetGain, 0.5f);
|
||||
}
|
||||
|
||||
// 平滑增益变化
|
||||
if (targetGain > currentGain)
|
||||
{
|
||||
currentGain = targetGain * attackRate + currentGain * (1 - attackRate);
|
||||
}
|
||||
else
|
||||
{
|
||||
currentGain = targetGain * releaseRate + currentGain * (1 - releaseRate);
|
||||
}
|
||||
|
||||
// 应用增益
|
||||
buffer[offset + n] = inputSample * currentGain;
|
||||
}
|
||||
|
||||
|
||||
return samplesRead;
|
||||
}
|
||||
|
||||
private float CalculateCoefficient(float timeInSeconds)
|
||||
{
|
||||
if (timeInSeconds <= 0.0f) return 0.0f;
|
||||
return (float)Math.Exp(-1.0 / (WaveFormat.SampleRate * timeInSeconds));
|
||||
}
|
||||
|
||||
private float CalculateSoftGain(float env)
|
||||
{
|
||||
// 软过渡:当包络接近阈值时逐渐降低增益
|
||||
if (env >= Threshold) return 1.0f;
|
||||
|
||||
// 计算相对阈值的位置 (0.0-1.0)
|
||||
float relativePosition = env / Threshold;
|
||||
|
||||
// 三次方曲线实现平滑过渡
|
||||
return relativePosition * relativePosition * relativePosition;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置噪声门状态
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
envelope = 0.0f;
|
||||
gateOpen = false;
|
||||
holdCountRemaining = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 新增平滑处理器
|
||||
public class SmoothingSampleProvider : ISampleProvider
|
||||
{
|
||||
private readonly ISampleProvider source;
|
||||
private readonly float[] history;
|
||||
private int historyIndex;
|
||||
|
||||
public SmoothingSampleProvider(ISampleProvider source, int windowSize = 5)
|
||||
private readonly float[] weights;
|
||||
|
||||
public SmoothingSampleProvider(ISampleProvider source, int windowSize)
|
||||
{
|
||||
this.source = source;
|
||||
this.history = new float[windowSize];
|
||||
this.WaveFormat = source.WaveFormat;
|
||||
this.history = new float[windowSize];
|
||||
this.weights = new float[windowSize];
|
||||
|
||||
// 创建高斯权重
|
||||
float sigma = windowSize / 6.0f;
|
||||
float weightSum = 0;
|
||||
for (int i = 0; i < windowSize; i++)
|
||||
{
|
||||
float x = (i - windowSize / 2.0f) / sigma;
|
||||
weights[i] = (float)Math.Exp(-0.5f * x * x);
|
||||
weightSum += weights[i];
|
||||
}
|
||||
|
||||
// 归一化权重
|
||||
for (int i = 0; i < windowSize; i++)
|
||||
{
|
||||
weights[i] /= weightSum;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public WaveFormat WaveFormat { get; }
|
||||
|
||||
|
||||
public int Read(float[] buffer, int offset, int count)
|
||||
{
|
||||
int samplesRead = source.Read(buffer, offset, count);
|
||||
|
||||
|
||||
for (int n = 0; n < samplesRead; n++)
|
||||
{
|
||||
// 保存当前样本到历史记录
|
||||
history[historyIndex] = buffer[offset + n];
|
||||
historyIndex = (historyIndex + 1) % history.Length;
|
||||
|
||||
// 简单移动平均平滑
|
||||
|
||||
// 计算加权平均
|
||||
float sum = 0;
|
||||
int index = historyIndex;
|
||||
for (int i = 0; i < history.Length; i++)
|
||||
sum += history[i];
|
||||
|
||||
buffer[offset + n] = sum / history.Length;
|
||||
{
|
||||
sum += history[index] * weights[i];
|
||||
index = (index - 1 + history.Length) % history.Length;
|
||||
}
|
||||
|
||||
// 更新样本
|
||||
buffer[offset + n] = sum;
|
||||
|
||||
// 更新历史索引
|
||||
historyIndex = (historyIndex + 1) % history.Length;
|
||||
}
|
||||
|
||||
|
||||
return samplesRead;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user