Java中AudioFileStream_iOS音频学习一之AudioFileStream

音乐一直是我的爱好,作为一名开发,同时我也想知道这些音乐是怎么播放的,音效是如何改变的,如何升降调,一个音乐播放器是怎么实现的。从而开启我的音频学习之路

基本知识

人耳所能听到的声音,最低的频率是从20Hz起一直到最高频率20KHZ,因此音频文件格式的最大带宽是20KHZ。根据奈奎斯特的理论,只有采样频率高于声音信号最高频率的两倍时,才能把数字信号表示的声音还原成为原来的声音,所以音频文件的采样率一般在40~50KHZ,比如最常见的CD音质采样率44.1KHZ。

对声音进行采样、量化过程称为脉冲编码调制(Pulse Code Modulation)简称PCM,是无损的,但是数据量过大,从而产生各种压缩格式,有无损压缩(ALAC、APE、FLAC)和有损压缩(MP3、AAC、OGG、WMA)两种。

最常用的是MP3格式,其码率代表了MP3数据的压缩质量,常用的码率有128kbit/s、160kbit/s、320kbit/s等等,这个值越高声音质量也就越高。MP3编码方式常用的有两种固定码率(Constant bitrate,CBR)和可变码率(Variable bitrate,VBR)。

MP3格式中的数据通常由两部分组成,一部分为ID3用来存储歌名、演唱者、专辑、音轨数等信息,另一部分为音频数据。音频数据部分以帧(frame)为单位存储,每个音频都有自己的帧头,如图所示就是一个MP3文件帧结构图(图片同样来自互联网)。MP3中的每一个帧都有自己的帧头,其中存储了采样率等解码必须的信息,所以每一个帧都可以独立于文件存在和播放,这个特性加上高压缩比使得MP3文件成为了音频流播放的主流格式。帧头之后存储着音频数据,这些音频数据是若干个PCM数据帧经过压缩算法压缩得到的,对CBR的MP3数据来说每个帧中包含的PCM数据帧是固定的,而VBR是可变的。

18fcb6ae1a93?utm_campaign=maleskine&utm_content=note&utm_medium=seo_notes&utm_source=recommendation

MP3帧

了解了基础概念之后我们就可以列出一个经典的音频播放流程(以MP3为例):

1.读取MP3文件

2.解析采样率、码率、时长等信息,分离MP3中的音频帧

3.对分离出来的音频帧解码得到PCM数据

4.对PCM数据进行音效处理(均衡器、混响器等,非必须)

5.把PCM数据解码成音频信号

6.把音频信号交给硬件播放

重复1-6步直到播放完成

在iOS系统中apple对上述流程进行了封装和并提供不同接口

18fcb6ae1a93?utm_campaign=maleskine&utm_content=note&utm_medium=seo_notes&utm_source=recommendation

CoreAudio

说明

Audio File Services:读写音频数据,可以完成播放流程中的第2步;

Audio File Stream Services:对音频进行解码,可以完成播放流程中的第2步;

Audio Converter services:音频数据转换,可以完成播放流程中的第3步;

Audio Processing Graph Services:音效处理模块,可以完成播放流程中的第4步;

Audio Unit Services:播放音频数据:可以完成播放流程中的第5步、第6步;

Extended Audio File Services:Audio File Services和Audio Converter services的结合体;

AVAudioPlayer/AVPlayer(AVFoundation):高级接口,可以完成整个音频播放的过程(包括本地文件和网络流播放,第4步除外);

Audio Queue Services:高级接口,可以进行录音和播放,可以完成播放流程中的第3、5、6步;

OpenAL:用于游戏音频播放,暂不讨论

如果你只是想实现音频的播放,没有其他需求AVFoundation会很好的满足你的需求。它的接口使用简单、不用关心其中的细节;

如果你的app需要对音频进行流播放并且同时存储,那么AudioFileStreamer加AudioQueue能够帮到你,你可以先把音频数据下载到本地,一边下载一边用NSFileHandler等接口读取本地音频文件并交给AudioFileStreamer或者AudioFile解析分离音频帧,分离出来的音频帧可以送给AudioQueue进行解码和播放。如果是本地文件直接读取文件解析即可。(这两个都是比较直接的做法,这类需求也可以用AVFoundation+本地server的方式实现,AVAudioPlayer会把请求发送给本地server,由本地server转发出去,获取数据后在本地server中存储并转送给AVAudioPlayer。另一个比较trick的做法是先把音频下载到文件中,在下载到一定量的数据后把文件路径给AVAudioPlayer播放,当然这种做法在音频seek后就回有问题了。);

