内容简介:今天我们学习音频的采集、编码、生成文件、转码等操作,我们生成三种格式的文件格式,pcm、wav、aac 三种格式,并且我们用 AudioStack 来播放音频,最后我们播放这个音频。本篇文章你将学到:AudioRecord 是 Android 系统提供的用于实现录音的功能类,要想了解这个类的具体的说明和用法,我们可以去看一下官方的文档:
今天我们学习音频的采集、编码、生成文件、转码等操作,我们生成三种格式的文件格式,pcm、wav、aac 三种格式,并且我们用 AudioStack 来播放音频,最后我们播放这个音频。
本篇文章你将学到:
- AudioRecord 音频的采集
- 生成 PCM 文件
- PCM 转化为 WAV 文件
- PCM 转化为 AAC 文件
- 附上正常运行 demo 源码
使用 AudioRecord 实现录音生成PCM 文件
AudioRecord 是 Android 系统提供的用于实现录音的功能类,要想了解这个类的具体的说明和用法,我们可以去看一下官方的文档:
AndioRecord类的主要功能是让各种 Java 应用能够管理音频资源,以便它们通过此类能够录制声音相关的硬件所收集的声音。此功能的实现就是通过”pulling”(读取)AudioRecord对象的声音数据来完成的。在录音过程中,应用所需要做的就是通过后面三个类方法中的一个去及时地获取AudioRecord对象的录音数据. AudioRecord类提供的三个获取声音数据的方法分别是read(byte[], int, int), read(short[], int, int), read(ByteBuffer, int). 无论选择使用那一个方法都必须事先设定方便用户的声音数据的存储格式。
开始录音的时候,AudioRecord需要初始化一个相关联的声音buffer, 这个buffer主要是用来保存新的声音数据。这个buffer的大小,我们可以在对象构造期间去指定。它表明一个AudioRecord对象还没有被读取(同步)声音数据前能录多长的音(即一次可以录制的声音容量)。声音数据从音频硬件中被读出,数据大小不超过整个录音数据的大小(可以分多次读出),即每次读取初始化buffer容量的数据。
1.1 首先要声明一些全局的变量和常量参数
主要是声明一些用到的参数,具体解释可以看注释。
//指定音频源 这个和MediaRecorder是相同的 MediaRecorder.AudioSource.MIC指的是麦克风 private static final int mAudioSource = MediaRecorder.AudioSource.MIC; //指定采样率 (MediaRecoder 的采样率通常是8000Hz AAC的通常是44100Hz。 设置采样率为44100,目前为常用的采样率,官方文档表示这个值可以兼容所有的设置) private static final int mSampleRateInHz = 44100; //指定捕获音频的声道数目。在AudioFormat类中指定用于此的常量,单声道 private static final int mChannelConfig = AudioFormat.CHANNEL_CONFIGURATION_MONO; //指定音频量化位数 ,在AudioFormaat类中指定了以下各种可能的常量。通常我们选择ENCODING_PCM_16BIT和ENCODING_PCM_8BIT PCM代表的是脉冲编码调制,它实际上是原始音频样本。 //因此可以设置每个样本的分辨率为16位或者8位,16位将占用更多的空间和处理能力,表示的音频也更加接近真实。 private static final int mAudioFormat = AudioFormat.ENCODING_PCM_16BIT; //指定缓冲区大小。调用AudioRecord类的getMinBufferSize方法可以获得。 private int mBufferSizeInBytes; // 声明 AudioRecord 对象 private AudioRecord mAudioRecord = null; 复制代码
1.2 获取buffer的大小并创建AudioRecord
//初始化数据,计算最小缓冲区 mBufferSizeInBytes = AudioRecord.getMinBufferSize(mSampleRateInHz, mChannelConfig, mAudioFormat); //创建AudioRecorder对象 mAudioRecord = new AudioRecord(mAudioSource, mSampleRateInHz, mChannelConfig, mAudioFormat, mBufferSizeInBytes); 复制代码
1.3 创建一个子线程开启线程录音,并写入文件文件
@Override public void run() { //标记为开始采集状态 isRecording = true; //创建文件 createFile(); try { //判断AudioRecord未初始化,停止录音的时候释放了,状态就为STATE_UNINITIALIZED if (mAudioRecord.getState() == mAudioRecord.STATE_UNINITIALIZED) { initData(); } //最小缓冲区 byte[] buffer = new byte[mBufferSizeInBytes]; //获取到文件的数据流 mDataOutputStream = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(mRecordingFile))); //开始录音 mAudioRecord.startRecording(); //getRecordingState获取当前AudioReroding是否正在采集数据的状态 while (isRecording && mAudioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) { int bufferReadResult = mAudioRecord.read(buffer, 0, mBufferSizeInBytes); for (int i = 0; i < bufferReadResult; i++) { mDataOutputStream.write(buffer[i]); } } } catch (Exception e) { Log.e(TAG, "Recording Failed"); } finally { // 停止录音 stopRecord(); IOUtil.close(mDataOutputStream); } } 复制代码
1.4 权限和采集小结
注意:权限需求:WRITE_EXTERNAL_STORAGE、RECORD_AUDIO
到现在基本的录音的流程就介绍完了,但是这时候问题来了:
1) 我按照流程,把音频数据都输出到文件里面了,停止录音后,打开此文件,发现不能播放,到底是为什么呢?
答:按照流程走完了,数据是进去了,但是现在的文件里面的内容仅仅是最原始的音频数据,术语称为raw(中文解释是“原材料”或“未经处理的东西”),这时候,你让播放器去打开,它既不知道保存的格式是什么,又不知道如何进行解码操作。当然播放不了。
2) 那如何才能在播放器中播放我录制的内容呢?
答: 在文件的数据开头加入AAC HEAD 或者 AAC 数据即可,也就是文件头。只有加上文件头部的数据,播放器才能正确的知道里面的内容到底是什么,进而能够正常的解析并播放里面的内容。
PCM 、WAV、AAC 的文件头介绍
我这里简单的介绍一下这三种的格式的基本介绍,具体我添加了具体的访问链接,具体点击详情查看,我这里点到为止。
PCM:PCM(Pulse Code Modulation----脉码调制录音)。所谓PCM录音就是将声音等模拟信号变成符号化的脉冲列,再予以记录。PCM信号是由[1]、[0]等符号构成的数字信号,而未经过任何编码和压缩处理。与模拟信号比,它不易受传送系统的杂波及失真的影响。动态范围宽,可得到音质相当好的影响效果。
WAV: wav是一种无损的音频文件格式,WAV符合 PIFF(Resource Interchange File Format)规范。所有的WAV都有一个文件头,这个文件头音频流的编码参数。WAV对音频流的编码没有硬性规定,除了PCM之外,还有几乎所有支持ACM规范的编码都可以为WAV的音频流进行编码。
简单来说:WAV 是一种无损的音频文件格式,PCM是没有压缩的编码方式
AAC: AAC(Advanced Audio Coding),中文称为“高级音频编码”,出现于1997年,基于 MPEG-2的音频编码技术。由Fraunhofer IIS、杜比实验室、AT&T、Sony(索尼)等公司共同开发,目的是取代MP3格式。2000年,MPEG-4标准出现后,AAC 重新集成了其特性,加入了SBR技术和PS技术,为了区别于传统的 MPEG-2 AAC 又称为 MPEG-4 AAC。他是一种专为声音数据设计的文件压缩格式,与Mp3类似。利用AAC格式,可使声音文件明显减小,而不会让人感觉声音质量有所降低 。
PCM 转化为 WAV
在文件的数据开头加入WAVE HEAD 或者 AAC 数据即可,也就是文件头。只有加上文件头部的数据,播放器才能正确的知道里面的内容到底是什么,进而能够正常的解析并播放里面的内容。具体的头文件的描述,在Play a WAV file on an AudioTrack里面可以进行了解。
public class WAVUtil { /** * PCM文件转WAV文件 * * @param inPcmFilePath 输入PCM文件路径 * @param outWavFilePath 输出WAV文件路径 * @param sampleRate 采样率,例如44100 * @param channels 声道数 单声道:1或双声道:2 * @param bitNum 采样位数,8或16 */ public static void convertPcm2Wav(String inPcmFilePath, String outWavFilePath, int sampleRate,int channels, int bitNum) { FileInputStream in = null; FileOutputStream out = null; byte[] data = new byte[1024]; try { //采样字节byte率 long byteRate = sampleRate * channels * bitNum / 8; in = new FileInputStream(inPcmFilePath); out = new FileOutputStream(outWavFilePath); //PCM文件大小 long totalAudioLen = in.getChannel().size(); //总大小,由于不包括RIFF和WAV,所以是44 - 8 = 36,在加上PCM文件大小 long totalDataLen = totalAudioLen + 36; writeWaveFileHeader(out, totalAudioLen, totalDataLen, sampleRate, channels, byteRate); int length = 0; while ((length = in.read(data)) > 0) { out.write(data, 0, length); } } catch (Exception e) { e.printStackTrace(); } finally { IOUtil.close(in,out); } } /** * 输出WAV文件 * * @param out WAV输出文件流 * @param totalAudioLen 整个音频PCM数据大小 * @param totalDataLen 整个数据大小 * @param sampleRate 采样率 * @param channels 声道数 * @param byteRate 采样字节byte率 * @throws IOException */ private static void writeWaveFileHeader(FileOutputStream out, long totalAudioLen,long totalDataLen, int sampleRate, int channels, long byteRate) throws IOException { byte[] header = new byte[44]; header[0] = 'R'; // RIFF header[1] = 'I'; header[2] = 'F'; header[3] = 'F'; header[4] = (byte) (totalDataLen & 0xff);//数据大小 header[5] = (byte) ((totalDataLen >> 8) & 0xff); header[6] = (byte) ((totalDataLen >> 16) & 0xff); header[7] = (byte) ((totalDataLen >> 24) & 0xff); header[8] = 'W';//WAVE header[9] = 'A'; header[10] = 'V'; header[11] = 'E'; //FMT Chunk header[12] = 'f'; // 'fmt ' header[13] = 'm'; header[14] = 't'; header[15] = ' ';//过渡字节 //数据大小 header[16] = 16; // 4 bytes: size of 'fmt ' chunk header[17] = 0; header[18] = 0; header[19] = 0; //编码方式 10H为PCM编码格式 header[20] = 1; // format = 1 header[21] = 0; //通道数 header[22] = (byte) channels; header[23] = 0; //采样率,每个通道的播放速度 header[24] = (byte) (sampleRate & 0xff); header[25] = (byte) ((sampleRate >> 8) & 0xff); header[26] = (byte) ((sampleRate >> 16) & 0xff); header[27] = (byte) ((sampleRate >> 24) & 0xff); //音频数据传送速率,采样率*通道数*采样深度/8 header[28] = (byte) (byteRate & 0xff); header[29] = (byte) ((byteRate >> 8) & 0xff); header[30] = (byte) ((byteRate >> 16) & 0xff); header[31] = (byte) ((byteRate >> 24) & 0xff); // 确定系统一次要处理多少个这样字节的数据,确定缓冲区,通道数*采样位数 header[32] = (byte) (channels * 16 / 8); header[33] = 0; //每个样本的数据位数 header[34] = 16; header[35] = 0; //Data chunk header[36] = 'd';//data header[37] = 'a'; header[38] = 't'; header[39] = 'a'; header[40] = (byte) (totalAudioLen & 0xff); header[41] = (byte) ((totalAudioLen >> 8) & 0xff); header[42] = (byte) ((totalAudioLen >> 16) & 0xff); header[43] = (byte) ((totalAudioLen >> 24) & 0xff); out.write(header, 0, 44); } } 复制代码
看到下图我们生成了相对的 wav 文件,我们用用本机自带播放器打开此时就能正常播放,但是我们发现他的大小比较大,我们看到就是几分钟就这么大,我们平时用的是 mp3 、aac 格式的,我们如何办到的呢,这里我们继续看一下 mp3 格式如何能生成 。
PCM 转化为 AAC 文件格式
生成 aac 文件播放
public class AACUtil { ... /** * 初始化AAC编码器 */ private void initAACMediaEncode() { try { //参数对应-> mime type、采样率、声道数 MediaFormat encodeFormat = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, 16000, 1); encodeFormat.setInteger(MediaFormat.KEY_BIT_RATE, 64000);//比特率 encodeFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, 1); encodeFormat.setInteger(MediaFormat.KEY_CHANNEL_MASK, AudioFormat.CHANNEL_IN_MONO); encodeFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); encodeFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 1024);//作用于inputBuffer的大小 mediaEncode = MediaCodec.createEncoderByType(encodeType); mediaEncode.configure(encodeFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); } catch (IOException e) { e.printStackTrace(); } if (mediaEncode == null) { LogUtil.e("create mediaEncode failed"); return; } mediaEncode.start(); encodeInputBuffers = mediaEncode.getInputBuffers(); encodeOutputBuffers = mediaEncode.getOutputBuffers(); encodeBufferInfo = new MediaCodec.BufferInfo(); } private boolean codeOver = false; /** * 开始转码 * 音频数据{@link #srcPath}先解码成PCM PCM数据在编码成MediaFormat.MIMETYPE_AUDIO_AAC音频格式 * mp3->PCM->aac */ public void startAsync() { LogUtil.w("start"); new Thread(new DecodeRunnable()).start(); } /** * 解码{@link #srcPath}音频文件 得到PCM数据块 * * @return 是否解码完所有数据 */ private void srcAudioFormatToPCM() { File file = new File(srcPath);// 指定要读取的文件 FileInputStream fio = null; try { fio = new FileInputStream(file); byte[] bb = new byte[1024]; while (!codeOver) { if (fio.read(bb) != -1) { LogUtil.e("============ putPCMData ============" + bb.length); dstAudioFormatFromPCM(bb); } else { codeOver = true; } } fio.close(); } catch (Exception e) { e.printStackTrace(); } } private byte[] chunkAudio = new byte[0]; /** * 编码PCM数据 得到AAC格式的音频文件 */ private void dstAudioFormatFromPCM(byte[] pcmData) { int inputIndex; ByteBuffer inputBuffer; int outputIndex; ByteBuffer outputBuffer; int outBitSize; int outPacketSize; byte[] PCMAudio; PCMAudio = pcmData; encodeInputBuffers = mediaEncode.getInputBuffers(); encodeOutputBuffers = mediaEncode.getOutputBuffers(); encodeBufferInfo = new MediaCodec.BufferInfo(); inputIndex = mediaEncode.dequeueInputBuffer(0); inputBuffer = encodeInputBuffers[inputIndex]; inputBuffer.clear(); inputBuffer.limit(PCMAudio.length); inputBuffer.put(PCMAudio);//PCM数据填充给inputBuffer mediaEncode.queueInputBuffer(inputIndex, 0, PCMAudio.length, 0, 0);//通知编码器 编码 outputIndex = mediaEncode.dequeueOutputBuffer(encodeBufferInfo, 0); while (outputIndex > 0) { outBitSize = encodeBufferInfo.size; outPacketSize = outBitSize + 7;//7为ADT头部的大小 outputBuffer = encodeOutputBuffers[outputIndex];//拿到输出Buffer outputBuffer.position(encodeBufferInfo.offset); outputBuffer.limit(encodeBufferInfo.offset + outBitSize); chunkAudio = new byte[outPacketSize]; addADTStoPacket(chunkAudio, outPacketSize);//添加ADTS outputBuffer.get(chunkAudio, 7, outBitSize);//将编码得到的AAC数据 取出到byte[]中 try { //录制aac音频文件,保存在手机内存中 bos.write(chunkAudio, 0, chunkAudio.length); bos.flush(); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } outputBuffer.position(encodeBufferInfo.offset); mediaEncode.releaseOutputBuffer(outputIndex, false); outputIndex = mediaEncode.dequeueOutputBuffer(encodeBufferInfo, 0); } } /** * 添加ADTS头 * * @param packet * @param packetLen */ private void addADTStoPacket(byte[] packet, int packetLen) { int profile = 2; // AAC LC int freqIdx = 8; // 16KHz int chanCfg = 1; // CPE // fill in ADTS data packet[0] = (byte) 0xFF; packet[1] = (byte) 0xF1; packet[2] = (byte) (((profile - 1) << 6) + (freqIdx << 2) + (chanCfg >> 2)); packet[3] = (byte) (((chanCfg & 3) << 6) + (packetLen >> 11)); packet[4] = (byte) ((packetLen & 0x7FF) >> 3); packet[5] = (byte) (((packetLen & 7) << 5) + 0x1F); packet[6] = (byte) 0xFC; } /** * 释放资源 */ public void release() { ... } /** * 解码线程 */ private class DecodeRunnable implements Runnable { @Override public void run() { srcAudioFormatToPCM(); } } } 复制代码
AudioStack 播放
AudioTrack 类可以完成Android平台上音频数据的输出任务。AudioTrack有两种数据加载模式(MODE_STREAM和MODE_STATIC),对应的是数据加载模式和音频流类型, 对应着两种完全不同的使用场景。
MODE_STREAM:在这种模式下,通过write一次次把音频数据写到AudioTrack中。这和平时通过write系统调用往文件中写数据类似,但这种工作方式每次都需要把数据从用户提供的Buffer中拷贝到AudioTrack内部的Buffer中,这在一定程度上会使引入延时。为解决这一问题,AudioTrack就引入了第二种模式。
MODE_STATIC:这种模式下,在play之前只需要把所有数据通过一次write调用传递到AudioTrack中的内部缓冲区,后续就不必再传递数据了。这种模式适用于像铃声这种内存占用量较小,延时要求较高的文件。但它也有一个缺点,就是一次write的数据不能太多,否则系统无法分配足够的内存来存储全部数据。
播放声音可以用MediaPlayer和AudioTrack,两者都提供了Java API供应用开发者使用。虽然都可以播放声音,但两者还是有很大的区别的,其中最大的区别是MediaPlayer可以播放多种格式的声音文件,例如MP3,AAC,WAV,OGG,MIDI等。MediaPlayer会在framework层创建对应的音频解码器。而AudioTrack只能播放已经解码的PCM流,如果对比支持的文件格式的话则是AudioTrack只支持wav格式的音频文件,因为wav格式的音频文件大部分都是PCM流。AudioTrack不创建解码器,所以只能播放不需要解码的wav文件。
3.1 音频流的类型
在AudioTrack构造函数中,会接触到AudioManager.STREAM_MUSIC这个参数。它的含义与Android系统对音频流的管理和分类有关。
Android将系统的声音分为好几种流类型,下面是几个常见的:
STREAM_ALARM:警告声
STREAM_MUSIC:音乐声,例如music等
STREAM_RING:铃声
STREAM_SYSTEM:系统声音,例如低电提示音,锁屏音等
STREAM_VOCIE_CALL:通话声
注意:上面这些类型的划分和音频数据本身并没有关系。例如MUSIC和RING类型都可以是某首MP3歌曲。另外,声音流类型的选择没有固定的标准,例如,铃声预览中的铃声可以设置为MUSIC类型。音频流类型的划分和Audio系统对音频的管理策略有关。
3.2 Buffer分配和Frame的概念
在计算Buffer分配的大小的时候,我们经常用到的一个方法就是:getMinBufferSize。这个函数决定了应用层分配多大的数据Buffer。
AudioTrack.getMinBufferSize(8000,//每秒8K个采样点 AudioFormat.CHANNEL_CONFIGURATION_STEREO,//双声道 AudioFormat.ENCODING_PCM_16BIT); 复制代码
从AudioTrack.getMinBufferSize开始追溯代码,可以发现在底层的代码中有一个很重要的概念:Frame(帧)。Frame是一个单位,用来描述数据量的多少。1单位的Frame等于1个采样点的字节数×声道数(比如PCM16,双声道的1个Frame等于2×2=4字节)。1个采样点只针对一个声道,而实际上可能会有一或多个声道。由于不能用一个独立的单位来表示全部声道一次采样的数据量,也就引出了Frame的概念。Frame的大小,就是一个采样点的字节数×声道数。另外,在目前的声卡驱动程序中,其内部缓冲区也是采用Frame作为单位来分配和管理的。
getMinBufSize会综合考虑硬件的情况(诸如是否支持采样率,硬件本身的延迟情况等)后,得出一个最小缓冲区的大小。一般我们分配的缓冲大小会是它的整数倍。
3.3 构建过程
每一个音频流对应着一个AudioTrack类的一个实例,每个AudioTrack会在创建时注册到 AudioFlinger中,由AudioFlinger把所有的AudioTrack进行混合(Mixer),然后输送到AudioHardware中进行播放,目前Android同时最多可以创建32个音频流,也就是说,Mixer最多会同时处理32个AudioTrack的数据流。
3.4 Show Me The Code
public class AudioTrackManager { ... //音频流类型 private static final int mStreamType = AudioManager.STREAM_MUSIC; //指定采样率 (MediaRecoder 的采样率通常是8000Hz AAC的通常是44100Hz。 设置采样率为44100,目前为常用的采样率,官方文档表示这个值可以兼容所有的设置) private static final int mSampleRateInHz = 44100; //指定捕获音频的声道数目。在AudioFormat类中指定用于此的常量 private static final int mChannelConfig = AudioFormat.CHANNEL_CONFIGURATION_MONO; //单声道 //指定音频量化位数 ,在AudioFormaat类中指定了以下各种可能的常量。通常我们选择ENCODING_PCM_16BIT和ENCODING_PCM_8BIT PCM代表的是脉冲编码调制,它实际上是原始音频样本。 //因此可以设置每个样本的分辨率为16位或者8位,16位将占用更多的空间和处理能力,表示的音频也更加接近真实。 private static final int mAudioFormat = AudioFormat.ENCODING_PCM_16BIT; //指定缓冲区大小。调用AudioRecord类的getMinBufferSize方法可以获得。 private int mMinBufferSize; //STREAM的意思是由用户在应用程序通过write方式把数据一次一次得写到audiotrack中。这个和我们在socket中发送数据一样, // 应用层从某个地方获取数据,例如通过编解码得到PCM数据,然后write到audiotrack。 private static int mMode = AudioTrack.MODE_STREAM; private void initData() { //根据采样率,采样精度,单双声道来得到frame的大小。 mMinBufferSize = AudioTrack.getMinBufferSize(mSampleRateInHz, mChannelConfig, mAudioFormat);//计算最小缓冲区 //注意,按照数字音频的知识,这个算出来的是一秒钟buffer的大小。 //创建AudioTrack mAudioTrack = new AudioTrack(mStreamType, mSampleRateInHz, mChannelConfig, mAudioFormat, mMinBufferSize, mMode); } /** * 启动播放线程 */ private void startThread() { destroyThread(); isStart = true; if (mRecordThread == null) { mRecordThread = new Thread(recordRunnable); mRecordThread.start(); } } /** * 播放线程 */ private Runnable recordRunnable = new Runnable() { @Override public void run() { try { //设置线程的优先级 android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO); byte[] tempBuffer = new byte[mMinBufferSize]; int readCount = 0; while (mDis.available() > 0) { readCount = mDis.read(tempBuffer); if (readCount == AudioTrack.ERROR_INVALID_OPERATION || readCount == AudioTrack.ERROR_BAD_VALUE) { continue; } //一边播放一边写入语音数据 if (readCount != 0 && readCount != -1) { //判断AudioTrack未初始化,停止播放的时候释放了,状态就为STATE_UNINITIALIZED if (mAudioTrack.getState() == mAudioTrack.STATE_UNINITIALIZED) { initData(); } mAudioTrack.play(); mAudioTrack.write(tempBuffer, 0, readCount); } } //播放完就停止播放 stopPlay(); } catch (Exception e) { e.printStackTrace(); } } }; /** * 启动播放 * * @param path */ public void startPlay(String path) { try { setPath(path); startThread(); } catch (Exception e) { e.printStackTrace(); } } /** * 停止播放 */ public void stopPlay() { try { destroyThread();//销毁线程 if (mAudioTrack != null) { if (mAudioTrack.getState() == AudioRecord.STATE_INITIALIZED) {//初始化成功 mAudioTrack.stop();//停止播放 } if (mAudioTrack != null) { mAudioTrack.release();//释放audioTrack资源 } } if (mDis != null) { mDis.close();//关闭数据输入流 } } catch (Exception e) { e.printStackTrace(); } } } 复制代码
源码地址
https://github.com/StudyLifeTime/basicvideotutorial
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。