基于WaveX低级音频函数的实时语音通信

作者:Sender Su  来源:原创内容  发布日期:2007-04-03  最后修改日期:2022-10-09

编者注:

  虽然现在Microsoft说Windows低级音频函数已经obsoleted,DirectSound也已经大行其道,但其相对易用的特点还是值得学习的。本文的另一个特点是作者结合了局域网实时音频传输的课题给出了这些API函数的使用实例,使得全文不仅仅是单纯的API函数使用介绍,从而具有相当的实用价值。

未经作者许可,请勿转载!

1 摘要

2 背景介绍

3 相关数据结构

4 参数设置

5 基本操作流程

6 消息及处理

7 程序结构

8 网络拥塞控制策略

9 系统测试

10 结束语

附录A  WaveX API

1 摘要

  本文介绍了基于WaveX低级音频API采集音频及实时播放的技术。并对音频实时性和连 续性作了比较深入的分析。利用双/多缓冲技术和网络拥塞控制策略可很好的控制音频的实时性和连续性。

2 背景介绍

  WINDOWS下音频的采集,播放有三种模式:

  1)通过高级音频函数、媒体控制接口MCI[1、2]设备驱 动程序;

  2)低级音频函数MIDI Mapper、低级音频设备驱动(WaveX API);

  3)利用DirectX中的DirectSound;

  使用MCI的方法极其简便,灵活性较差;使用低级音频函数的方法相对来说难一点,但是能够对音频数据进行灵活的操 控;而采用DirectSound的方法,控制声音数据灵活,效果比前二者都好,但实现起来是三者中最难的。

  低层音频服务及重要的数据结构低级音频服务控制着不同的音频设备,这些设备包括 WAVE,MIDI和辅助音频设 备。低级音频服务包括如下内容:

  (1)查询音频设备;

  (2)打开和关闭设备驱动程序;

  (3)分配和准备音频数据块;

  (4)管理音频数据块;

  (5)应用MMTIME结构;

  (6)处理错误。

  WaveX低级音频函数的相关声明和定义在mmsystem.h头文件和Winmm.lib库 中。所以如果程序中用到这些函数,必须包含mmsystem.h这个头文件,同时导进Winmm.lib库。 如下:

#include "mmsystem.h"

#pragma comment(lib,"Winmm.lib")

  双/多缓冲技术可以很好的实现声音的快速连续采集和实时顺畅播放。采集声音时,缓冲满了会有一个消息,程序在响应这 个消息需要几毫秒~几十毫秒甚至更多的时间,假设为Xms,如果只使用一个缓冲,程序必须在响应完该消息才再次采集声音,那么 在这Xms的时间里,没有采集到任何声音;声音的播放也是一样的道理,这样声音就会不连续。因此双缓冲或多缓冲技术是必要的, 让输入和输出设备可以循环使用这些缓冲,当程序在响应某块缓冲数据已满或播放完毕消息时,声卡可以继续往下一块缓冲添加数据或播放下一块缓冲的数据,如此 循环保障声音的连续性。

3 相关数据结构

  声音在采集(录音)和播放的时需要有一些统一的格式,包括音频格式类型,声道,采样率等信息。下面的数据结构具体描述了该格式:

typedef struct tWAVEFORMATEX

{

    WORD        wFormatTag;

    WORD        nChannels;

    DWORD       nSamplesPerSec;

    DWORD       nAvgBytesPerSec;

    WORD        nBlockAlign;

    WORD        wBitsPerSample;

    WORD        cbSize;

} WAVEFORMATEX, *PWAVEFORMATEX,

  NEAR *NPWAVEFORMATEX, FAR *LPWAVEFORMATEX;

  其中,wFormatTag是音频格式类型,nChannels是声道数,nSamplesPerSec是 采样频率,nAvgBytesPerSec是每秒钟的字节数,nBlockAlign是每个样本的 字节数,wBitsPerSample是每个样本的量化位数,cbSize是附加信息的字节大小。

  在打开声卡输入和输出设备之前,必须对音频的相关参数进行设置。在后面章节中将给出WAVE_FORMAT_PCM格 式音频的详细参数设置。

  音频数据块有一个头结构,这个结构包含了音频数据缓冲的地址,大小,已录音数据大小等信息和其他各种控制标志。这个结构适用于音频的输入(录 音)和输出(播放)缓冲中。下面是该结构的详细信息:

typedef struct wavehdr_tag

{

    LPSTR       lpData;

    DWORD       dwBufferLength;

    DWORD       dwBytesRecorded;

    DWORD       dwUser;

    DWORD       dwFlags;

    DWORD       dwLoops;

    struct      wavehdr_tag FAR *lpNext;

    DWORD       reserved;

} WAVEHDR, *PWAVEHDR, NEAR *NPWAVEHDR, FAR *LPWAVEHDR;

  其中,lpData是指定的缓冲块地址,dwBufferLength是指定的缓冲块大 小,dwBytesRecorded是已录音数据大小,dwUser是用户数据,dwFlags是 控制标志,表明缓冲的使用状态,dwLoops是音频输出时缓冲数据块循环的次数,lpNext和reserved是 系统保留数据。在程序实现时,通过设置或修改这个结构的相关参数来实现对音频输入和输出缓冲区的控制。

  程序中定义了一个队列结构,用来存储网络中接收到的音频数据,其结构如下:

struct CAudioOutData

{

    short *lpdata;

    DWORD dwLength;

};

struct CAudioOutData m_AudioDataOut[50];

  其中,lpdata是数据块地址,dwLength是数据块大小。通过调整m_AudioDataOut下 标实现队列的循环过程。

4 参数设置

  声卡输入和输出的音频属性可定义如下:

m_waveformt.wFormatTag        =  WAVE_FORMAT_PCM;

m_waveformt.nChannels         =  1;

m_waveformt.nSamplesPerSec    =  8000;

m_waveformt.wBitsPerSample    =  16;

m_waveformt.cbSize            =  0;

m_waveformt.nBlockAlign       =  2;

m_waveformt.nAvgBytesPerSec   = 16000;

  设置的音频格式类型是PCM格式,单通道,8000HZ的采样率,每秒采集的数据大小为16000bytes.其 中,存在着下面的关系:

nBlockAlign       =  nChannels * wBitsPerSample / 8 ;  

nAvgBytesPerSec   =  nSamplesPerSec * nBlockAlign ;

  音频数据块头结构可定义如下:

pWaveHdr->lpData            = 指定缓冲的地址;

pWaveHdr->dwBufferLength    = 指定缓冲的大小;

pWaveHdr->dwBytesRecorded   = 0 ;

pWaveHdr->dwUser            = 0 ;

pWaveHdr->dwFlags           = 0 ;

pWaveHdr->dwLoops           = 1 ;

pWaveHdr->lpNext            = NULL;

pWaveHdr->reserved          = 0;

m_waveformt.nAvgBytesPerSec = 16000;

  每次为输入或输出设备准备缓存的时候,都需要设置缓存数据块的头结构。

5 基本操作流程

  调用WaveX 低级音频函数API启动声卡录音的基本操作步骤如下图所示:

               打 开录音设备: waveInOpen

                           ↓

        为录音设备准备缓存: waveInPrepareHeader

                           ↓

          为输入设备增加缓存: waveInAddBuffer

                           ↓

                 启动录音: waveInStart

                           ↓

            清除缓存: waveInUnprepareHeader

                           ↓

                 停 止录音: waveInReset

                           ↓

               关 闭录音设备: waveInClose

图3.1    录音流程

  在这个过程中,会产生很多WM_WIM_***格式的WINDOWS消息。程序通过捕获 这些消息对缓存,数据和设备进行处理,具体可见后面章节。录音设备打开时,可以指定消息的响应方式:回掉函数,线程ID,WINDOWS窗 口句柄或事件句柄等。

MMRESULT waveInOpen(

    LPHWAVEIN phwi,         //输入设备句柄

    UINT uDeviceID,         //输入设备ID

    LPWAVEFORMATEX pwfx,    //录音格式指针

    DWORD dwCallback,       //处理消息的回调函数或窗口句柄, 线程ID等

    DWORD dwCallbackInstance,   //通常为0

    DWORD fdwOpen           //处理消息方式的符 号位

);

  调用WaveX 低级音频函数API启动声卡输出的基本操作步骤如下图所示:

                 打开输出设备: waveOutOpen

                              ↓

          为输出设备准备缓存: waveOutPrepareHeader

                              ↓

             写数据导输出设备缓存: waveOutWrite

                              ↓

            清除输出缓存: waveOutUnprepareHeader

                              ↓

                   停止输出: waveOutReset

                              ↓

                 关闭输出设备: waveOutClose

