网络自动打铃【一】——客户端

        最近在写一个小程序,说小也不是很小,麻雀虽小五脏俱全。就是实现C/S模式的打铃,一个TCP服务器和若干客户端,先把客户端的实现说一下,对音频处理不熟悉,走了不少弯路,希望能以此文章帮到有同样需求的。但在以实装为目的编写这个东西之前,需要知道这并不可靠,它不能保证你的每个电脑都开着,即使开着也可能休眠,即使不让休眠也不能保证系统音量大小,即使很容易设置系统音量,也不能保证音箱的音量旋钮位置、音箱开没开…………

一、总体规划

1、服务器(窗体应用):负责分发铃声、实时音频(具有混音功能,但实际根本不用写混音代码就能混音- -!!!)、编制计划等等吧,附加的小功能还一大堆,不一一列举了。

2、客户端(WINDOWS服务):播放铃声音乐、实时音频、调节系统音量等。

二、NUGET

1、NAudio:音频处理、调节系统音量、“混音”等功能

2、WatsonTcp:网络功能

三、服务和服务管理

        首先,客户端无窗体、需自启、有足够权限操作输出设备、读写文件……假装有一大堆理由之后,就写个服务呗。

1、创建解决方案——WIN服务(.NET FRAMEROWK 4.7.2)

2、NUGET俩库

3、在解决方案中添加新项目——服务管理窗体

4、在WIM服务中写入代码

    Dim th As Timer

    Protected Overrides Sub OnStart(ByVal args() As String)
        ' 请在此处添加代码以启动您的服务。此方法应完成设置工作,
        ' 以使您的服务开始工作。
        Try
            Dim ipstr As String = Registry.LocalMachine.OpenSubKey("SOFTWARE").OpenSubKey("AutoAudio").GetValue("ServerIPPort")
            '获取服务器IP和PORT
            Dim ipport = ipstr.ToString.Split(":")
            If ipport.Length = 2 Then
                '连接服务器
                TCPClient.Init(ipport(0), CInt(ipport(1)))
            Else
                log("ip err:" & ipstr)
            End If
            th = New Timer(AddressOf MTimedEvent)
            th.Change(0, 100)
        Catch ex As Exception
            log(ex.ToString)
        End Try
    End Sub

    Protected Overrides Sub OnStop()
        On Error Resume Next
        TCPClient.DisConnect()
        th.Dispose()
    End Sub

    Private Sub MTimedEvent()
        Try
            If Not TCPClient.Connected Then
                TCPClient.Connect()
            Else
                GC.Collect()
            End If
            '算了,不锁定播放时音量了,人家有人家的*
        Catch ex As Exception
            log(ex.ToString)
        End Try
    End Sub

    Private Sub log(msg As String)
        Using stream As FileStream = New FileStream("log.txt", FileMode.Append)
            Using writer As StreamWriter = New StreamWriter(stream)
                writer.WriteLine(DateTime.Now.ToString & ":" & msg)
            End Using
        End Using
    End Sub

就是说服务里不让用各种窗体控件,懒得很就用了Thread的Timer。就干一件事:客户端连接。不要迷惑那个注册表,那是服务管理器界面上一个textbox而已。需要注意的是Windows服务编写的时候要尽可能避免错误导致服务终止,但并不是try就行的,那些try是经过充分测试修正了各种可以预见的错误之后添加的——为了防止一些没想到的错误,并且最好有一个log记录功能——这会很好的帮你发现并记录错误。但log也要注意,直接写在当前目录下就可以了(我的是syswow64),也不是每个计算机都有c:d:吧?

5、TPCClient的逻辑