如果你正在开发一个专业的音乐播放软件,需要对音频施加音效(均衡器、混响器),那么除了数据的读取和解析以外还需要用到AudioConverter来把音频数据转换成PCM数据,再由AudioUnit+AUGraph来进行音效处理和播放(但目前多数带音效的app都是自己开发音效模块来坐PCM数据的处理,这部分功能自行开发在自定义性和扩展性上会比较强一些。PCM数据通过音效器处理完成后就可以使用AudioUnit播放了,当然AudioQueue也支持直接使对PCM数据进行播放。)。下图描述的就是使用AudioFile + AudioConverter + AudioUnit进行音频播放的流程(图片引自官方文档)。

——————目前我们先来看AudioFileStream如何实现第2步

创建AudioFileStream实例

一开始,我们都要初始化一个AudioFileStream的实例,

extern OSStatus

AudioFileStreamOpen (

void * __nullable inClientData,

AudioFileStream_PropertyListenerProc inPropertyListenerProc,

AudioFileStream_PacketsProc inPacketsProc,

AudioFileTypeID inFileTypeHint,

AudioFileStreamID __nullable * __nonnull outAudioFileStream)

__OSX_AVAILABLE_STARTING(__MAC_10_5,__IPHONE_2_0);

第一个参数,是一个上下文对象,一般为AudioFileStream的实例

第二个参数,是歌曲信息解析的回调,一般传入一个回调函数

第三个参数,是分离帧的回调,每解析出来一部分帧就会进行回调,也是传入一个回调函数

第四个参数,是文件类型的提示,这个参数在文件信息不完整的时候尤其有用,可以给AudioFileStream一些提示去解析我们的音频文件,无法确认可以传入0

一般会有以下几种

CF_ENUM(AudioFileTypeID) {

kAudioFileAIFFType = 'AIFF',

kAudioFileAIFCType = 'AIFC',

kAudioFileWAVEType = 'WAVE',

kAudioFileRF64Type = 'RF64',

kAudioFileSoundDesigner2Type = 'Sd2f',

kAudioFileNextType = 'NeXT',

kAudioFileMP3Type = 'MPG3', // mpeg layer 3

kAudioFileMP2Type = 'MPG2', // mpeg layer 2

kAudioFileMP1Type = 'MPG1', // mpeg layer 1

kAudioFileAC3Type = 'ac-3',

kAudioFileAAC_ADTSType = 'adts',

kAudioFileMPEG4Type = 'mp4f',

kAudioFileM4AType = 'm4af',

kAudioFileM4BType = 'm4bf',

kAudioFileCAFType = 'caff',

kAudioFile3GPType = '3gpp',

kAudioFile3GP2Type = '3gp2',

kAudioFileAMRType = 'amrf',

kAudioFileFLACType = 'flac'

};

第五个参数是返回AudioFileStream实例对应的ID,通过ID我们可以得到这个实例的一些信息,返回值是OSStatus,成功时会返回noErr。

解析数据

我们在得到AudioFileStream实例之后,就可以开始解析数据了,我们解析用的接口是这个

extern OSStatus

AudioFileStreamParseBytes(

AudioFileStreamID inAudioFileStream,

UInt32 inDataByteSize,

const void * __nullable inData,

AudioFileStreamParseFlags inFlags)

__OSX_AVAILABLE_STARTING(__MAC_10_5,__IPHONE_2_0);

第一个参数是AudioFileStreamID,也就是我们上面创建AudioFileStream实例后获得的ID

第二个参数是inDataByteSize,本次解析数据的长度

第三个参数是inData,本次解析的数据,

第四个参数是本次解析和上一次解析是否是连续的关系,如果是连续的传入0,否则传入kAudioFileStreamParseFlag_Discontinuity