图3.2    播放流程

  同录音一样,在音频输出过程中也有一系列的消息,程序通过捕获这些消息对缓存,数据和设备进行处理。具体操作见后面章节。

6 消息及处理

  WINDOWS下提供消息映射实现事件的处理。低级音频函数处理声音数据块也正要归功于WINDOW的 消息映射机制。

  图6.1 和6.2分别描述了声卡录音和播放过程中产生的消息:

        WM_WIM_OPEN

            │     ↘

            │       音频输入 设备打开消息

            ↓

        WM_WIM_DATA

            │     ↘

            │       缓冲录满或停止录音消息

            ↓

        WM_WIM_CLOSE

            │      ↘

            ↓        音频输入 设备关闭消息

6.1   录 音过程消息

        WM_WOM_OPEN

            │     ↘

            │       音频输出 设备打开消息

            ↓

        WM_WOM_DONE

            │     ↘

            │       缓冲播放完或停止输出消息

            ↓

        WM_WOM_CLOSE

            │      ↘

            ↓        音频输出 设备关闭消息

6.2   播 放过程消息

  在打开音频输入或输出设备消息处理中,可对一些变量进行初始化;在WM_WIM_CLOSE消息的处理中主要是调用WaveInUnprepareHeader释 放输入设备对应的缓存和其他资源;在WOM_CLOSE消息的处理中主要是调用WaveOutUnprepareHeader释 放输出设备对应的缓存和其他资源;在WM_WIM_DATA消息处理中,处理完录入的数据后需要重新为输入设备添加缓冲;在WM_WIM_DATA消 息处理中,首先清空已经播放的数据,调整循环队列中接收音频指针和播放音频指针,然后从该队列中拷贝数据到输出缓存中。

7 程序结构

  系统的功能是实现在局域网内的点对点实时音频通信。音频的网络传输采用UDP方式。

  程序中为输入设备准备了两块缓冲,输出设备的缓冲块数可以通过修改宏进行调整,但至少是两块。程序使用多线程技术实现声音的采集发送和接收播 放。总体结构如下所示:

                              程序启动

                                 ↓

                            设置音频属性

                                 │

                               ↙  ↘

                       启动录音     启动音频包接收

                          ↓              ↓

                      发送音频包       启动播放

7.1   总 体结构图

  程序总体结构很简单,大体可以分为两部分:一是录音和发送,二是接收和播放。

(一) 录音发送部分的流程如下:

                        打 开音频输入设备(录音)

                                  ↓

                         为 输入设备准备两块缓存

                                  ↓

                               启 动录音

                                  │

    ┌─────────────→│

    │                          ↙  ↘

    │   捕捉到消息:WM_WIM_DATA      捕捉到消息:WM_WIM_CLOSE

    │              ↓                           ↓

    │   对已录数据进行G721编码        释放输入设备所有缓存

    │              ↓                           ↓

    │        发送 编码后数据           关 闭音频输入设备,结束

    │              ↓

    │    为输入设备重新指定缓存

    │              │

    └───────┘

7.2   录 音及发送流程图

  部分源码:

1)打开音频输入设备源码为:

waveInOpen(&hWaveIn,

    WAVE_MAPPER,

    &m_waveformin,

    (DWORD)waveInProc,

    NULL,

    CALLBACK_FUNCTION);

  其中,hWaveIn为音频输入句柄,WAVE_MAPPER表示让系统选择录音的声 卡,m_waveformin是音频格式,waveInProc是消息处理函数名,CALLBACK_FUNCTION表 示以函数调用的方式处理响应录音过程中的消息。

2)为输入设备添加缓冲的源码为:

// 为音频输入准备两个缓存:pBuffer1,pBuffer2, 大小为BUFFER_SIZE。

// hWaveIn为音频输入句柄

pWaveHdr1->lpData           = (LPTSTR)pBuffer1;

pWaveHdr1->dwBufferLength   = BUFFER_SIZE;

pWaveHdr1->dwBytesRecorded  = 0;

pWaveHdr1->dwUser           = 0;

pWaveHdr1->dwFlags          = 0;

pWaveHdr1->dwLoops          = 1;