Public Class TCPClient

    Shared MyClient As WatsonTcpClient
    Shared lastConnectTime As DateTime
    Shared RingPlayer As AudioPlayer
    Shared RecordPlayer As AudioPlayer
    Shared Sub Init(ip As String, port As Integer)
        RingPlayer = New AudioPlayer
        RecordPlayer = New AudioPlayer
        MyClient = New WatsonTcpClient(ip, port)
        AddHandler MyClient.Events.MessageReceived, AddressOf MessageReceived
        AddHandler MyClient.Events.ServerConnected, AddressOf ServerConnected
        AddHandler MyClient.Events.ServerDisconnected, AddressOf ServerDisconnected
        AddHandler MyClient.Events.ExceptionEncountered, AddressOf ExceptionEncountered
        MyClient.Callbacks.SyncRequestReceived = AddressOf SyncRequestReceived
        lastConnectTime = Now.AddSeconds(-MyClient.Settings.ConnectTimeoutSeconds * 2)
    End Sub
    Shared Sub Connect()
        Try
            Dim sp As TimeSpan = Now - lastConnectTime
            If sp.TotalSeconds > MyClient.Settings.ConnectTimeoutSeconds * 2 Then
                lastConnectTime = Now
                MyClient.Connect()
            End If
        Catch ex As Exception
        End Try
    End Sub

    Shared Sub DisConnect()
        If MyClient IsNot Nothing Then MyClient.Disconnect()
    End Sub

    Shared Function Connected() As Boolean
        Return MyClient IsNot Nothing AndAlso MyClient.Connected
    End Function

    Private Shared Sub ServerConnected(sender As Object, args As ConnectionEventArgs)

    End Sub

    Private Shared Sub ServerDisconnected(sender As Object, args As DisconnectionEventArgs)

    End Sub

    Private Shared Sub MessageReceived(sender As Object, args As MessageReceivedEventArgs)
        Select Case args.Metadata("Type").ToString
            Case "Play"
                RingPlayer.ClearBuff()
                If IO.File.Exists(args.Metadata("Ring")) Then
                    RingPlayer.WriteProvider(IO.File.ReadAllBytes(args.Metadata("Ring")))
                Else
                    '这个地方不知道为啥元数据无法到达服务器
                    'Dim timer As New Threading.Timer(New Threading.TimerCallback(AddressOf QRingFile), args.Metadata("Ring"), 0, 0)
                    MyClient.SendAsync(UTF8Encoding.UTF8.GetBytes("RingFile:" & args.Metadata("Ring")))
                End If
            Case "Stop"
                RingPlayer.ClearBuff()
            Case "RecordData"
                RecordPlayer.WriteProvider(args.Data)
            Case "RingData"
                IO.File.WriteAllBytes(args.Metadata("FileName"), args.Data)
            Case "RingDataAndPlay"
                IO.File.WriteAllBytes(args.Metadata("FileName"), args.Data)
                RingPlayer.WriteProvider(args.Data)
            Case "SetRingVolume"
                RingPlayer.SetVolume(args.Metadata("Volume"))
            Case "SetRecordVolume"
                RecordPlayer.SetVolume(args.Metadata("Volume"))
        End Select
    End Sub

    Private Shared Function SyncRequestReceived(req As SyncRequest) As SyncResponse
        Return New SyncResponse(req, req.Data)
    End Function

    Private Shared Sub ExceptionEncountered(sender As Object, args As ExceptionEventArgs)
    End Sub

    Protected Overrides Sub Finalize()
        On Error Resume Next
        MyClient.Dispose()
        MyBase.Finalize()
    End Sub
End Class

其实不难理解,就是接到命令之后执行呗:

当接到命令播放时先看看本地有没有对应文件,没有则发一个请求

当接到停止命令时,清空播放缓存,自然就不播放了

当接到实时录音数据时添加到播放录音缓存

当接到铃声数据时写入到文件

当接到铃声数据并播放时接收数据、保存并添加到播放缓存

当接受到设置音量时,设置音量。

唯一值得注意的就是在服务中每隔0.1秒测试一次连接,这明显是短的,如果不需要添加其它功能,那么没有必要这么短,直接设置到比MyClient.Settings.ConnectTimeoutSeconds大就可以了。但规划时我想在这里做一些让客户端讨厌的事情——因为明显可以预见有的客户端会很讨厌的胡捣鼓这个程序。所以,在这里另外用了一个时间判断——这是必要的,一定不要在超时时间内再次连接服务器,否则可能无法正确建立连接。

6、音频播放和混音

        首先,“混音”是个没有代码的东西——我写了一些混音程序,也获得了比较好的效果,但后来发现只要每个音频流都对应一个waveOut就可以不用写混音代码:它们会同时、正确的工作,所以,一起播就完了混什么音啊,于是你看到的TCP部分有两个音频播放:


Public Class AudioPlayer
    Const SampleRate As Integer = 48000         '样本波特率(样本包括转化而来的文件和录音)
    Const SampleBits As Integer = 16            '样本深度
    Const SampleChannels As Integer = 1         '样本声道数
    Dim waveformat As WaveFormat = New WaveFormat(SampleRate, SampleBits, SampleChannels)   '样本音频格式
    Dim waveProvider As BufferedWaveProvider    '文件缓冲
    Dim wavOut As WaveOutEvent

    Sub New()
        waveProvider = New BufferedWaveProvider(waveformat)     '文件缓存,读文件时开辟相同大小
        waveProvider.BufferLength = 1024 * 1024 * 32
        wavOut = New WaveOutEvent
        wavOut.Init(waveProvider)
        wavOut.Volume = 1.0
        wavOut.Play()
    End Sub

    Sub SetVolume(vol As Single)
        wavOut.Volume = vol
        Dim devEnum As MMDeviceEnumerator = New MMDeviceEnumerator()
        Dim defaultDevice = devEnum.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia)
        defaultDevice.AudioEndpointVolume.MasterVolumeLevelScalar = vol
    End Sub

    Sub WriteProvider(data() As Byte)
        waveProvider.AddSamples(data, 0, data.Length)
    End Sub

    Sub ClearBuff()
        waveProvider.ClearBuffer()
    End Sub

    Function GetBuffLen() As Integer
        Return waveProvider.BufferedBytes
    End Function

    Protected Overrides Sub Finalize()
        wavOut.Stop()
        wavOut.Dispose()
        MyBase.Finalize()
    End Sub

End Class

可以注意到,wavOut一直在工作——这没有任何问题——waveProvider中没有可用数据时是不会发出声音的,所以我们只需要如TCP处理一样只管添加数据就行了。然后是关于系统的播放音量调节,很多文章都指出这需要用API或注册表,但实际上NAudio.CoreAudioApi已经实现了这个东西,在SetVolume函数中首先设置了wavout以指定音量播放,而后通过NAudio.CoreAudioApi来设置系统播放音量——完全可以在系统托盘的音量调节看到效果。类似的,也可以设置麦克风的录制音量。

所以,在写这些代码之前,我很难想象:混音这么容易解决——甚至无需额外代码;调节系统音量只需3行代码(当然不嫌长硬抬杠你可以写一行,不,半行足够)。

7、服务管理程序

        这个网上有很多教程,我再总结一下。可以用两种方法去添加app.manifest以取得更高权限从而操作服务:

A、在工程属性——安全性里勾选“启用ClickOnce安全设置”,而后保存再去掉这个勾(不去掉运行不了)

B、手工添加一个应用清单

        但无论如何,都需要按文件中给的提示修改requestedExecutionLevel节点:

      <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
        <!-- UAC 清单选项
             如果想要更改 Windows 用户帐户控制级别,请使用
             以下节点之一替换 requestedExecutionLevel 节点。n
        <requestedExecutionLevel  level="asInvoker" uiAccess="false" />
        <requestedExecutionLevel  level="requireAdministrator" uiAccess="false" />
        <requestedExecutionLevel  level="highestAvailable" uiAccess="false" />

            指定 requestedExecutionLevel 元素将禁用文件和注册表虚拟化。
            如果你的应用程序需要此虚拟化来实现向后兼容性,则删除此
            元素。
        -->
        <requestedExecutionLevel  level="requireAdministrator" uiAccess="false" />

在WIN7中我没有得到明确的UAC提示,但它确实有效,WIN10中会有提示(选其它凭据运行)。但无论如何如果用管理员权限打开VS,再加载解决方案都是一个好办法。

    Dim serviceFilePath As String = My.Application.Info.DirectoryPath & "/AutoAudio.exe"
    Dim serviceName As String = "AutoAudio"
    ''' <summary>
    ''' 更新注册表
    ''' </summary>
    Sub RegUpDate()
        Try
            Registry.LocalMachine.DeleteSubKey("SOFTWARE\AutoAudio", True)
        Catch ex As Exception
        End Try
        Try
            Dim key = Registry.LocalMachine.OpenSubKey("SOFTWARE", True)
            Dim subkey = key.CreateSubKey("AutoAudio", True)
            subkey.SetValue("ServerIPPort", txtServerIPPort.Text, RegistryValueKind.String)
        Catch ex As Exception
        End Try
    End Sub

这是写注册表的部分,当打开上述UAC权限之后,可能你直接运行REGEDIT看不到你写入的键值,这时可以用管理员权限运行或者用读取注册表的代码测试一下取回的是否正确,正确就行了。

 

PS:服务管理器的其它代码我也是复制粘贴的,自己搜一下吧。记得在启动服务按钮中调用RegUpDate函数以在服务启动前写入注册表,让TCP连接能够知道连接哪个IPPORT(WatsonTcp的IpPort格式是ip:port)

上一篇:powerDesigner 把name项添加到注释(comment)


下一篇:深入浅出图神经网络 GCN代码实战