这里的“连续”指的是,像MP3的数据都以帧的形式存在的,解析时也是以帧作为单位去解析的,但在解码之前我们不可能知道每个帧的边界在第几个字节,所以就会出现这样的情况:我们传给AudioFileStreamParseBytes的数据在解析完成之后会有一部分数据余下来,这部分数据是接下去那一帧的前半部分,如果再次有数据输入需要继续解析时就必须要用到前一次解析余下来的数据才能保证帧数据完整,所以在正常播放的情况下传入0即可。目前知道的需要传入kAudioFileStreamParseFlag_Discontinuity的情况有两个,一个是在seek完毕之后显然seek后的数据和之前的数据完全无关;另一个是开源播放器AudioStreamer的作者@Matt Gallagher曾在自己的blog中提到过的:

the Audio File Stream Services hit me with a nasty bug: AudioFileStreamParseBytes will crash when trying to parse a streaming MP3.

In this case, if we pass the kAudioFileStreamParseFlag_Discontinuity flag to AudioFileStreamParseBytes on every invocation between receiving kAudioFileStreamProperty_ReadyToProducePackets and the first successful call to MyPacketsProc, then AudioFileStreamParseBytes will be extra cautious in its approach and won't crash.

Matt发布这篇blog是在2008年,这个Bug年代相当久远了,而且原因未知,究竟是否修复也不得而知,而且由于环境不同(比如测试用的mp3文件和所处的iOS系统)无法重现这个问题,所以我个人觉得还是按照Matt的work around在回调得到kAudioFileStreamProperty_ReadyToProducePackets之后,在正常解析第一帧之前都传入kAudioFileStreamParseFlag_Discontinuity比较好。

回过头来,这个函数的返回值也是OSStatus,返回的错误码有以下,

CF_ENUM(OSStatus)

{

kAudioFileStreamError_UnsupportedFileType = 'typ?',

kAudioFileStreamError_UnsupportedDataFormat = 'fmt?',

kAudioFileStreamError_UnsupportedProperty = 'pty?',

kAudioFileStreamError_BadPropertySize = '!siz',

kAudioFileStreamError_NotOptimized = 'optm',

kAudioFileStreamError_InvalidPacketOffset = 'pck?',

kAudioFileStreamError_InvalidFile = 'dta?',

kAudioFileStreamError_ValueUnknown = 'unk?',

kAudioFileStreamError_DataUnavailable = 'more',

kAudioFileStreamError_IllegalOperation = 'nope',

kAudioFileStreamError_UnspecifiedError = 'wht?',

kAudioFileStreamError_DiscontinuityCantRecover = 'dsc!'

};

里面需要注意的是kAudioFileStreamError_NotOptimized

它的含义是指这个音频文件头不存在或者文件头可能在文件的末尾,无法正常parse,也就是这个音频文件需要全部下载完再能播放,无法流播

同时AudioFileStreamParseBytes方法每一次调用都需要注意返回值,一旦出现错误就可以不必继续parse

解析文件格式信息

在我们调用AudioFileStreamParseBytes方法之后,之前初始化方法里面的AudioFileStream_PropertyListenerProc也开始回调,进入这个方法看一下

typedef void (*AudioFileStream_PropertyListenerProc)(

void * inClientData,

AudioFileStreamID inAudioFileStream,

AudioFileStreamPropertyID inPropertyID,

AudioFileStreamPropertyFlags * ioFlags);

第一个参数是我们初始化实例的上下文对象

第二个参数是实例的ID

第三个参数是此次回调解析的信息ID,表示当前PropertyID对应的信息已经解析完成(例如数据格式,音频信息的偏移量),可以通过AudioFileStreamGetProperty来获取这个propertyID里面对应的值

extern OSStatus

AudioFileStreamGetPropertyInfo(

AudioFileStreamID inAudioFileStream,

AudioFileStreamPropertyID inPropertyID,

UInt32 * __nullable outPropertyDataSize,

Boolean * __nullable outWritable)

__OSX_AVAILABLE_STARTING(__MAC_10_5,__IPHONE_2_0);

第四个参数ioFlags是一个返回的参数,表示这个property是否需要缓存,如果需要的话就可以赋值kAudioFileStreamPropertyFlag_PropertyIsCached

这个回调会进行多次,但不是每一次都需要进行处理,propertyID的列表如下

CF_ENUM(AudioFileStreamPropertyID)

{

kAudioFileStreamProperty_ReadyToProducePackets = 'redy',

kAudioFileStreamProperty_FileFormat = 'ffmt',

kAudioFileStreamProperty_DataFormat = 'dfmt',

kAudioFileStreamProperty_FormatList = 'flst',

kAudioFileStreamProperty_MagicCookieData = 'mgic',

kAudioFileStreamProperty_AudioDataByteCount = 'bcnt',

kAudioFileStreamProperty_AudioDataPacketCount = 'pcnt',

kAudioFileStreamProperty_MaximumPacketSize = 'psze',

kAudioFileStreamProperty_DataOffset = 'doff',

kAudioFileStreamProperty_ChannelLayout = 'cmap',

kAudioFileStreamProperty_PacketToFrame = 'pkfr',

kAudioFileStreamProperty_FrameToPacket = 'frpk',

kAudioFileStreamProperty_PacketToByte = 'pkby',

kAudioFileStreamProperty_ByteToPacket = 'bypk',

kAudioFileStreamProperty_PacketTableInfo = 'pnfo',

kAudioFileStreamProperty_PacketSizeUpperBound = 'pkub',

kAudioFileStreamProperty_AverageBytesPerPacket = 'abpp',

kAudioFileStreamProperty_BitRate = 'brat',

kAudioFileStreamProperty_InfoDictionary = 'info'

};

这里解释几个propertyID

1.kAudioFileStreamProperty_ReadyToProducePackets

表示解析完成,可以对音频数据开始进行帧的分离

2.kAudioFileStreamProperty_BitRate

表示音频数据的码率,获取这个property是为了计算音频的总时长duration,而且在数据量比较小时出现ReadyToProducePackets还是没有获取到bitRate,这时需要分离一些帧,然后计算平均bitRate

UInt32 averageBitRate = totalPackectByteCount / totalPacketCout;

2.kAudioFileStreamProperty_DataOffset

表示音频数据在整个音频文件的offset,因为大多数音频文件都会有一个文件头。个值在seek时会发挥比较大的作用,音频的seek并不是直接seek文件位置而seek时间(比如seek到2分10秒的位置),seek时会根据时间计算出音频数据的字节offset然后需要再加上音频数据的offset才能得到在文件中的真正offset。

3.kAudioFileStreamProperty_DataFormat

表示音频文件结构信息,是一个AudioStreamBasicDescription

struct AudioStreamBasicDescription

{

Float64 mSampleRate;

AudioFormatID mFormatID;

AudioFormatFlags mFormatFlags;

UInt32 mBytesPerPacket;

UInt32 mFramesPerPacket;

UInt32 mBytesPerFrame;

UInt32 mChannelsPerFrame;

UInt32 mBitsPerChannel;

UInt32 mReserved;

};

4.kAudioFileStreamProperty_FormatList

作用和kAudioFileStreamProperty_DataFormat一样,不过这个获取到的是一个AudioStreamBasicDescription的数组,这个参数用来支持AAC SBR这样包含多个文件类型的音频格式。但是我们不知道有多少个format,所以要先获取总数据大小

AudioFormatListItem *formatList = malloc(formatListSize);

OSStatus status = AudioFileStreamGetProperty(_audioFileStreamID, kAudioFileStreamProperty_FormatList, &formatListSize, formatList);

if (status == noErr) {

UInt32 supportedFormatsSize;

status = AudioFormatGetPropertyInfo(kAudioFormatProperty_DecodeFormatIDs, 0, NULL, &supportedFormatsSize);

if (status != noErr) {

free(formatList);

return;

}

UInt32 supportedFormatCount = supportedFormatsSize / sizeof(OSType);

OSType *supportedFormats = (OSType *)malloc(supportedFormatsSize);

status = AudioFormatGetProperty(kAudioFormatProperty_DecodeFormatIDs, 0, NULL, &supportedFormatsSize, supportedFormats);

if (status != noErr) {

free(formatList);

free(supportedFormats);

return;

}

for (int i = 0; i * sizeof(AudioFormatListItem) < formatListSize; i++) {

AudioStreamBasicDescription format = formatList[i].mASBD;

for (UInt32 j = 0; j < supportedFormatCount; j++) {

if (format.mFormatID == supportedFormats[j]) {

_format = format;

[self calculatePacketDuration];

break;

}

}

}

free(supportedFormats);

};

free(formatList);

5.kAudioFileStreamProperty_AudioDataByteCount

表示音频文件音频数据的总量。这个是用来计算音频的总时长并且可以在seek的时候计算时间对应的字节offset

UInt32 audioDataByteCount;

UInt32 byteCountSize = sizeof(audioDataByteCount);

OSStatus status = AudioFileStreamGetProperty(_audioFileStreamID, kAudioFileStreamProperty_AudioDataByteCount, &byteCountSize, &audioDataByteCount);

if (status == noErr) {

NSLog(@"audioDataByteCount : %u, byteCountSize: %u",audioDataByteCount,byteCountSize);

}

跟bitRate一样,在数据量比较小的时候可能获取不到audioDataByteCount,这时就需要近似计算

UInt32 dataOffset = ...; //kAudioFileStreamProperty_DataOffset

UInt32 fileLength = ...; //音频文件大小

UInt32 audioDataByteCount = fileLength - dataOffset;

解析完音频帧之后,我们来分离音频帧

分离音频帧

读取完格式信息完成后,我们来继续调用AudioFileStreamParseBytes方法对帧进行分离,并进入AudioFileStream_PacketsProc回调方法

typedef void (*AudioFileStream_PacketsProc)(

void * inClientData,

UInt32 inNumberBytes,

UInt32 inNumberPackets,

const void * inInputData,

AudioStreamPacketDescription *inPacketDescriptions);

第一个参数同样是上下文对象

第二个参数,本次处理的数据大小

第三个参数,本次共处理了多少帧,

第四个参数,处理的所有数据

第五个参数,AudioStreamPacketDescription数组,存储了每一帧数据是从第几个字节开始的,这一帧总共多少字节

struct AudioStreamPacketDescription

{

SInt64 mStartOffset;

UInt32 mVariableFramesInPacket;

UInt32 mDataByteSize;

};

处理分离音频帧

if (_discontinuous) {

_discontinuous = NO;

}

if (numberOfBytes == 0 || numberOfPackets == 0) {

return;

}

BOOL deletePackDesc = NO;

if (packetDescriptions == NULL) {

//如果packetDescriptions不存在,就按照CBR处理,平均每一帧数据的数据后生成packetDescriptions

deletePackDesc = YES;

UInt32 packetSize = numberOfBytes / numberOfPackets;

AudioStreamPacketDescription *descriptions = (AudioStreamPacketDescription *)malloc(sizeof(AudioStreamPacketDescription)*numberOfPackets);

for (int i = 0; i < numberOfPackets; i++) {

UInt32 packetOffset = packetSize * i;

descriptions[i].mStartOffset = packetOffset;

descriptions[i].mVariableFramesInPacket = 0;

if (i == numberOfPackets-1) {

descriptions[i].mDataByteSize = numberOfPackets-packetOffset;

}else{

descriptions[i].mDataByteSize = packetSize;

}

}

packetDescriptions = descriptions;

}

NSMutableArray *parseDataArray = [NSMutableArray array];

for (int i = 0; i < numberOfPackets; i++) {

SInt64 packetOffset = packetDescriptions[i].mStartOffset;

//把解析出来的帧数据放进自己的buffer中

ZJParseAudioData *parsedData = [ZJParseAudioData parsedAudioDataWithBytes:packets+packetOffset packetDescription:packetDescriptions[i]];

[parseDataArray addObject:parsedData];

if (_processedPacketsCount < BitRateEstimationMaxPackets) {

_processedPacketsSizeTotal += parsedData.packetDescription.mDataByteSize;

_processedPacketsCount += 1;

[self calculateBitRate];

[self calculateDuration];

}

}

...

if (deletePackDesc) {

free(packetDescriptions);

}

inPacketDescriptions这个字段为空时需要按CBR的数据处理。但其实在解析CBR数据时inPacketDescriptions一般也有返回,因为即使是CBR数据帧的大小也不是恒定不变的,例如CBR的MP3会在每一帧的数据后放1byte的填充位,这个填充位也不一定一直存在,所以帧会有1byte的浮动

Seek

这个其实就是我们拖动进度条,需要到几分几秒,而我们实际上操作的是文件,即寻址到第几个字节开始播放音频数据

对于原始的PCM数据来说每一个PCM帧都是固定长度的,对应的播放时长也是固定的,但一旦转换成压缩后的音频数据就会因为编码形式的不同而不同了。对于CBR而言每个帧中所包含的PCM数据帧是恒定的,所以每一帧对应的播放时长也是恒定的;而VBR则不同,为了保证数据最优并且文件大小最小,VBR的每一帧中所包含的PCM数据帧是不固定的,这就导致在流播放的情况下VBR的数据想要做seek并不容易。这里我们也只讨论CBR下的seek。

我们一般是这样实现CBR的seek

1.近似地计算seek到哪个字节

double seekToTime = ...; //需要seek到哪个时间,秒为单位

UInt64 audioDataByteCount = ...; //通过kAudioFileStreamProperty_AudioDataByteCount获取的值

SInt64 dataOffset = ...; //通过kAudioFileStreamProperty_DataOffset获取的值

double durtion = ...; //通过公式(AudioDataByteCount * 8) / BitRate计算得到的时长

//近似seekOffset = 数据偏移 + seekToTime对应的近似字节数

SInt64 approximateSeekOffset = dataOffset + (seekToTime / duration) * audioDataByteCount;

2.计算seekToTime对应的是第几个帧

利用之前的解析得到的音频格式信息计算packetDuration

//首先需要计算每个packet对应的时长

AudioStreamBasicDescription asbd = ...; 通过kAudioFileStreamProperty_DataFormat或者kAudioFileStreamProperty_FormatList获取的值

double packetDuration = asbd.mFramesPerPacket / asbd.mSampleRate

//然后计算packet位置

SInt64 seekToPacket = floor(seekToTime / packetDuration);

3.使用AudioFileStreamSeek计算精确的字节偏移时间

AudioFileStreamSeek可以用来寻找某一个帧(Packet)对应的字节偏移(byte offset):

如果ioFlags里有kAudioFileStreamSeekFlag_OffsetIsEstimated说明给出的outDataByteOffset是估算的,并不准确,那么还是应该用第1步计算出来的approximateSeekOffset来做seek;

如果ioFlags里没有kAudioFileStreamSeekFlag_OffsetIsEstimated说明给出了准确的outDataByteOffset,就是输入的seekToPacket对应的字节偏移量,我们可以根据outDataByteOffset来计算出精确的seekOffset和seekToTime;

4.按照seekByteOffset读取对应的数据继续使用AudioFileStreamParseByte进行解析

计算duration

获取时长的最佳方法是从ID3信息中去读取,那样是最准确的。如果ID3信息中没有存,那就依赖于文件头中的信息去计算了。

计算duration的公式如下:

double duration = (audioDataByteCount * 8) / bitRate

音频数据的字节总量audioDataByteCount可以通过kAudioFileStreamProperty_AudioDataByteCount获取,码率bitRate可以通过kAudioFileStreamProperty_BitRate获取也可以通过Parse一部分数据后计算平均码率来得到。

对于CBR数据来说用这样的计算方法的duration会比较准确,对于VBR数据就不好说了。所以对于VBR数据来说,最好是能够从ID3信息中获取到duration,获取不到再想办法通过计算平均码率的途径来计算duration。

最后需要关闭AudioFileStream

extern OSStatus AudioFileStreamClose(AudioFileStreamID inAudioFileStream);

小结

1.使用AudioFileStream需要先调用AudioFileStreamOpen,最好提供文件类型帮助解析

2.当有数据时调用AudioFileStreamParseBytes进行解析,当出现noErr以外的值则代表解析出错,kAudioFileStreamError_NotOptimized则代表文件缺少头信息或者在文件尾部不适合流播放

3.在调用AudioFileStreamParseBytes之后会先进入AudioFileStream_PropertyListenerProc,当回调得到kAudioFileStreamProperty_ReadyToProducePackets则再进入MyAudioFileStreamPacketsCallBack分离帧信息。

4.使用后需关闭AudioFileStream

最后demo地址 : https://github.com/chanbendong/ZJAudioFileStream

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值