pWaveHdr1->lpNext           = NULL;

pWaveHdr1->reserved         = 0;

// 准备头结构

waveInPrepareHeader(hWaveIn,pWaveHdr1,sizeof(WAVEHDR)) );

pWaveHdr2->lpData           = (LPTSTR)pBuffer2;

pWaveHdr2->dwBufferLength   = BUFFER_SIZE;

pWaveHdr2->dwBytesRecorded  = 0;

pWaveHdr2->dwUser           = 0;

pWaveHdr2->dwFlags          = 0;

pWaveHdr2->dwLoops          = 1;

pWaveHdr2->lpNext           = NULL;

pWaveHdr2->reserved         = 0;

// 准备头结构

waveInPrepareHeader(hWaveIn,pWaveHdr2,sizeof(WAVEHDR)) );

// 添加缓存

waveInAddBuffer (hWaveIn, pWaveHdr1, sizeof (WAVEHDR));

waveInAddBuffer (hWaveIn, pWaveHdr2, sizeof (WAVEHDR));

3)WM_WIM_DATA消息处理中为输入设备添加缓存源码为:

// dwParam1为消息处理函数参数

waveInPrepareHeader (hWaveIn, (PWAVEHDR)dwParam1, sizeof (WAVEHDR));

waveInAddBuffer (hWaveIn, (PWAVEHDR)dwParam1, sizeof (WAVEHDR));

  WM_WIM_CLOSE消息处理中释放缓存源码为:

waveInUnprepareHeader (hWaveIn, pWaveHdr1, sizeof (WAVEHDR));

waveInUnprepareHeader (hWaveIn, pWaveHdr2, sizeof (WAVEHDR));

(二)音频接收播放流程如下所示:

             打开音频输 出设备(播放)

                        ↓

          为输出设备准备n块缓存 (n >= 2)

                        │

                      ↙  ↘

                  ↙          ↘

             ↙                    ↘

            ↓                         ↓←────┐

         启动网络               循 环队列中音频     │

         音频数据                接 收指针位置      │

         接收线程                 是 否大于1   ──┘

            │                         │        N

    ┌──→│                         │Y

    │      ↓                         ↓

    │   接收数据      ┌─────→启动播放

    │   丢包处理      │              │

    │      │         │            ↙   ↘

    │      │         │        ↙           ↘

    │      ↓         │      ↓               ↓

    │调用G721对接收   │  捕捉到消息:    捕捉到消息:

    │的数据进行解码   │  WM_WIM_DATA     WM_WIM_CLOSE

    │      │         │      ↓               ↓

    │      ↓         │调整音频播放指针  释放所有音频

    │ 将解码后数据    │在循环队列中位置  输 出设备缓存

    │ 拷进循环队列    │      ↓               ↓

    │      │         │从循环队列中拷贝  关闭音频输出

    └───┘         │ 数据到输出缓存    设备,结束

                       │      ↓

                       └───┘

7.3   音 频接收及播放播放流程图

  部分源码:

  打开音频输出设备:

waveOutOpen(&hWaveOut,

    WAVE_MAPPER,

    &m_waveformout,

    (DWORD)waveOutProc,

    NULL,

    CALLBACK_FUNCTION);

  hWaveOut是输出设备句柄;WAVE_MAPPER表示让系统选择播放声卡;m_waveformout表 示输出音频格式;waveOutProc是音频输出消息处理函数名;CALLBACK_FUNCTION表 示以回掉函数的方式响应音频输出过程的消息。

  为音频输出准备缓冲并启动音频输出源码:

for( int i=0;i为输出 缓冲块数

{   // outBuffer[i]是每一块缓冲区的 首地址,为short类型

  pWaveHdrOut[i]->lpData            = (LPTSTR)outBuffer[i];

  pWaveHdrOut[i]->dwBufferLength    = OUTSIZE*sizeof(short);

  pWaveHdrOut[i]->dwBytesRecorded   = 0 ;

  pWaveHdr->lpNext                  = NULL ;

  pWaveHdr->reserved                = 0 ;

  m_waveformt.nAvgBytesPerSec  = 16000 ;

  pWaveHdrOut[i]->dwUser         = 0 ;

  pWaveHdrOut[i]->dwFlags         = 0 ;

  pWaveHdrOut[i]->dwLoops        = 1 ; 

  pWaveHdrOut[i]->lpNext          = NULL ;

  pWaveHdrOut[i]->reserved        = 0 ;

}

while(1)

{ // nReceive 是网络音频接收指针在循环队列中的位置

  if( nReceive > 1 )   // 当接收到的数据大于1帧时才启动声卡输出

  {

    for(int i=0;i

    { // 启动音频输出所有缓冲区块

      waveOutPrepareHeader(hWaveOut,pWaveHdrOut[i],sizeof(WAVEHDR)) ;

      waveOutWrite(hWaveOut,pWaveHdrOut[i],sizeof(WAVEHDR));

    }

    break ;

  }

  Sleep(100) ;

}

  WM_WOM_DONE消息处理中为播放缓冲准备数据源码为:

// dwParam1为消息处理函数参数;m_AudioDataOut是 音频接收循环队列首地址

// nAudioOut为音频播放指针在循环队列中位置

memcpy(

    ((PWAVEHDR)dwParam1)->lpData,

    (char )m_AudioDataOut[nAudioOut].lpdata,

    m_AudioDataOut[nAudioOut].dwLength*sizeof(short)

);

waveOutWrite(hWaveOut,(PWAVEHDR)dwParam1,sizeof(WAVEHDR));

  WM_WOM_CLOSE消息处理释放资源源码为:

for(int i=0;i释 放资源

{

  //释放输出头缓冲

  waveOutUnprepareHeader(hWaveOut,pWaveHdrOut[i],sizeof(WAVEHDR));

  if( pWaveHdrOut[i] )

  {

    free(pWaveHdrOut[i]) ;

    pWaveHdrOut[i] = NULL ;

  }

  if( outBuffer[i] )  // 释 放输出缓冲

  {

    free(outBuffer[i]) ;

    outBuffer[i] = NULL ;

  }

}

8 网络拥塞控制策略

  从网络中接收到的音频数据解码后存放在循环队列中,队列中缓存块数为50,每块大小为400Bytes(网 络音频数据一帧为100B,解码后为400B);声卡播放音频需要从循环队列中取数据,令接收指针 在循环队列中的位置为nR,播放指针为nP,两者之间的关系如图8.1所 示:

图8.1   循 环队列结构

  nP 和nR指向的位置一开始都是0。如果同时启动网络接 收和音频播放的话就会造成同一块内存的读写冲突,导致程序崩溃,所以程序中当nR>1时才启动音频播放。

  nR和nP的移动是异步的。当接收到一帧网络音频数据时nR = (nR+1)%50;而nP的移动需要视网络状态好坏而定。通过协调两者的移动步伐达到网络拥塞控制的目的。具 体如下:

  1)网络正常,即nR和nP的差距在2~8之 间,包括2和8,则nP = (nP+1)%50;

  2)网络繁忙,即nR指针移动过满,导致nR-nP < 2,则 nP保持不动,下一次播放的数据为空数据;

  3)网络较好,且因某些原因声卡输出慢了,即nR-nP > 8,则 np = (nP+abs(nR-nP)%50-5)%50 ;通过丢弃一些数据帧以保证声音的实时性,否则会造成声音延时过大,而且随着 时间推移会越来越大。

9 系统测试

  语音通信主要测试以下几个方面:

  1)实时性:话音是否有延时;

  2)连续性:话音是否连续,中间是否会断续;

  3)稳定性:话音是否会丢失,若会,丢失的是否严重;

  程序中某些参数是固定的:音频每次采集的大小为400B,编码后为100B;接收音频循 环队列块数为50。可调整的是循环队列每块缓存大小,声卡输出缓冲大小(两者的大小是一样的),以及声卡输出缓冲数目。假设缓 存大小为BUFFER,缓存块数为BUFFERNUM,通过测试有以下结果:

BUFFER(Bytes)

BUFFERNUM

话音效果

实时性

连续

稳定

400

2

不连续

稳定

400

4

有点不连续

稳定

400

8

一般

稳定

400

16

稳定

800

2

有点连续

稳定

800

4

一般

稳定

800

8

稳定

800

16

稳定

2000

2

有点延迟

稳定

2000

4

延迟大

稳定

2000

8

延迟较大

稳定

2000

16

延迟很大

稳定

4000

2

延迟大

稳定

4000

