如何使用 C# 和 SIP、SDP、RTP 和 RTCP 构建 .NET 软电话






4.79/5 (15投票s)
关于如何轻松构建软电话应用程序的论文和教程。
引言
如今,VoIP(网络电话)技术的快速普及是可以预见的。您可以在越来越多的领域找到 VoIP 解决方案。其中一个可能的应用模式就是构建一个简单的互联网电话程序。基于这些事实,我决定基于以下知识和需求构建我自己的 VoIP 电话应用。
所使用的代码应支持最新的稳定 .NET 技术、C# 编程语言,并且易于混淆。[3]
VoIP 呼叫的两个基本协议是 SIP [1] 和 H323 [2] 协议。这两个协议都能够利用其他协议在参与者之间建立视听通信。
我决定使用 SIP 协议,因为它易于实现,并且对通信过程的理解也更容易。此外,SIP 协议不继承 PSTN 网络的功能。详细的比较也探讨了这个问题。
因此,在设计决策之后,很自然地就决定使用 SIP、SDP、RTP 和 RTCP 协议:SIP(会话发起协议)用于在各方之间创建会话。SDP(会话描述协议)用于描述多媒体通信会话。RTP(实时应用程序传输协议)定义了媒体数据的传输。通信过程是通过使用 SIP、SDP 然后 RTP 协议建立起来的。
实验
在我实验的第一阶段,我决定编写一个自己的软电话。它从 SIP 协议的最小实现开始,然后我开发了 SIP 消息的最小表示(换句话说,我开发了包含在普通 SIP 消息中的 SIP 头部,例如 Via、Contact、From、To、Call-ID)。之后,我成功地在 INVITE 级别建立了呼叫。
到目前为止,我可以轻松快速地继续,但随后我遇到了两个问题:一是某些情况下收到的 INVITE 消息不起作用。二是用于媒体协调的 SDP 协议缺失或等待实现,负责媒体通信的 RTP 协议也缺失。然后,由此产生了以下软件架构的构想。
+-----------------------------+
| UserAgent [4] |
+--------------+--------------+
|SIP/SDP [4][5]| RTP [6] |
+--------------+--------------+
| Network layer |
+-----------------------------+
由于此软件架构只有五分之二已完成,我开始在互联网上搜索组件。网上有一些不错的 SDP 实现,但 RTP 实现也包括网络通信。这个事实会使标准化使用变得困难。当我处理第一个问题时,我发现了一个名为“SIP 协议的搭便车指南”[8] 的 SIP 指南。那时我决定放弃使用我自己编写的组件来实现所需的应用。
在我实验的第二阶段,我一直在寻找提供复杂 SIP 协议处理解决方案的外部组件。互联网上大多数可用的 SDK 都不能满足上述要求。它们无法正确使用,难以使用,或者需要过多的技术知识,或者是包装的 COM 对象。
经过我的搜索,我找到了 Ozeki 提供的解决方案。Ozeki VoIP SIP SDK 提供了易于使用的界面;此外,它还通过一个模拟软电话对象来帮助测试。在软件开发生命周期的 MockUp 部分,它使得对适当组件和模型的测试更加容易。此外,随机事件模拟现实,并创建真实的场景,而无需进行实际的电话呼叫。
在我实验的第三阶段,我了解并开始使用选定的组件。现在我想总结一下结果和经验。由于本文的目的不是展示 Windows 声音管理,声音管理将以一种简单而引人注目的方式呈现。基于 Ozeki VoIP SIP SDK 网站 [9][10] 上提供的示例代码,借助一个能够务实地处理 VoIP 呼叫的组件,您可以获得一个透明、易用且简单的代码。
Ozeki VoIP SIP SDK
为了简化,我将向您展示一个忽略 GUI 实现和音频设备技术细节处理的程序。通过在控制台应用程序中展示 SDK 用法,可以轻松解决这些细节带来的问题。通过即时返回接收到的音频数据,也可以解决声音处理问题。因此,代码专注于事件处理和结构对象的介绍。
为此,我们需要熟悉可用的工具。在抽象层中间是 IPhoneCall
。您可以在 Ozeki 网站上提供的文档中找到有关 IPhoneCall
的更多信息。可以将一个监听器附加到 Phonecall
对象上,这类似于 Observer
模式。尽管如此,附件和分离仍需要程序员通过 AttachListener
和 DetachListener
扩展方法来完成。此外,所有事件类型都通过 VoIPEventArgs
进行统一处理。
public interface IPhoneCallListener
{
void CallErrorOccured(object sender, VoIPEventArgs<CallError> e);
void CallStateChanged(object sender, VoIPEventArgs<CallState> e);
void DtmfReceived(object sender, VoIPEventArgs<DTMF> e);
void MediaDataReceived(object sender, VoIPEventArgs<VoIPMediaData> e);
void PlainMediaDataReceived(object sender, VoIPEventArgs<EncodedMediaData> e);
}
在代表性电话呼叫对象的活动生命周期中,可能会出现各种情况。这些情况列在 IPhoneCallListener
中。这些函数名称不言自明,因此在此不再赘述。下面的示例可以为您提供指导。
class PongCallListener : IPhoneCallListener
{
public void DtmfReceived(object sender, VoIPEventArgs<DTMF> e)
{
var dtmf = e.Item;
var call = (PhoneCall)sender;
Console.WriteLine("Dtmf received");
call.SendDTMFSignal(VoIPMediaType.Audio, e.Item);
}
在此示例中,我们创建了一个简单的 PhoneCallListener
对象。一旦收到一些信息,它就会将其发送给对方。DTMF 信号发送是一个例子。
public void CallErrorOccured(object sender, VoIPEventArgs<CallError> e)
{
var call = (PhoneCall)sender;
Console.WriteLine("Call error occurred: " e.Item);
}
如果在呼叫配置过程中发生错误,错误的目的将被写在屏幕上。
public void MediaDataReceived(object sender, VoIPEventArgs<VoIPMediaData> e)
{
var call = (PhoneCall)sender;
call.SendMediaData(e.Item.MediaType, e.Item.PCMData);
}
已收到纯 PCM 格式的数据。这意味着 SDK 不仅可以处理音频,还可以处理其他媒体类型的数据。在这里,我们只是将接收到的数据发送回发送方,从而让他大吃一惊。
public void CallStateChanged(object sender, VoIPEventArgs<CallState> e)
{
var call = (PhoneCall)sender;
Console.WriteLine("Call state changed: " e.Item);
if (e.Item > CallState.InCall)
call.DetachListener(this);
}
呼叫的状态可能会发生变化。如果状态不为 InCall
,即以某种方式结束了,我们挂断电话,或者对方挂断了电话。如果发生这些情况,则值得使用上述 DetachListener
方法将 PhoneCallListener
对象从 PhoneCall
对象中移除。
public void PlainMediaDataReceived(object sender, VoIPEventArgs<EncodedMediaData> e)
{
}
}
如果来电的数据是加密形式的,那么在应用程序运行时,我们使用了 IPhoneCall.PlainMediaData
属性。在这种情况下,我们无需执行任何操作。
因此,我们最需要处理的设备是实现 IPhoneCall
接口的对象,以及附加到它的 IPhoneCallListener
对象。通过这种方式,我们获得了适当的创造性自由。
pclass Program
{
我们还需要创建电话呼叫。为了做到这一点,我们需要一个程序。
static Dictionary<string,IPhoneCall> Calls;
static PongCallListener FunnyCallListener;
该程序包含活动呼叫的 Dictionary
,并且我们只使用一个 PongCallListener
。
static void Main(string[] args)
{
ISoftPhone SoftPhone = new SoftPhone("", 5000, 8000, 5060);
//ISoftPhone SoftPhone = new VoIP.SDK.Mock.ArbSoftPhone();
SoftPhone.IncommingCall = (SoftPhone_IncommingCall);
IPhoneLine PhoneLine = null;
FunnyCallListener = new PongCallListener();
Calls = new Dictionary<string, IPhoneCall>();
我们实例化一个处理呼叫的 SoftPhone
对象。如果我们厌倦了使用真实呼叫测试我们的应用程序,那么我们可以使用接收到的 ArbSoftPhone
,它可以创建随机情况。
在我们的 SoftPhone
完成后,我们订阅来电事件,顾名思义,该事件在有来电时触发。在参数中,单独找到呼叫对象。
我们还需要一个电话线路,这就是 IPhoneLine
接口。在此,我想补充一点,SDK 可以处理多条并行线路,并在其上进行多次并行呼叫。然后,我们实例化我们将在程序运行期间附加到每个呼叫的 CallListener
对象。Calls
字典将 Call string
s 分配给呼叫对象。
Console.WriteLine("Be funny!");
Console.Write("Display name: "); string displayName = Console.ReadLine();
Console.Write("Username: "); string username = Console.ReadLine();
Console.Write("Register name: "); string registerName = Console.ReadLine();
Console.Write("Register password: "); string registerPassword = Console.ReadLine();
Console.Write("Domain server: "); string domainServer = Console.ReadLine();
我们从用户那里读取注册信息。
string[] domains = domainServer.Split(':');
int port = 5060;
if (domains.Length == 2)
port = Int32.Parse(domains[1]);
SIPAccount account = new SIPAccount(true, displayName, username, registerName,
registerPassword, domains[0], port);
PhoneLine = SoftPhone.CreateAndRegisterPhoneLine(account);
我们检查给定的域中是否有端口,如果没有,则在注册期间使用默认的 5060 端口。为此,我们需要创建一个 SIPAccount
对象,然后使用此对象,向 SoftPhone
请求一个 IPhoneLine
对象。在此 IPhoneLine
对象上,我们开始注册过程。
while (true)
{
然后,就是有趣的部分了……
string statement = Console.ReadLine().Trim();
if (statement.StartsWith("exit"))
break;
……直到我们厌倦为止。我们从键盘读取一个 string
。如果它是“exit”,我们就退出;如果是其他内容……
if (!Calls.ContainsKey(statement))
{
if (PhoneLine.RegisteredInfo == PhoneLineInformation.RegistrationSucceded)
{
IPhoneCall Call = SoftPhone.CreateCallObject(
PhoneLine, statement, FunnyCallListener
);
Calls.Add(statement, Call);
Call.CallStateChanged = (Call_CallStateChanged);
Call.Start();
}
}
}
……我们检查是否已将呼叫对象附加到收到的 string
,如果是,则什么也不发生;如果不是,我们检查我们的电话线路是否已成功注册。如果已注册,我们就用 SoftPhone
创建一个电话对象,它将只在我们的一个电话线路上呼叫。这些呼叫将针对键入的电话号码创建,并将包含在 DialInfo
的属性中。
我们将键入的电话号码附加到创建的对象,我们订阅呼叫状态转换,以便在对方挂断时,我们能收到通知,然后就可以将其从 Calls 中移除。
然后我们开始呼叫。我们不断重复这个过程,直到我们厌倦为止。
foreach (IPhoneCall call in Calls.Values)
call.HangUp();
SoftPhone.Close();
}
退出时,我们挂断所有正在进行的呼叫。
之后,我们还需要事件处理程序。这些是用于处理来电。此外,它们还负责在特定呼叫结束时从呼叫对象字典中移除。
static void SoftPhone_IncommingCall(object sender, VoIPEventArgs<IPhoneCall> e)
{
e.Item.AttachListener(FunnyCallListener);
Calls.Add(e.Item.DialInfo, e.Item);
e.Item.CallStateChanged = (Call_CallStateChanged);
e.Item.Accept();
}
我们立即将我们的 CallListener
附加到传入的呼叫。我们将其添加到 Calls 中。然后我们还添加了更改状态转换函数。我们自动接受传入的呼叫。
static void Call_CallStateChanged(object sender, VoIPEventArgs<CallState> e)
{
if (e.Item > CallState.InCall)
{
IPhoneCall call = sender as IPhoneCall;
if (call == null)
return;
Calls.Remove(call.DialInfo);
call.DetachListener(FunnyCallListener);
call.CallStateChanged -= (Call_CallStateChanged);
}
}
}
如果收到的 CallState
大于 InCall
,则呼叫已结束,这是我们感兴趣的事件。我们根据拨号信息将呼叫对象从 Calls 中移除,然后像 Call_CallStateChanged
事件处理程序一样,将其从呼叫对象中移除 CallListener
。CallState
是一个 enum
,它对呼叫状态进行排序。例如,它从 Setup
到 InCall
,经过 Completed
,这就是为什么可以使用比较运算符,任何表示呼叫结束的都大于 InCall
。
该示例展示了开发一个能够处理电话呼叫的、通常很复杂的应用程序是多么简单快捷。扩展仅取决于 IPhoneCallListener
的实现。
摘要
总而言之,实现自己的 VoIP SoftPhone
需要花费大量时间和精力。因此,使用先前编写的组件是有效的。在研究了许多 SDK 后,最容易理解的是 Ozeki 提供的解决方案。正如示例所示,通过在接口中编写、在类中实现,它以最简单的方式处理电话呼叫。正如阿尔伯特·爱因斯坦所说:“一切都应该尽可能简单,但不能更简单。”
您无需担心实现细节。程序员可以对电话呼叫进行的所有操作,都集中在这里,并且其状态在一个地方定义。因此,您的计划可以轻松实现,因为您不必陷入技术细节的泥潭。它就像一、二、三一样简单。我请求一部电话和一条或多条线路,然后我就可以呼叫或被呼叫。然而,本文大量引用了网络页面上的文档,从中可以获得更多信息。我向所有人推荐这个解决方案。
参考文献
[1] http://en.wikipedia.org/wiki/Session_Initiation_Protocol
[2] http://en.wikipedia.org/wiki/H323
[3] http://en.wikipedia.org/wiki/Obfuscated_code
[4] http://tools.ietf.org/html/rfc3261
[5] http://tools.ietf.org/html/rfc4566
[6] http://tools.ietf.org/html/rfc3550
[7] http://tools.ietf.org/html/rfc3551
[8] http://tools.ietf.org/html/rfc5411
[9] http://www.voip-sip-sdk.com
[10] http://www.voip-sip-sdk.com/index.php?owpn=98
历史
- 2011年3月18日:初始发布