This commit is contained in:
zpc 2025-03-29 01:05:06 +08:00
parent 63a0438406
commit 71db19a4be
7 changed files with 449 additions and 54 deletions

View File

@ -126,6 +126,31 @@ public class AudioFilesConfig
/// </summary>
public int MinKeyTonePlayTimeMs { get; set; } = 200;
/// <summary>
/// 按键音音量倍数 (正常音量为1.0)
/// </summary>
public float KeyToneVolume { get; set; } = 1.0f;
/// <summary>
/// 待机嘟声音量倍数 (正常音量为1.0)
/// </summary>
public float WaitingToneVolume { get; set; } = 1.0f;
/// <summary>
/// 拨出嘟声音量倍数 (正常音量为1.0)
/// </summary>
public float DialToneVolume { get; set; } = 0.9f;
/// <summary>
/// 提示留言音量倍数 (正常音量为1.0)
/// </summary>
public float PromptAfterBeepVolume { get; set; } = 1.0f;
/// <summary>
/// 风铃提示音文件名
/// </summary>
public string WindChimeFile { get; set; } = "风铃.wav";
/// <summary>
/// 获取完整的音频文件路径
/// </summary>
@ -234,6 +259,16 @@ public class RecordingConfig
/// 背景音乐音量 (0.0-1.0)
/// </summary>
public float BackgroundMusicVolume { get; set; } = 0.1f;
/// <summary>
/// 无声音播放风铃提示的时间(秒)
/// </summary>
public float WindChimePromptSeconds { get; set; } = 7.5f;
/// <summary>
/// 风铃声淡出的时间(毫秒)
/// </summary>
public int WindChimeFadeOutMs { get; set; } = 2000;
}
/// <summary>

View File

@ -25,9 +25,15 @@
"WaitingToneFile": "等待嘟音.wav", // 等待嘟音文件
"WaitForPickupFile": "等待接电话.mp3", // 等待接电话音频文件
"PhonePickupFile": "电话接起.mp3", // 电话接起音频文件
"PromptUserRecordFile": "提示用户录音.mp3", // 提示用户录音音频文件
"PromptUserRecordFile": "提示用户录音.mp3", // 提示用户录音音频文件
"BeepPromptFile": "滴提示音.wav", // 滴提示音文件
"DigitToneFileTemplate": "{0}.mp3" // 数字按键音频文件模板
"DigitToneFileTemplate": "{0}.mp3", // 数字按键音频文件模板
"MinKeyTonePlayTimeMs": 200, // 按键音的最小播放时间(毫秒)
"KeyToneVolume": 1.0, // 按键音量倍数 (0.0-1.0)
"WaitingToneVolume": 1.0, // 待机嘟声音量倍数 (0.0-1.0)
"DialToneVolume": 0.9, // 拨出嘟声音量倍数 (0.0-1.0)
"PromptAfterBeepVolume": 1.0, // 提示留言音量倍数 (0.0-1.0)
"WindChimeFile": "风铃.wav" // 风铃提示音文件名
},
"Dial": {
"MinDigitsToDialOut": 8, // 拨号所需的最小位数
@ -39,15 +45,22 @@
"RecordingDeviceNumber": 0, // 录音设备编号
"SampleRate": 16000, // 录音采样率
"Channels": 1, // 录音通道数1=单声道2=立体声)
"BufferMilliseconds": 100, // 录音缓冲区大小(毫秒)
"SilenceThreshold": 0.02, // 静音检测阈值
"BufferMilliseconds": 50, // 录音缓冲区大小(毫秒)
"SilenceThreshold": 0.05, // 静音检测阈值
"SilenceTimeoutSeconds": 30, // 无声音自动挂断的时间(秒)
"AllowUserHangup": true // 是否允许用户手动挂断(按回车键)
"AllowUserHangup": true, // 是否允许用户手动挂断(按回车键)
"UploadRecordingToServer": false, // 是否上传录音文件到服务器
"EnableBackgroundMusic": true, // 是否在录音时播放背景音乐
"BackgroundMusicFile": "bj.mp3", // 录音背景音乐文件名
"BackgroundMusicVolume": 0.1, // 背景音乐音量 (0.0-1.0)
"WindChimePromptSeconds": 7.5, // 无声音播放风铃提示的时间(秒)
"WindChimeFadeOutMs": 3000 // 风铃声淡出的时间(毫秒)
},
"CallFlow": {
"WaitForPickupMinSeconds": 3, // 等待接电话音频的最小持续时间(秒)
"WaitForPickupMaxSeconds": 6, // 等待接电话音频的最大持续时间(秒)
"PlayPickupProbability": 0.15 // 播放电话接起音频的概率0-1之间的小数
"PlayPickupProbability": 0.15, // 播放电话接起音频的概率0-1之间的小数
"ResetSystemAfterHangup": false // 用户挂断后是否重置系统false表示直接退出程序
}
}
```

View File

@ -70,6 +70,14 @@ public class PhoneBoothService : IPhoneBoothService, IDisposable
private CancellationTokenSource? _backgroundMusicCts = null;
private volatile bool _isBackgroundMusicPlaying = false;
// 风铃提示音相关
private WaveOutEvent? _windChimeDevice = null;
private AudioFileReader? _windChimeReader = null;
private CancellationTokenSource? _windChimeCts = null;
private volatile bool _isWindChimePlaying = false;
private Timer? _windChimeTimer = null;
private DateTime _lastSpeechTime = DateTime.MinValue;
[StructLayout(LayoutKind.Sequential)]
private struct KBDLLHOOKSTRUCT
{
@ -282,13 +290,12 @@ public class PhoneBoothService : IPhoneBoothService, IDisposable
_waitingToneDevice = new WaveOutEvent();
_waitingToneReader = new AudioFileReader(_config.AudioFiles.GetFullPath(_config.AudioFiles.WaitingToneFile));
_waitingToneDevice.Init(_waitingToneReader);
Console.WriteLine("音频设备初始化成功");
// 设置待机嘟声音量确保在0.0-1.0范围内
_waitingToneDevice.Volume = Math.Clamp(_config.AudioFiles.WaitingToneVolume > 0 ? _config.AudioFiles.WaitingToneVolume : 1.0f, 0.0f, 1.0f);
}
catch (Exception ex)
{
Console.WriteLine($"初始化音频设备失败: {ex.Message}");
throw;
}
}
@ -334,6 +341,28 @@ public class PhoneBoothService : IPhoneBoothService, IDisposable
_isBackgroundMusicPlaying = false;
// 释放风铃提示音设备
_windChimeCts?.Cancel();
_windChimeCts?.Dispose();
_windChimeCts = null;
if (_windChimeDevice != null)
{
_windChimeDevice.Stop();
_windChimeDevice.Dispose();
_windChimeDevice = null;
}
if (_windChimeReader != null)
{
_windChimeReader.Dispose();
_windChimeReader = null;
}
_isWindChimePlaying = false;
_windChimeTimer?.Dispose();
_windChimeTimer = null;
foreach (var (device, reader) in _keyToneDevices.Values)
{
device.Stop();
@ -552,14 +581,18 @@ public class PhoneBoothService : IPhoneBoothService, IDisposable
// 启动静音检测计时器
_lastSoundTime = DateTime.Now;
_lastSpeechTime = DateTime.Now; // 初始化最后说话时间
_silenceTimer = new Timer(CheckSilence, recordingCompletionSource, 1000, 1000);
// 启动风铃提示计时器
_windChimeTimer = new Timer(CheckWindChimePrompt, null, 1000, 1000);
Console.WriteLine("播放滴提示音...");
try
{
await PlayAudioAndWait(
_config.AudioFiles.GetFullPath(_config.AudioFiles.BeepPromptFile),
null, false, recordingCts.Token, 0.1f);
null, false, recordingCts.Token);
}
catch (OperationCanceledException)
{
@ -838,6 +871,14 @@ public class PhoneBoothService : IPhoneBoothService, IDisposable
_silenceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_silenceTimer?.Dispose();
_silenceTimer = null;
// 停止风铃提示音计时器
_windChimeTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_windChimeTimer?.Dispose();
_windChimeTimer = null;
// 停止风铃提示音
_ = StopWindChime(false);
// 停止录音
if (_waveIn != null)
@ -909,6 +950,19 @@ public class PhoneBoothService : IPhoneBoothService, IDisposable
_lasHasoudDateTime = DateTime.Now;
_isSpeaking = false;
}
// 如果监测到说话,更新最后说话时间
if (isSpeaking)
{
_lastSpeechTime = DateTime.Now;
// 如果风铃声正在播放,平滑停止它
if (_isWindChimePlaying)
{
_ = StopWindChime(true); // 平滑淡出
}
}
// 如果最大音量超过阈值,则认为有声音
return isSpeaking;
}
@ -925,6 +979,9 @@ public class PhoneBoothService : IPhoneBoothService, IDisposable
// 确保背景音乐被停止
StopBackgroundMusic();
// 确保风铃提示音被停止
_ = StopWindChime(false);
// 清除录音状态
_isRecording = false;
@ -1238,6 +1295,8 @@ public class PhoneBoothService : IPhoneBoothService, IDisposable
var device = new WaveOutEvent();
var reader = new AudioFileReader(_config.AudioFiles.GetDigitToneFilePath(digit));
device.Init(reader);
// 设置按键音量确保在0.0-1.0范围内
device.Volume = Math.Clamp(_config.AudioFiles.KeyToneVolume > 0 ? _config.AudioFiles.KeyToneVolume : 1.0f, 0.0f, 1.0f);
// 记录开始播放时间
var startTime = DateTime.Now;
@ -1245,6 +1304,7 @@ public class PhoneBoothService : IPhoneBoothService, IDisposable
if (_keyToneDevices.TryAdd(digit, (device, reader)))
{
device.Play();
// 确保至少播放最小时长
@ -1301,17 +1361,44 @@ public class PhoneBoothService : IPhoneBoothService, IDisposable
{
device = new WaveOutEvent();
reader = new AudioFileReader(audioPath);
//device.Volume
if (volume != null)
// 如果没有指定音量,根据音频文件类型设置音量
if (volume == null)
{
device.Volume = volume.Value;
// 根据文件名判断音频类型并设置对应的音量
if (audioPath.Contains(_config.AudioFiles.WaitForPickupFile))
{
// 拨出嘟声音量
device.Volume = Math.Clamp(_config.AudioFiles.DialToneVolume > 0 ? _config.AudioFiles.DialToneVolume : 1.0f, 0.0f, 1.0f);
}
else if (audioPath.Contains(_config.AudioFiles.PromptUserRecordFile))
{
// 提示用户录音的音量
device.Volume = Math.Clamp(_config.AudioFiles.PromptAfterBeepVolume > 0 ? _config.AudioFiles.PromptAfterBeepVolume : 1.0f, 0.0f, 1.0f);
}
else if (audioPath.Contains(_config.AudioFiles.BeepPromptFile))
{
// 滴提示音的音量
device.Volume = Math.Clamp(_config.AudioFiles.PromptAfterBeepVolume > 0 ? _config.AudioFiles.PromptAfterBeepVolume : 1.0f, 0.0f, 1.0f);
}
else if (audioPath.Contains(_config.AudioFiles.WaitingToneFile))
{
// 待机嘟声音量 (虽然这个可能不会通过这个方法播放)
device.Volume = Math.Clamp(_config.AudioFiles.WaitingToneVolume > 0 ? _config.AudioFiles.WaitingToneVolume : 1.0f, 0.0f, 1.0f);
}
else
{
// 默认音量为1.0
device.Volume = 1.0f;
}
}
else
{
device.Volume = Math.Clamp(volume.Value, 0.0f, 1.0f);
}
device.Init(reader);
//device.Volume
if (volume != null)
{
device.Volume = volume.Value;
}
// 如果需要循环播放,创建一个循环播放的任务
if (loop && maxDuration.HasValue)
{
@ -1518,4 +1605,182 @@ public class PhoneBoothService : IPhoneBoothService, IDisposable
Console.WriteLine($"停止背景音乐失败: {ex.Message}");
}
}
/// <summary>
/// 检查是否需要播放风铃提示音
/// </summary>
private void CheckWindChimePrompt(object? state)
{
try
{
// 如果用户已挂断或者录音已停止,不执行操作
if (_isHangUpKeyPressed || !_isRecording)
{
return;
}
// 检查是否超过配置的无声音时间但还未达到挂断时间
float windChimePromptSeconds = _config.Recording.WindChimePromptSeconds;
int silenceTimeoutSeconds = _config.Recording.SilenceTimeoutSeconds;
double secondsSinceLastSpeech = (DateTime.Now - _lastSpeechTime).TotalSeconds;
// 如果超过风铃提示时间但未达到挂断时间,并且风铃没在播放,开始播放风铃
if (secondsSinceLastSpeech >= windChimePromptSeconds &&
secondsSinceLastSpeech < silenceTimeoutSeconds &&
!_isWindChimePlaying)
{
Console.WriteLine($"检测到{windChimePromptSeconds}秒无声音,播放风铃提示音...");
_ = PlayWindChime();
}
}
catch (Exception ex)
{
Console.WriteLine($"检查风铃提示音错误: {ex.Message}");
}
}
/// <summary>
/// 播放风铃提示音
/// </summary>
private async Task PlayWindChime()
{
try
{
// 如果已经在播放,先停止
if (_isWindChimePlaying)
{
await StopWindChime(true); // 平滑淡出
}
// 防止重复启动
if (_isWindChimePlaying)
{
return;
}
_windChimeCts?.Cancel();
_windChimeCts?.Dispose();
_windChimeCts = CancellationTokenSource.CreateLinkedTokenSource(_programCts.Token);
var token = _windChimeCts.Token;
string windChimeFilePath = _config.AudioFiles.GetFullPath(_config.AudioFiles.WindChimeFile);
if (!File.Exists(windChimeFilePath))
{
Console.WriteLine($"风铃提示音文件不存在: {windChimeFilePath}");
return;
}
// 初始化播放设备
_windChimeDevice = new WaveOutEvent();
_windChimeReader = new AudioFileReader(windChimeFilePath);
_windChimeDevice.Init(_windChimeReader);
_windChimeDevice.Volume = Math.Clamp(1.0f, 0.0f, 1.0f); // 初始音量设为最大
_isWindChimePlaying = true;
Console.WriteLine("开始播放风铃提示音");
// 开始播放
_windChimeDevice.Play();
// 单独开启一个任务检测是否有声音,如有则淡出停止
_ = Task.Run(async () =>
{
try
{
while (_isWindChimePlaying && !token.IsCancellationRequested)
{
// 如果检测到有声音,淡出停止
if ((DateTime.Now - _lastSpeechTime).TotalSeconds < 1.0)
{
Console.WriteLine("检测到用户说话,风铃提示音淡出...");
await StopWindChime(true); // 平滑淡出
break;
}
// 如果到达音频文件末尾,循环播放
if (_windChimeReader != null && _windChimeReader.Position >= _windChimeReader.Length)
{
_windChimeReader.Position = 0;
}
await Task.Delay(100, token);
}
}
catch (OperationCanceledException)
{
// 正常取消
}
catch (Exception ex)
{
Console.WriteLine($"风铃提示音监控错误: {ex.Message}");
}
});
}
catch (Exception ex)
{
Console.WriteLine($"播放风铃提示音错误: {ex.Message}");
await StopWindChime(false); // 出错时直接停止,不淡出
}
}
/// <summary>
/// 停止风铃提示音
/// </summary>
/// <param name="fadeOut">是否平滑淡出</param>
private async Task StopWindChime(bool fadeOut)
{
try
{
if (!_isWindChimePlaying)
{
return; // 如果没有播放,不需要停止
}
// 如果需要淡出
if (fadeOut && _windChimeDevice != null)
{
int fadeOutMs = _config.Recording.WindChimeFadeOutMs;
float startVolume = _windChimeDevice.Volume;
int steps = 20; // 淡出的步骤数
int stepDelay = fadeOutMs / steps;
// 逐步降低音量实现淡出效果
for (int i = 0; i < steps; i++)
{
if (_windChimeDevice == null || !_isWindChimePlaying)
{
break; // 如果设备已释放或播放已停止,直接退出
}
float ratio = 1.0f - ((float)i / steps);
_windChimeDevice.Volume = startVolume * ratio;
await Task.Delay(stepDelay);
}
}
// 正式停止
_windChimeCts?.Cancel();
if (_windChimeDevice != null)
{
_windChimeDevice.Stop();
_windChimeDevice.Dispose();
_windChimeDevice = null;
}
if (_windChimeReader != null)
{
_windChimeReader.Dispose();
_windChimeReader = null;
}
_isWindChimePlaying = false;
Console.WriteLine("风铃提示音已停止");
}
catch (Exception ex)
{
Console.WriteLine($"停止风铃提示音错误: {ex.Message}");
_isWindChimePlaying = false;
}
}
}

View File

@ -1,5 +1,5 @@
{
"SignalRHubUrl": "http://localhost:81/audiohub",
"SignalRHubUrl": "http://115.159.44.16/audiohub",
"ConfigBackupPath": "config.json",
"AutoConnectToServer": true,
"AllowOfflineStart": false

View File

@ -7,7 +7,12 @@
"PromptUserRecordFile": "\u63D0\u793A\u7528\u6237\u5F55\u97F3.mp3",
"BeepPromptFile": "\u6EF4\u63D0\u793A\u97F3.wav",
"DigitToneFileTemplate": "{0}.mp3",
"MinKeyTonePlayTimeMs": 200
"MinKeyTonePlayTimeMs": 200,
"KeyToneVolume": 1.0,
"WaitingToneVolume": 1.0,
"DialToneVolume": 0.9,
"PromptAfterBeepVolume": 1.0,
"WindChimeFile": "\u98CE\u94C3.wav"
},
"Dial": {
"MinDigitsToDialOut": 8,
@ -20,13 +25,15 @@
"SampleRate": 16000,
"Channels": 1,
"BufferMilliseconds": 50,
"SilenceThreshold": 0.15,
"SilenceTimeoutSeconds": 30,
"SilenceThreshold": 0.1,
"SilenceTimeoutSeconds": 15,
"AllowUserHangup": true,
"UploadRecordingToServer": false,
"EnableBackgroundMusic": true,
"BackgroundMusicFile": "bj.mp3",
"BackgroundMusicVolume": 0.1
"BackgroundMusicVolume": 0.3,
"WindChimePromptSeconds": 7.5,
"WindChimeFadeOutMs": 3000
},
"CallFlow": {
"WaitForPickupMinSeconds": 3,

View File

@ -137,10 +137,13 @@
},
// 右侧容器配置
rightContainer: {
fontSize: '40px', // 右侧文字大小
fontWeight: '700', // 右侧文字粗细
fontStyle: 'italic', // 右侧文字样式
typewriterSpeed: 2000 // 右侧文字打字机速度(毫秒)增加到2000毫秒
fontSize: '40px', // 右侧文字大小
fontWeight: '700', // 右侧文字粗细
fontStyle: 'italic', // 右侧文字样式
typewriterSpeed: 2000, // 右侧文字打字机速度(毫秒)增加到2000毫秒
fadeChars: 4, // 渐变字符数量,值越大渐变效果越长
fadeStepTime: 500, // 字符透明度过渡时间(毫秒)
fadeDelayFactor: 0.25 // 每个字符的透明度递减因子(0-1)
},
// 水波纹效果配置
waterEffect: {
@ -199,34 +202,92 @@
if (isAnimating) return;
isAnimating = true;
var i = 0;
$(selector).html('').css('opacity', 1);
// 获取渐变相关配置
const fadeChars = CONFIG.rightContainer.fadeChars || 4; // 渐变字符数量
const fadeStepTime = CONFIG.rightContainer.fadeStepTime || 500; // 过渡时间
const fadeDelayFactor = CONFIG.rightContainer.fadeDelayFactor || 0.25; // 透明度递减因子
function typeWriter() {
if (i < text.length) {
// 创建一个新的 span 元素包含当前字符
const charSpan = $('<span></span>').text(text.charAt(i));
charSpan.css('opacity', 0); // 初始透明度为0
$(selector).append(charSpan); // 添加到容器
// 对这个字符应用淡入效果
charSpan.animate({opacity: 1}, speed * 0.8, function() {
// 淡入完成后继续下一个字符
i++;
setTimeout(typeWriter, speed * 0.2);
});
} else {
isAnimating = false;
if (callback) callback();
// 打字效果完成后等待随机4-7秒然后请求下一条文本
const delay = Math.floor(Math.random() * 3000) + 4000; // 4000-7000毫秒
console.log(`文本显示完成,将在${delay/1000}秒后请求下一条文本`);
setTimeout(requestNextText, delay);
}
// 清空容器并设置初始可见度
$(selector).html('').css('opacity', 1);
// 创建所有字符元素但初始透明度为0
for (let i = 0; i < text.length; i++) {
const charSpan = $('<span></span>').text(text.charAt(i));
charSpan.css({
'opacity': 0,
'display': 'inline-block', // 确保每个字符是独立的块
'transition': `opacity ${fadeStepTime}ms ease-in-out` // 平滑的透明度过渡效果
});
$(selector).append(charSpan);
}
typeWriter();
// 获取所有字符span元素
const chars = $(selector).find('span');
const totalChars = chars.length;
// 设置初始不透明度,形成从左到右的渐变效果
// 左侧字符较深,右侧字符较浅
const setInitialOpacity = () => {
for (let i = 0; i < totalChars; i++) {
// 计算不透明度:第一个字符不透明度最高,往后逐渐降低
let position = i / totalChars; // 字符在文本中的相对位置 (0-1)
let opacity = Math.max(0, 0.8 - (position * fadeChars * 0.2));
// 超出可见范围的字符完全透明
if (i >= fadeChars) {
opacity = 0;
}
// 设置不透明度
$(chars[i]).css('opacity', opacity);
}
};
// 执行渐变动画
const animateText = () => {
let progress = 0;
const step = () => {
if (progress >= 1) {
// 完成动画
chars.css('opacity', 1);
isAnimating = false;
if (callback) callback();
// 等待随机4-7秒后请求下一条文本
const delay = Math.floor(Math.random() * 3000) + 4000; // 4000-7000毫秒
console.log(`文本显示完成,将在${delay/1000}秒后请求下一条文本`);
setTimeout(requestNextText, delay);
return;
}
// 更新每个字符的透明度
for (let i = 0; i < totalChars; i++) {
let targetOpacity = 1; // 目标不透明度(完全显示)
let currentPosition = i / totalChars; // 字符的相对位置
// 计算当前动画进度下该位置的字符应有的透明度
// 小于当前进度的字符应该更不透明
let opacity = targetOpacity * Math.max(0, 1 - Math.max(0, (currentPosition - progress) * 3));
// 设置不透明度
$(chars[i]).css('opacity', opacity);
}
// 增加进度
progress += 0.02;
// 继续下一步动画
setTimeout(step, speed / 50);
};
// 开始动画
step();
};
// 设置初始不透明度并开始动画
setInitialOpacity();
setTimeout(animateText, 50);
}
/**
@ -422,6 +483,17 @@
CONFIG.rightContainer.fontStyle = newConfig.rightContainer.fontStyle || CONFIG.rightContainer.fontStyle;
CONFIG.rightContainer.typewriterSpeed = newConfig.rightContainer.typewriterSpeed || CONFIG.rightContainer.typewriterSpeed;
// 更新渐显效果相关的配置
if (newConfig.rightContainer.fadeChars !== undefined) {
CONFIG.rightContainer.fadeChars = newConfig.rightContainer.fadeChars;
}
if (newConfig.rightContainer.fadeStepTime !== undefined) {
CONFIG.rightContainer.fadeStepTime = newConfig.rightContainer.fadeStepTime;
}
if (newConfig.rightContainer.fadeDelayFactor !== undefined) {
CONFIG.rightContainer.fadeDelayFactor = newConfig.rightContainer.fadeDelayFactor;
}
// 应用到CSS
document.documentElement.style.setProperty('--right-font-size', CONFIG.rightContainer.fontSize);
document.documentElement.style.setProperty('--right-font-weight', CONFIG.rightContainer.fontWeight);

View File

@ -8,7 +8,10 @@
"fontSize": "40px",
"fontWeight": "bolder",
"fontStyle": "italic",
"typewriterSpeed": 500
"typewriterSpeed": 6000,
"fadeChars": 1,
"fadeStepTime": 1000,
"fadeDelayFactor": 0.20
},
"waterEffect": {
"enabled": true,