4

延迟较大

稳定

4000

8

延迟很大

稳定

4000

16

很差

稳定

  根据之前分析及上面测试结果,不难发现在局域网中,网络比较稳定,话音基本上不丢失。

  当缓冲区较小时,必须增大缓冲块数以保障话音的连续性;当缓冲较大时,必须减少缓冲块数以保证话音实时性。音频在网络拥塞控制时,如果nR移 动速度比nP快,则他们之间最大差距可能是8,若每块大小为4000B,则 就有4000×8=32000B数据的延迟。

  所以在实际应用中,可将缓冲大小和缓冲块数设置比较合适的值,例如:400,16;或800,8; 也可以调整网络拥塞控制策略优化话音效果,例如可将nR和nP的差值幅度调小作为网络正常的情况。

10 结束语

  网络拥塞控制策略和双/多缓冲技术是实现实时连续话音通信的关键技术。在实际应用中建立两个UDP套 接字:一个用于发送音频数据,一个用于接收音频数据,利用WaveX音频函数采集播放声音,可以很好的实现音频的实时通信

附录A  WaveX API

 

waveInGetNumDevs 返回系统中存在的波形输入设备的数量
waveInAddBuffer 向波形输入设备添加一个输入缓冲区
waveInGetDevCaps 查询指定的波形输入设备以确定其性能
waveInGetErrorText 检取由指定的错误代码标识的文本说明
waveInGetID 获取指定的波形输入设备的标识符
waveInGetPosition 检取指定波形输入设备的当前位置
waveInMessage 发送一条消息给波形输入设备的驱动器
waveInOpen 为录音而打开一个波形输入设备
waveInPrepareHeader 为波形输入准备一个输入缓冲区
waveInStart 启动在指定的波形输入设备的输入
waveInReset 停止给定的波形输入设备的输入,且将当前位置清零
waveInStop 停止在指定的波形输入设备上的输入
waveInUnprepareHeader 清除由waveInPrepareHeader函 数实现的准备
WaveInClose 关闭指定的波形输入设置
waveOutBreakLoop 中断给定的波形输出设备上一个循环,并允许 播放驱动取列表中的下一个块
waveOutClose 关闭指定的波形输出设备
waveOutGetDevCaps 查询一个指定的波形输出设备以确定其性能
waveOutGetErrorText 检取由指定的错误代码标识的文本说明
waveOutGetID 检取指定的波形输出设备的标识符
waveOutGetNumDevs 检取系统中存在的波形输出设备的数量
waveOutGetPitch 查询一个波形输出设备的当前音调设置
waveOutGetPlaybackRate 查询一个波形输出设备当前播放的速度
waveOutGetPosition 检取指定波形输出设备的当前播放位置
waveOutGetVolume 查询指定波形输出设备的当前音量设置
waveOutMessage 发送一条消息给一个波形输出设备的驱动器
waveOutOpen 为播放打开一个波形输出设备
waveOutPause 暂停指定波形输出设备上的播放
waveOutPrepareHeader 为播放准备一个波形缓冲区
waveOutRestart 重新启动一个被暂停的波形输出设备
waveOutSetPitch 设置一个波形输出设备的音调
waveOutSetPlaybackRate 设置指定波形输出设备的速度
waveOutSetVolume 设置指定的波形输出设备的音量
waveOutUnprepareHeader 清除由waveOutPrepareHeader函 数实现的准备
waveOutWrite 向指定的波形输出设备发送一个数据块

 

- END -


本文已获作者(林文焕)本人许可转载,其他转载请直接联系作者:linpder AT 163.com。

本栏目相关
  •  2008-11-10 Linux 音频 API 指南
  •  2007-04-03 基于WaveX低级音频函数的实时语音通信
  •  2001-09-13 Graph Editor 教程
  •  2001-09-20 Ogg Vorbis测试报告
  •  2005-11-27 Parametric Stereo/参量立体声简介
  •  2005-11-26 MPEG 1 Layer-2+SBR对比MPEG 1 Layer-2
  •  2005-12-16 Fraunhofer IIS 音频水印技术
  •  2006-05-03 什么是ABX盲听测试
  •  2005-11-21 mp3PRO 的 Spectral Band Replication 技术详细介绍
  • 本站微信订阅号:

    微信订阅号二维码

    本页网址二维码: