65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.79/5 (15投票s)

2011年3月18日

CPOL

9分钟阅读

viewsIcon

142227

downloadIcon

11638

关于如何轻松构建软电话应用程序的论文和教程。

引言

如今,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 模式。尽管如此,附件和分离仍需要程序员通过 AttachListenerDetachListener 扩展方法来完成。此外,所有事件类型都通过 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 strings 分配给呼叫对象。

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,它对呼叫状态进行排序。例如,它从 SetupInCall,经过 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日:初始发布
© . All rights reserved.