1、电脑端下载并安装FreeSWITCH,配置教程可千问、豆包,防火墙端口要配置正确SIP(5506),FSL(8821),RTP(16384-32768)
2、网络广播音柱配置
参数填写FreeSwitch端分配的帐户信息

音频编码PCMA或者PCMU(站长用的是PCMU)

参数填写完成后,重启下音柱
3、实现代码
internal static class ClassSIP
{
private const string FsIp = “192.168.5.250”;//这个是站长FreeSwitch服务器的地址
private const int FsEslPort = 8821;//FreeSwitch服务器默认端口
private const string FsPassword = “ClueCon”;//FreeSwitch服务器默认的一个登录密码,站长没有改
private static Socket _eslSocket;
private static readonly ConcurrentDictionary<string, string> _extCallUuid = new ConcurrentDictionary<string, string>();
private static readonly ConcurrentDictionary<string, string> _jobMap = new ConcurrentDictionary<string, string>();
private static readonly object _lockInit = new object();
private static Thread _eventThread;
private static bool _runEvent = false;
private static readonly Encoding ASCII = Encoding.ASCII;
// 事件、命令应答缓冲区完全隔离,杜绝粘包污染
private static readonly StringBuilder _eventBuf = new StringBuilder();
private static readonly object _lockEventBuf = new object();
public static bool FunInitSip()
{
lock (_lockInit)
{
if (_eslSocket != null && _eslSocket.Connected)
return true;
Cleanup();
Thread.Sleep(60);
try
{
_eslSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
{
SendTimeout = 3000,
ReceiveTimeout = 1000
};
//ClassSystem.FunCreateRunLog($”FreeSwitch:准备连接 {FsIp}:{FsEslPort}”);
IPEndPoint ep = new IPEndPoint(IPAddress.Parse(FsIp), FsEslPort);
var ar = _eslSocket.BeginConnect(ep, null, null);
if (!ar.AsyncWaitHandle.WaitOne(3000))
{
_eslSocket.Close();
_eslSocket = null;
//ClassSystem.FunCreateRunLog(“FreeSwitch:连接超时”);
return false;
}
_eslSocket.EndConnect(ar);
//ClassSystem.FunCreateRunLog(“FreeSwitch:TCP连接建立成功”);
ReadCmdReply();
SendRaw($”auth {FsPassword}\r\n\r\n”);
string authRes = ReadCmdReply();
if (!authRes.Contains(“+OK”))
{
//ClassSystem.FunCreateRunLog(“FreeSwitch:认证应答异常”);
Cleanup();
return false;
}
SendRaw(“event plain BACKGROUND_JOB CHANNEL_CREATE CHANNEL_HANGUP_COMPLETE\r\n\r\n”);
ReadCmdReply();
_runEvent = true;
_eventThread = new Thread(EventWorker) { IsBackground = true };
_eventThread.Start();
//ClassSystem.FunCreateRunLog(“FreeSwitch:ESL连接FreeSWITCH成功,事件订阅完成”);
return true;
}
catch (Exception ex)
{
//ClassSystem.FunCreateRunLog($”FreeSwitch:连接异常 {ex.Message}”);
Cleanup();
return false;
}
}
}
public static bool CallSingleSpeaker(string ext, string audioPath)
{
if (_eslSocket == null || !_eslSocket.Connected)
{
//ClassSystem.FunCreateRunLog(“FreeSwitch:连接失效,尝试重连”);
if (!FunInitSip())
{
//ClassSystem.FunCreateRunLog(“FreeSwitch:重连失败”);
return false;
}
}
if (_extCallUuid.ContainsKey(ext))
{
//ClassSystem.FunCreateRunLog($”音柱 {ext} 正在播放,禁止重复呼叫”);
return false;
}
try
{
string path = audioPath.Replace(‘\\’, ‘/’);
string jobId = Guid.NewGuid().ToString(“N”);
//PCMU 适用于简单的单向通知。例如:呼叫分机,接通后播放一段欢迎语或通知,播放完毕后呼叫通常会自动挂断。
string cmd = $”bgapi originate {{origination_uuid={jobId},codec=PCMU}}user/{ext} &playback({path})\r\n\r\n”;
//适用于需要双向交互或复杂路由的场景。因为 bridge 建立了双向媒体通道,如果 playback 之后还有其他应用(例如 &record 录音,或者 &park 保持通话),或者需要监听通话过程中的 DTMF 按键,使用 bridge 会更加灵活。此外,bridge 可以桥接真实的 SIP 用户、会议(conference)或外部服务。
// string cmd = $”bgapi originate {{origination_uuid={jobId},absolute_codec_string=PCMU}}user/{ext} bridge(playback({path}))\r\n\r\n”;
SendRaw(cmd);
string res = ReadCmdReply();
// 不再打印res原始内容,彻底屏蔽大包刷屏
string jobUuid = GetHeader(res, “Job-UUID”);
if (string.IsNullOrEmpty(jobUuid))
{
//ClassSystem.FunCreateRunLog($”呼叫{ext}失败:未获取Job-UUID”);
return false;
}
_jobMap.TryAdd(jobUuid, ext);
//ClassSystem.FunCreateRunLog($”已发起音柱{ext}播放,等待分配通话ID,JobID:{jobUuid}”);
return true;
}
catch
{
return false;
}
}
public static void HangUpSingle(string ext)
{
if (_extCallUuid.TryRemove(ext, out string uuid))
{
SendRaw($”api uuid_kill {uuid}\r\n\r\n”);
ReadCmdReply();
//ClassSystem.FunCreateRunLog($”挂断音柱 {ext},通话ID:{uuid}”);
}
}
public static void HangUpAll()
{
foreach (var kv in _extCallUuid)
HangUpSingle(kv.Key);
}
public static bool IsSpeakerBusy(string ext) => _extCallUuid.ContainsKey(ext);
public static void DisposeSip()
{
lock (_lockInit)
{
_runEvent = false;
HangUpAll();
_eslSocket?.Close();
_eslSocket?.Dispose();
_eslSocket = null;
lock (_lockEventBuf) _eventBuf.Clear();
_extCallUuid.Clear();
_jobMap.Clear();
//ClassSystem.FunCreateRunLog(“ESL资源全部释放”);
}
}
#region 底层收发
private static void SendRaw(string msg)
{
if (_eslSocket == null || !_eslSocket.Connected) return;
try
{
byte[] data = ASCII.GetBytes(msg);
_eslSocket.Send(data);
}
catch (Exception ex)
{
//ClassSystem.FunCreateRunLog($”ESL发送异常:{ex.Message}”);
}
}
/// <summary>命令应答独立接收,不受事件干扰</summary>
private static string ReadCmdReply(int timeout = 3000)
{
StringBuilder buf = new StringBuilder();
byte[] temp = new byte[1024];
DateTime expire = DateTime.Now.AddMilliseconds(timeout);
while (DateTime.Now < expire)
{
if (_eslSocket == null || !_eslSocket.Connected) break;
int len;
try
{
len = _eslSocket.Receive(temp, SocketFlags.Peek);
if (len <= 0)
{
Thread.Sleep(10);
continue;
}
len = _eslSocket.Receive(temp);
}
catch (SocketException)
{
break;
}
buf.Append(ASCII.GetString(temp, 0, len));
string all = buf.ToString();
int endPos = all.IndexOf(“\r\n\r\n”);
if (endPos >= 0)
{
return all.Substring(0, endPos + 4);
}
}
return buf.ToString();
}
private static void EventWorker()
{
byte[] tempBuf = new byte[4096];
while (_runEvent)
{
if (_eslSocket == null || !_eslSocket.Connected)
{
//ClassSystem.FunCreateRunLog(“ESL链路断开,事件线程退出”);
break;
}
try
{
int len = _eslSocket.Receive(tempBuf);
if (len <= 0)
{
Thread.Sleep(20);
continue;
}
lock (_lockEventBuf)
{
_eventBuf.Append(ASCII.GetString(tempBuf, 0, len));
}
while (true)
{
string total;
lock (_lockEventBuf) total = _eventBuf.ToString();
int headerEnd = total.IndexOf(“\r\n\r\n”);
if (headerEnd < 0) break;
int contentLen = 0;
int clIndex = total.IndexOf(“Content-Length: “);
if (clIndex > -1)
{
int lineEnd = total.IndexOf(“\r\n”, clIndex);
if (lineEnd > clIndex)
{
string numStr = total.Substring(clIndex + 16, lineEnd – clIndex – 16).Trim();
int.TryParse(numStr, out contentLen);
}
}
int fullSize = headerEnd + 4 + contentLen;
if (total.Length < fullSize) break;
string oneEvent;
lock (_lockEventBuf)
{
oneEvent = _eventBuf.ToString(0, fullSize);
_eventBuf.Remove(0, fullSize);
}
// 只内部解析处理事件,【不再打印事件原文日志】
ProcessEvent(oneEvent);
}
}
catch (SocketException)
{
lock (_lockEventBuf) _eventBuf.Clear();
Thread.Sleep(80);
}
catch (Exception ex)
{
//ClassSystem.FunCreateRunLog($”事件线程异常:{ex.Message}”);
Thread.Sleep(200);
}
}
//ClassSystem.FunCreateRunLog(“ESL事件监听线程终止”);
}
private static void ProcessEvent(string evt)
{
string evtName = GetHeader(evt, “Event-Name”);
if (evtName == “BACKGROUND_JOB”)
{
string jobUuid = GetHeader(evt, “Job-UUID”);
string callUuid = GetHeader(evt, “Unique-ID”);
if (string.IsNullOrEmpty(jobUuid) || string.IsNullOrEmpty(callUuid))
return;
string ext = null;
if (!_jobMap.TryRemove(jobUuid, out ext))
{
Thread.Sleep(15);
if (!_jobMap.TryRemove(jobUuid, out ext))
{
Thread.Sleep(15);
if (!_jobMap.TryRemove(jobUuid, out ext))
{
//ClassSystem.FunCreateRunLog($”警告:BACKGROUND_JOB[{jobUuid}] 任务不存在,事件超前或已处理”);
return;
}
}
}
if (_extCallUuid.TryAdd(ext, callUuid))
{
//ClassSystem.FunCreateRunLog($”音柱{ext}成功分配通话ID:{callUuid}(BACKGROUND_JOB匹配)”);
}
else
{
//ClassSystem.FunCreateRunLog($”音柱{ext}通话ID存入失败,状态已存在”);
}
return;
}
if (evtName == “CHANNEL_HANGUP_COMPLETE”)
{
string hangUuid = GetHeader(evt, “Unique-ID”);
foreach (var kv in _extCallUuid)
{
if (kv.Value == hangUuid)
{
_extCallUuid.TryRemove(kv.Key, out _);
//ClassSystem.FunCreateRunLog($”音柱{kv.Key}通话挂断,释放ID:{hangUuid}”);
break;
}
}
return;
}
}
private static string GetHeader(string src, string key)
{
string search = $”{key}: “;
int pos = src.IndexOf(search);
if (pos < 0) return string.Empty;
int lineEnd = src.IndexOf(“\r\n”, pos);
if (lineEnd < 0) lineEnd = src.Length;
return src.Substring(pos + search.Length, lineEnd – pos – search.Length).Trim();
}
private static void Cleanup()
{
_runEvent = false;
_eslSocket?.Close();
_eslSocket?.Dispose();
_eslSocket = null;
lock (_lockEventBuf) _eventBuf.Clear();
}
#endregion
}
4、调用方式
应用程序启动事件里,调用: ClassSIP.FunInitSip();
需要播放的代码调用: ClassSIP.CallSingleSpeaker(valueSipID, valueAudioFilePath))
//valueSipID为SIP帐号,valueAudioFilePath为要播放的音频文件
5、音频文件转换
文字转语音 https://ttsmaker.cn/
音频文件转换 https://g711.org/ PCMU第一个 PCMA第二个


