diff --git a/app/build.gradle b/app/build.gradle index 18b427c7..2fcb6542 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -26,7 +26,7 @@ android { applicationId "com.xypower.mpapp" minSdk COMPILE_MIN_SDK_VERSION as int //noinspection ExpiredTargetSdkVersion - targetSdk 28 + targetSdk TARGET_SDK_VERSION as int versionCode AppVersionCode versionName AppVersionName @@ -135,9 +135,10 @@ dependencies { // implementation 'com.tencent:mmkv-static:1.3.0' // implementation project(path: ':opencv') implementation files('libs/devapi.aar') - debugImplementation files('libs/rtmp-client-debug.aar') - releaseImplementation files('libs/rtmp-client.aar') - implementation project(':gpuv') + // debugImplementation files('libs/rtmp-client-debug.aar') + // releaseImplementation files('libs/rtmp-client.aar') + api project(':gpuv') + api project(':stream') implementation 'dev.mobile:dadb:1.2.7' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f32efefc..5aadf097 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -72,6 +72,15 @@ + + + + + + - - + android:grantUriPermissions="true" /> - - - - - \ No newline at end of file diff --git a/app/src/main/java/com/xypower/mpapp/BridgeService.java b/app/src/main/java/com/xypower/mpapp/BridgeService.java deleted file mode 100644 index 37bf3278..00000000 --- a/app/src/main/java/com/xypower/mpapp/BridgeService.java +++ /dev/null @@ -1,235 +0,0 @@ -package com.xypower.mpapp; - -import android.app.Service; - -import android.content.Intent; -import android.os.Handler; -import android.os.IBinder; -import android.text.TextUtils; -import android.util.Base64; -import android.util.Log; - -import androidx.annotation.Nullable; - -import com.xypower.common.FilesUtils; -import com.xypower.common.JSONUtils; -import com.xypower.common.MicroPhotoContext; -import com.xypower.mpapp.v2.Camera2VideoActivity; - -import org.json.JSONObject; - -import java.io.File; - - -public class BridgeService extends Service { - - private final static String TAG = "MPLOG"; - - private final static String ACTION_IMP_PUBKEY = "imp_pubkey"; - private final static String ACTION_GEN_KEYS = "gen_keys"; - private final static String ACTION_CERT_REQ = "cert_req"; - private final static String ACTION_BATTERY_VOLTAGE = "query_bv"; - private final static String ACTION_RECORDING = "recording"; - private final static String ACTION_TAKE_PHOTO = "take_photo"; - - private final static int REQUEST_CODE_RECORDING = Camera2VideoActivity.REQUEST_CODE_RECORDING; - - private Handler mHandler = null; - private boolean m3V3TurnedOn = false; - private boolean mAutoClose = true; - - private String mVideoFilePath = null; - - public BridgeService() { - } - - @Override - public void onCreate() { - super.onCreate(); - - mHandler = new Handler(); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (intent == null) { - stopSelf(); - return START_NOT_STICKY; - } - - final String action = intent.getStringExtra("action"); - if (!TextUtils.isEmpty(action)) { - if (TextUtils.equals(action, ACTION_IMP_PUBKEY)) { - String cert = intent.getStringExtra("cert"); - String path = intent.getStringExtra("path"); - int index = intent.getIntExtra("index", 1); - - if (!TextUtils.isEmpty(cert)) { - // Import - // String cert = intent.getStringExtra("md5"); - byte[] content = Base64.decode(cert, Base64.DEFAULT); - if (content != null) { - MicroPhotoService.importPublicKey(index, content); - } - } else if (TextUtils.isEmpty(path)) { - String md5 = intent.getStringExtra("md5"); - File file = new File(path); - if (file.exists() && file.isFile()) { - MicroPhotoService.importPublicKeyFile(index, path, md5); - } - } - } else if (TextUtils.equals(action, ACTION_GEN_KEYS)) { - int index = intent.getIntExtra("index", 0); - boolean res = MicroPhotoService.genKeys(index); - String path = intent.getStringExtra("path"); - if (!TextUtils.isEmpty(path)) { - FilesUtils.ensureParentDirectoryExisted(path); - FilesUtils.writeTextFile(path, res ? "1" : "0"); - } - } else if (TextUtils.equals(action, ACTION_CERT_REQ)) { - int index = intent.getIntExtra("index", 0); - int type = intent.getIntExtra("type", 0); - String subject = intent.getStringExtra("subject"); - String path = intent.getStringExtra("path"); - MicroPhotoService.genCertRequest(index, type, subject, path); - } else if (TextUtils.equals(action, ACTION_BATTERY_VOLTAGE)) { - String path = intent.getStringExtra("path"); - // #define CMD_GET_CHARGING_BUS_VOLTAGE_STATE 112 - // #define CMD_GET_BAT_VOL_STATE 115 - // #define CMD_GET_BAT_BUS_VOLTAGE_STATE 117 - int bv = MicroPhotoService.getGpioInt(117); - int bcv = MicroPhotoService.getGpioInt(112); - if (!TextUtils.isEmpty(path)) { - FilesUtils.ensureParentDirectoryExisted(path); - FilesUtils.writeTextFile(path + ".tmp", Integer.toString(bv) + " " + Integer.toString(bcv)); - File file = new File(path + ".tmp"); - file.renameTo(new File(path)); - } - } else if (TextUtils.equals(action, ACTION_TAKE_PHOTO)) { - String path = intent.getStringExtra("path"); - int channel = intent.getIntExtra("channel", 1); - int preset = intent.getIntExtra("preset", 0xFF); - - int width = intent.getIntExtra("width", 0); - int height = intent.getIntExtra("height", 0); - - String leftTopOsd = intent.getStringExtra("leftTopOsd"); - String rightTopOsd = intent.getStringExtra("rightTopOsd"); - String rightBottomOsd = intent.getStringExtra("rightBottomOsd"); - String leftBottomOsd = intent.getStringExtra("leftBottomOsd"); - - String appPath = MicroPhotoContext.buildMpAppDir(getApplicationContext()); - File configFile = new File(appPath); - configFile = new File(configFile, "data/channels/" + Integer.toString(channel) + ".json"); - - File tmpConfigFile = new File(appPath); - tmpConfigFile = new File(tmpConfigFile, "tmp/" + Integer.toString(channel) + "-" + Long.toString(System.currentTimeMillis()) + ".json"); - - if (configFile.exists()) { - try { - - FilesUtils.copyFile(configFile, tmpConfigFile); - - } catch (Exception ex) { - ex.printStackTrace(); - } - } - - JSONObject configJson = JSONUtils.loadJson(tmpConfigFile.getAbsolutePath()); - try { - if (configJson == null) { - configJson = new JSONObject(); - } - if (width > 0) { - configJson.put("resolutionCX", width); - } - if (height > 0) { - configJson.put("resolutionCY", height); - } - - JSONObject osdJson = configJson.getJSONObject("osd"); - if (osdJson == null) { - osdJson = configJson.put("osd", new JSONObject()); - } - osdJson.put("leftTop", TextUtils.isEmpty(leftTopOsd) ? "" : leftTopOsd); - osdJson.put("rightTop", TextUtils.isEmpty(rightTopOsd) ? "" : rightTopOsd); - osdJson.put("rightBottom", TextUtils.isEmpty(rightBottomOsd) ? "" : rightBottomOsd); - osdJson.put("leftBottom", TextUtils.isEmpty(leftBottomOsd) ? "" : leftBottomOsd); - - JSONUtils.saveJson(tmpConfigFile.getAbsolutePath(), configJson); - } catch (Exception ex) { - ex.printStackTrace(); - } - - File file = new File(path); - if (file.exists()) { - file.delete(); - } else { - FilesUtils.ensureParentDirectoryExisted(path); - } - - MicroPhotoService.takePhoto(channel, preset, true, tmpConfigFile.getAbsolutePath(), path); - if (tmpConfigFile.exists()) { - tmpConfigFile.delete(); - } - - } else if (TextUtils.equals(action, ACTION_RECORDING)) { - String path = intent.getStringExtra("path"); - int channel = intent.getIntExtra("channel", 1); - int cameraId = intent.getIntExtra("cameraId", -1); - int quality = intent.getIntExtra("quality", 0); - int width = intent.getIntExtra("width", 1280); - int height = intent.getIntExtra("height", 720); - int duration = intent.getIntExtra("duration", 15); - int orientation = intent.getIntExtra("orientation", 0); - long videoId = System.currentTimeMillis() / 1000; - - String leftTopOsd = intent.getStringExtra("leftTopOsd"); - String rightTopOsd = intent.getStringExtra("rightTopOsd"); - String rightBottomOsd = intent.getStringExtra("rightBottomOsd"); - String leftBottomOsd = intent.getStringExtra("leftBottomOsd"); - - if (cameraId == -1) { - cameraId = channel - 1; - } - - Intent recordingIntent = MicroPhotoService.makeRecordingIntent(getApplicationContext(), - cameraId, videoId, duration, width, height, quality, orientation, - leftTopOsd, rightTopOsd, rightBottomOsd, leftBottomOsd); - - mVideoFilePath = path; - mAutoClose = false; - - recordingIntent.putExtra("ActivityResult", true); - // startActivityForResult(recordingIntent, REQUEST_CODE_RECORDING); - } - } - - if (mAutoClose) { - mHandler.postDelayed(new Runnable() { - @Override - public void run() { - Log.i(TAG, "BridgeActivity will finish automatically"); - stopSelf(); - } - }, 200); - } - - return super.onStartCommand(intent, flags, startId); - } - - /** - * 服务销毁时的回调 - */ - @Override - public void onDestroy() { - super.onDestroy(); - } - - @Nullable - @Override - public IBinder onBind(Intent intent) { - return null; - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/xypower/mpapp/StreamActivity.java b/app/src/main/java/com/xypower/mpapp/StreamActivity.java new file mode 100644 index 00000000..2ddf4e3d --- /dev/null +++ b/app/src/main/java/com/xypower/mpapp/StreamActivity.java @@ -0,0 +1,49 @@ +package com.xypower.mpapp; + +import androidx.appcompat.app.AppCompatActivity; + +import android.content.Intent; +import android.os.Bundle; + +import io.antmedia.rtmp_client.RTMPMuxer; + +public class StreamActivity extends AppCompatActivity { + + private RTMPMuxer mRtmpMuxer; + private String mUrl; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_stream); + + Intent intent = getIntent(); + if (intent != null) { + mUrl = intent.getStringExtra("url"); + } + + openRtmpMuxer(mUrl); + } + + protected void openRtmpMuxer(String url) { + if (mRtmpMuxer != null) { + if (mRtmpMuxer.isConnected()) { + mRtmpMuxer.close(); + } + } else { + mRtmpMuxer = new RTMPMuxer(); + } + + int result = mRtmpMuxer.open(url,0, 0); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + if (mRtmpMuxer != null) { + mRtmpMuxer.close(); + mRtmpMuxer = null; + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_stream.xml b/app/src/main/res/layout/activity_stream.xml new file mode 100644 index 00000000..d0d7643a --- /dev/null +++ b/app/src/main/res/layout/activity_stream.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/common/build.gradle b/common/build.gradle index 04de2f89..931f0f37 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -6,8 +6,8 @@ android { compileSdk 33 defaultConfig { - minSdk 24 - targetSdk 28 + minSdk COMPILE_MIN_SDK_VERSION as int + targetSdk TARGET_SDK_VERSION as int testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" diff --git a/mpmaster/build.gradle b/mpmaster/build.gradle index 88c1deec..fb8e405c 100644 --- a/mpmaster/build.gradle +++ b/mpmaster/build.gradle @@ -14,8 +14,8 @@ android { defaultConfig { applicationId "com.xypower.mpmaster" - minSdk 25 - targetSdk 28 + minSdk COMPILE_MIN_SDK_VERSION as int + targetSdk TARGET_SDK_VERSION as int versionCode AppVersionCode versionName AppVersionName diff --git a/settings.gradle b/settings.gradle index 001b34d7..ac0b2a70 100644 --- a/settings.gradle +++ b/settings.gradle @@ -21,3 +21,4 @@ include ':gpuv' // project(':opencv').projectDir = new File(opencvsdk + '/sdk') include ':common' +include ':stream' \ No newline at end of file diff --git a/stream/.gitignore b/stream/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/stream/.gitignore @@ -0,0 +1 @@ +/build diff --git a/stream/build.gradle b/stream/build.gradle new file mode 100644 index 00000000..646ab32b --- /dev/null +++ b/stream/build.gradle @@ -0,0 +1,50 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +apply plugin: 'com.android.library' +group='com.github.ChillingVan' + +android { + compileSdkVersion 33 + + defaultConfig { + minSdkVersion COMPILE_MIN_SDK_VERSION as int + targetSdkVersion TARGET_SDK_VERSION as int + + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + api 'com.github.ChillingVan:android-openGL-canvas:v1.5.3.0' + // implementation 'net.butterflytv.utils:rtmp-client:3.1.0' + debugImplementation files('libs/rtmp-client-debug.aar') + releaseImplementation files('libs/rtmp-client.aar') + + implementation 'androidx.annotation:annotation:1.5.0' +} \ No newline at end of file diff --git a/app/libs/rtmp-client-debug.aar b/stream/libs/rtmp-client-debug.aar similarity index 100% rename from app/libs/rtmp-client-debug.aar rename to stream/libs/rtmp-client-debug.aar diff --git a/app/libs/rtmp-client.aar b/stream/libs/rtmp-client.aar similarity index 100% rename from app/libs/rtmp-client.aar rename to stream/libs/rtmp-client.aar diff --git a/stream/proguard-rules.pro b/stream/proguard-rules.pro new file mode 100644 index 00000000..7f86d056 --- /dev/null +++ b/stream/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in D:\develop-win\android\dev-tools-win\android-studio-windows\sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/stream/src/main/AndroidManifest.xml b/stream/src/main/AndroidManifest.xml new file mode 100644 index 00000000..764f77de --- /dev/null +++ b/stream/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + diff --git a/stream/src/main/java/com/xypower/stream/camera/CameraInterface.java b/stream/src/main/java/com/xypower/stream/camera/CameraInterface.java new file mode 100644 index 00000000..53a3cc7f --- /dev/null +++ b/stream/src/main/java/com/xypower/stream/camera/CameraInterface.java @@ -0,0 +1,48 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +package com.xypower.stream.camera; + +import android.graphics.SurfaceTexture; +import android.hardware.Camera; + +/** + * Created by Chilling on 2017/5/29. + */ + +public interface CameraInterface { + void setPreview(SurfaceTexture surfaceTexture); + + void openCamera(); + + void switchCamera(); + + void switchCamera(int previewWidth, int previewHeight); + + boolean isOpened(); + + void startPreview(); + + void stopPreview(); + + Camera getCamera(); + + void release(); +} diff --git a/stream/src/main/java/com/xypower/stream/camera/CameraUtils.java b/stream/src/main/java/com/xypower/stream/camera/CameraUtils.java new file mode 100644 index 00000000..43233463 --- /dev/null +++ b/stream/src/main/java/com/xypower/stream/camera/CameraUtils.java @@ -0,0 +1,101 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +package com.xypower.stream.camera; + +import android.hardware.Camera; +import android.util.Log; + +import java.util.List; + +/** + * Camera-related utility functions. + */ +public class CameraUtils { + private static final String TAG = "tag"; + + /** + * Attempts to find a preview size that matches the provided width and height (which + * specify the dimensions of the encoded video). If it fails to find a match it just + * uses the default preview size for video. + *

+ * TODO: should do a best-fit match, e.g. + * https://github.com/commonsguy/cwac-camera/blob/master/camera/src/com/commonsware/cwac/camera/CameraUtils.java + */ + public static void choosePreviewSize(Camera.Parameters parms, int width, int height) { + // We should make sure that the requested MPEG size is less than the preferred + // size, and has the same aspect ratio. + Camera.Size ppsfv = parms.getPreferredPreviewSizeForVideo(); + if (ppsfv != null) { + Log.d(TAG, "Camera preferred preview size for video is " + + ppsfv.width + "x" + ppsfv.height); + } + + //for (Camera.Size size : parms.getSupportedPreviewSizes()) { + // Log.d(TAG, "supported: " + size.width + "x" + size.height); + //} + + for (Camera.Size size : parms.getSupportedPreviewSizes()) { + if (size.width == width && size.height == height) { + parms.setPreviewSize(width, height); + return; + } + } + + Log.w(TAG, "Unable to set preview size to " + width + "x" + height); + if (ppsfv != null) { + parms.setPreviewSize(ppsfv.width, ppsfv.height); + } + // else use whatever the default size is + } + + /** + * Attempts to find a fixed preview frame rate that matches the desired frame rate. + *

+ * It doesn't seem like there's a great deal of flexibility here. + *

+ * TODO: follow the recipe from http://stackoverflow.com/questions/22639336/#22645327 + * + * @return The expected frame rate, in thousands of frames per second. + */ + public static int chooseFixedPreviewFps(Camera.Parameters parms, int desiredThousandFps) { + List supported = parms.getSupportedPreviewFpsRange(); + + for (int[] entry : supported) { + //Log.d(TAG, "entry: " + entry[0] + " - " + entry[1]); + if ((entry[0] == entry[1]) && (entry[0] == desiredThousandFps)) { + parms.setPreviewFpsRange(entry[0], entry[1]); + return entry[0]; + } + } + + int[] tmp = new int[2]; + parms.getPreviewFpsRange(tmp); + int guess; + if (tmp[0] == tmp[1]) { + guess = tmp[0]; + } else { + guess = tmp[1] / 2; // shrug + } + + Log.d(TAG, "Couldn't find match for " + desiredThousandFps + ", using " + guess); + return guess; + } +} diff --git a/stream/src/main/java/com/xypower/stream/camera/InstantVideoCamera.java b/stream/src/main/java/com/xypower/stream/camera/InstantVideoCamera.java new file mode 100644 index 00000000..5babcb92 --- /dev/null +++ b/stream/src/main/java/com/xypower/stream/camera/InstantVideoCamera.java @@ -0,0 +1,123 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +package com.xypower.stream.camera; + +import android.graphics.SurfaceTexture; +import android.hardware.Camera; + +import java.io.IOException; + +/** + * Created by Chilling on 2016/12/10. + */ + +public class InstantVideoCamera implements CameraInterface { + + private Camera camera; + private boolean isOpened; + private int currentCamera; + private int previewWidth; + private int previewHeight; + + @Override + public void setPreview(SurfaceTexture surfaceTexture) { + try { + camera.setPreviewTexture(surfaceTexture); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public InstantVideoCamera(int currentCamera, int previewWidth, int previewHeight) { + this.currentCamera = currentCamera; + this.previewWidth = previewWidth; + this.previewHeight = previewHeight; + } + + @Override + public void openCamera() { + Camera.CameraInfo info = new Camera.CameraInfo(); + + // Try to find a front-facing camera (e.g. for videoconferencing). + int numCameras = Camera.getNumberOfCameras(); + for (int i = 0; i < numCameras; i++) { + Camera.getCameraInfo(i, info); + if (info.facing == currentCamera) { + camera = Camera.open(i); + break; + } + } + if (camera == null) { + camera = Camera.open(); + } + + Camera.Parameters parms = camera.getParameters(); + + CameraUtils.choosePreviewSize(parms, previewWidth, previewHeight); + isOpened = true; + } + + @Override + public void switchCamera() { + switchCamera(previewWidth, previewHeight); + } + + @Override + public void switchCamera(int previewWidth, int previewHeight) { + this.previewWidth = previewWidth; + this.previewHeight = previewHeight; + release(); + currentCamera = currentCamera == Camera.CameraInfo.CAMERA_FACING_BACK ? Camera.CameraInfo.CAMERA_FACING_FRONT : Camera.CameraInfo.CAMERA_FACING_BACK; + openCamera(); + } + + @Override + public boolean isOpened() { + return isOpened; + } + + @Override + public void startPreview() { + camera.startPreview(); + } + + @Override + public void stopPreview() { + camera.stopPreview(); + } + + @Override + public Camera getCamera() { + return camera; + } + + @Override + public void release() { + if (isOpened) { + camera.stopPreview(); + camera.release(); + camera = null; + isOpened = false; + } + } + + +} diff --git a/stream/src/main/java/com/xypower/stream/encoder/MediaCodecInputStream.java b/stream/src/main/java/com/xypower/stream/encoder/MediaCodecInputStream.java new file mode 100644 index 00000000..1eb6a693 --- /dev/null +++ b/stream/src/main/java/com/xypower/stream/encoder/MediaCodecInputStream.java @@ -0,0 +1,166 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +package com.xypower.stream.encoder; + +import android.annotation.SuppressLint; +import android.media.MediaCodec; +import android.media.MediaCodec.BufferInfo; +import android.media.MediaFormat; +import android.os.Build; +import androidx.annotation.NonNull; +import android.util.Log; + +import com.chillingvan.canvasgl.util.Loggers; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +@SuppressLint("NewApi") +public class MediaCodecInputStream extends InputStream { + + public final String TAG = "MediaCodecInputStream"; + + private MediaCodec mMediaCodec = null; + private MediaFormatCallback mediaFormatCallback; + private BufferInfo mBufferInfo = new BufferInfo(); + private ByteBuffer mBuffer = null; + private boolean mClosed = false; + + public MediaFormat mMediaFormat; + private ByteBuffer[] encoderOutputBuffers; + + public MediaCodecInputStream(MediaCodec mediaCodec, MediaFormatCallback mediaFormatCallback) { + mMediaCodec = mediaCodec; + this.mediaFormatCallback = mediaFormatCallback; + } + + @Override + public void close() { + mClosed = true; + } + + @Deprecated + @Override + public int read() throws IOException { + return 0; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + int readLength; + int encoderStatus = -1; + + if (mBuffer == null) { + while (!Thread.interrupted() && !mClosed) { + synchronized (mMediaCodec) { + if (mClosed) return 0; + // timeout should not bigger than 0 for clear voice + encoderStatus = mMediaCodec.dequeueOutputBuffer(mBufferInfo, 0); + Loggers.d(TAG, "Index: " + encoderStatus + " Time: " + mBufferInfo.presentationTimeUs + " size: " + mBufferInfo.size); + if (encoderStatus >= 0) { + if (Build.VERSION.SDK_INT >= 21) { + mBuffer = mMediaCodec.getOutputBuffer(encoderStatus); + } else { + mBuffer = mMediaCodec.getOutputBuffers()[encoderStatus]; + } + mBuffer.position(mBufferInfo.offset); + mBuffer.limit(mBufferInfo.offset + mBufferInfo.size); + break; + } else if (encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + mMediaFormat = mMediaCodec.getOutputFormat(); + if (mediaFormatCallback != null) { + mediaFormatCallback.onChangeMediaFormat(mMediaFormat); + } + Log.i(TAG, mMediaFormat.toString()); + } else if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) { + Log.v(TAG, "No buffer available..."); + return 0; + } else { + Log.e(TAG, "Message: " + encoderStatus); + return 0; + } + + if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + return 0; + } + } + } + } + + + if (mClosed) throw new IOException("This InputStream was closed"); + + readLength = length < mBufferInfo.size - mBuffer.position() ? length : + mBufferInfo.size - mBuffer.position(); + mBuffer.get(buffer, offset, readLength); + if (mBuffer.position() >= mBufferInfo.size) { + mMediaCodec.releaseOutputBuffer(encoderStatus, false); + mBuffer = null; + } + + return readLength; + } + + public int available() { + if (mBuffer != null) + return mBufferInfo.size - mBuffer.position(); + else + return 0; + } + + public BufferInfo getLastBufferInfo() { + return mBufferInfo; + } + + public static void readAll(MediaCodecInputStream is, byte[] buffer, @NonNull OnReadAllCallback onReadAllCallback) { + byte[] readBuf = buffer; + + int readSize = 0; + do { + try { + readSize = is.read(readBuf, 0, readBuf.length); + onReadAllCallback.onReadOnce(readBuf, readSize, copyBufferInfo(is.getLastBufferInfo())); + } catch (IOException e) { + e.printStackTrace(); + return; + } + } while (readSize > 0); + } + + @NonNull + private static BufferInfo copyBufferInfo(BufferInfo lastBufferInfo) { + BufferInfo bufferInfo = new BufferInfo(); + bufferInfo.presentationTimeUs = lastBufferInfo.presentationTimeUs; + bufferInfo.flags = lastBufferInfo.flags; + bufferInfo.offset = lastBufferInfo.offset; + bufferInfo.size = lastBufferInfo.size; + return bufferInfo; + } + + public interface OnReadAllCallback { + void onReadOnce(byte[] buffer, int readSize, BufferInfo mediaBufferSize); + } + + public interface MediaFormatCallback { + void onChangeMediaFormat(MediaFormat mediaFormat); + } +} diff --git a/stream/src/main/java/com/xypower/stream/encoder/audio/AACEncoder.java b/stream/src/main/java/com/xypower/stream/encoder/audio/AACEncoder.java new file mode 100644 index 00000000..e5f5d2ed --- /dev/null +++ b/stream/src/main/java/com/xypower/stream/encoder/audio/AACEncoder.java @@ -0,0 +1,176 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +package com.xypower.stream.encoder.audio; + +import android.media.AudioFormat; +import android.media.AudioRecord; +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaFormat; +import android.media.audiofx.AcousticEchoCanceler; +import android.media.audiofx.AutomaticGainControl; +import android.media.audiofx.NoiseSuppressor; +import android.util.Log; + +import com.chillingvan.canvasgl.util.Loggers; +import com.xypower.stream.encoder.MediaCodecInputStream; +import com.xypower.stream.publisher.StreamPublisher; + +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * Data Stream: + * MIC -> AudioRecord -> voice data(byte[]) -> MediaCodec -> encode data(byte[]) + */ + +public class AACEncoder { + + private static final String TAG = "AACEncoder"; + + private AudioRecord mAudioRecord; + private final MediaCodec mMediaCodec; + private final MediaCodecInputStream mediaCodecInputStream; + private Thread mThread; + private OnDataComingCallback onDataComingCallback; + private int samplingRate; + private final int bufferSize; + private boolean isStart; + + public AACEncoder(final StreamPublisher.StreamPublisherParam params) throws IOException { + this.samplingRate = params.samplingRate; + + bufferSize = params.audioBufferSize; + mMediaCodec = MediaCodec.createEncoderByType(params.audioMIME); + mMediaCodec.configure(params.createAudioMediaFormat(), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + mediaCodecInputStream = new MediaCodecInputStream(mMediaCodec, new MediaCodecInputStream.MediaFormatCallback() { + @Override + public void onChangeMediaFormat(MediaFormat mediaFormat) { + params.setAudioOutputMediaFormat(mediaFormat); + } + }); + mAudioRecord = new AudioRecord(params.audioSource, samplingRate, params.channelCfg, AudioFormat.ENCODING_PCM_16BIT, bufferSize * 10); + if (NoiseSuppressor.isAvailable()) { + NoiseSuppressor noiseSuppressor = NoiseSuppressor.create(mAudioRecord.getAudioSessionId()); + if (noiseSuppressor != null) { + noiseSuppressor.setEnabled(true); + } + } + if (AcousticEchoCanceler.isAvailable()) { + AcousticEchoCanceler aec = AcousticEchoCanceler.create(mAudioRecord.getAudioSessionId()); + if (aec != null) { + aec.setEnabled(true); //android 11 issue low volume + } + } + if (AutomaticGainControl.isAvailable()) { + AutomaticGainControl agc = AutomaticGainControl.create(mAudioRecord.getAudioSessionId()); + if (agc != null) { + agc.setEnabled(true); + } + } + } + + public void start() { + mAudioRecord.startRecording(); + mMediaCodec.start(); + final long startWhen = System.nanoTime(); + final ByteBuffer[] inputBuffers = mMediaCodec.getInputBuffers(); + mThread = new Thread(new Runnable() { + @Override + public void run() { + int len, bufferIndex; + while (isStart && !Thread.interrupted()) { + synchronized (mMediaCodec) { + if (!isStart) return; + bufferIndex = mMediaCodec.dequeueInputBuffer(10000); + if (bufferIndex >= 0) { + inputBuffers[bufferIndex].clear(); + long presentationTimeNs = System.nanoTime(); + len = mAudioRecord.read(inputBuffers[bufferIndex], bufferSize); + presentationTimeNs -= (len / samplingRate ) / 1000000000; + Loggers.i(TAG, "Index: " + bufferIndex + " len: " + len + " buffer_capacity: " + inputBuffers[bufferIndex].capacity()); + long presentationTimeUs = (presentationTimeNs - startWhen) / 1000; + if (len == AudioRecord.ERROR_INVALID_OPERATION || len == AudioRecord.ERROR_BAD_VALUE) { + Log.e(TAG, "An error occured with the AudioRecord API !"); + } else { + mMediaCodec.queueInputBuffer(bufferIndex, 0, len, presentationTimeUs, 0); + if (onDataComingCallback != null) { + onDataComingCallback.onComing(); + } + } + } + } + } + } + }); + + mThread.start(); + isStart = true; + } + + + public static void addADTStoPacket(byte[] packet, int packetLen, int channelCnt) { + int profile = MediaCodecInfo.CodecProfileLevel.AACObjectLC; // AAC LC + int freqIdx = 4; // 44.1KHz + int chanCfg = channelCnt; // CPE channel cnt + + + // fill in ADTS data + packet[0] = (byte) 0xFF; + packet[1] = (byte) 0xF9; + 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 setOnDataComingCallback(OnDataComingCallback onDataComingCallback) { + this.onDataComingCallback = onDataComingCallback; + } + + public interface OnDataComingCallback { + void onComing(); + } + + + public MediaCodecInputStream getMediaCodecInputStream() { + return mediaCodecInputStream; + } + + + public synchronized void close() { + if (!isStart) { + return; + } + Loggers.d(TAG, "Interrupting threads..."); + isStart = false; + mThread.interrupt(); + mediaCodecInputStream.close(); + synchronized (mMediaCodec) { + mMediaCodec.stop(); + mMediaCodec.release(); + } + mAudioRecord.stop(); + mAudioRecord.release(); + } + +} diff --git a/stream/src/main/java/com/xypower/stream/encoder/video/H264Encoder.java b/stream/src/main/java/com/xypower/stream/encoder/video/H264Encoder.java new file mode 100644 index 00000000..76d36360 --- /dev/null +++ b/stream/src/main/java/com/xypower/stream/encoder/video/H264Encoder.java @@ -0,0 +1,186 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +package com.xypower.stream.encoder.video; + +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaFormat; +import android.view.Surface; + +import com.chillingvan.canvasgl.ICanvasGL; +import com.chillingvan.canvasgl.MultiTexOffScreenCanvas; +import com.chillingvan.canvasgl.glview.texture.GLTexture; +import com.chillingvan.canvasgl.glview.texture.gles.EglContextWrapper; +import com.chillingvan.canvasgl.util.Loggers; +import com.xypower.stream.encoder.MediaCodecInputStream; +import com.xypower.stream.publisher.StreamPublisher; + +import java.io.IOException; +import java.util.List; + +/** + * Data Stream: + * + * The texture of {@link H264Encoder#addSharedTexture} -> Surface of MediaCodec -> encode data(byte[]) + * + */ +public class H264Encoder { + + private final Surface mInputSurface; + private final MediaCodecInputStream mediaCodecInputStream; + MediaCodec mEncoder; + + private static final String MIME_TYPE = "video/avc"; // H.264 Advanced Video Coding + private static final int FRAME_RATE = 30; // 30fps + private static final int IFRAME_INTERVAL = 5; // 5 seconds between I-frames + protected final EncoderCanvas offScreenCanvas; + private OnDrawListener onDrawListener; + private boolean isStart; + private int initialTextureCount = 1; + + + public H264Encoder(StreamPublisher.StreamPublisherParam params) throws IOException { + this(params, EglContextWrapper.EGL_NO_CONTEXT_WRAPPER); + } + + + /** + * + * @param eglCtx can be EGL10.EGL_NO_CONTEXT or outside context + */ + public H264Encoder(final StreamPublisher.StreamPublisherParam params, final EglContextWrapper eglCtx) throws IOException { + +// MediaFormat format = MediaFormat.createVideoFormat(MIME_TYPE, params.width, params.height); + MediaFormat format = params.createVideoMediaFormat(); + + + // Set some properties. Failing to specify some of these can cause the MediaCodec + // configure() call to throw an unhelpful exception. + format.setInteger(MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); + format.setInteger(MediaFormat.KEY_BIT_RATE, params.videoBitRate); + format.setInteger(MediaFormat.KEY_FRAME_RATE, params.frameRate); + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, params.iframeInterval); + mEncoder = MediaCodec.createEncoderByType(params.videoMIMEType); + mEncoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + mInputSurface = mEncoder.createInputSurface(); + mEncoder.start(); + mediaCodecInputStream = new MediaCodecInputStream(mEncoder, new MediaCodecInputStream.MediaFormatCallback() { + @Override + public void onChangeMediaFormat(MediaFormat mediaFormat) { + params.setVideoOutputMediaFormat(mediaFormat); + } + }); + + this.initialTextureCount = params.getInitialTextureCount(); + offScreenCanvas = new EncoderCanvas(params.width, params.height, eglCtx); + } + + /** + * If called, should be called before start() called. + */ + public void addSharedTexture(GLTexture texture) { + offScreenCanvas.addConsumeGLTexture(texture); + } + + + public Surface getInputSurface() { + return mInputSurface; + } + + public MediaCodecInputStream getMediaCodecInputStream() { + return mediaCodecInputStream; + } + + /** + * + * @param initialTextureCount Default is 1 + */ + public void setInitialTextureCount(int initialTextureCount) { + if (initialTextureCount < 1) { + throw new IllegalArgumentException("initialTextureCount must >= 1"); + } + this.initialTextureCount = initialTextureCount; + } + + public void start() { + offScreenCanvas.start(); + isStart = true; + } + + public void close() { + if (!isStart) return; + + Loggers.d("H264Encoder", "close"); + offScreenCanvas.end(); + mediaCodecInputStream.close(); + synchronized (mEncoder) { + mEncoder.stop(); + mEncoder.release(); + } + isStart = false; + } + + public boolean isStart() { + return isStart; + } + + public void requestRender() { + offScreenCanvas.requestRender(); + } + + + public void requestRenderAndWait() { + offScreenCanvas.requestRenderAndWait(); + } + + public void setOnDrawListener(OnDrawListener l) { + this.onDrawListener = l; + } + + public interface OnDrawListener { + /** + * Called when a frame is ready to be drawn. + * @param canvasGL The gl canvas + * @param producedTextures The textures produced by internal. These can be used for camera or video decoder to render. + * @param consumedTextures See {@link #addSharedTexture(GLTexture)}. The textures you set from outside. Then you can draw the textures render by other Views of OffscreenCanvas. + */ + void onGLDraw(ICanvasGL canvasGL, List producedTextures, List consumedTextures); + } + + private class EncoderCanvas extends MultiTexOffScreenCanvas { + public EncoderCanvas(int width, int height, EglContextWrapper eglCtx) { + super(width, height, eglCtx, H264Encoder.this.mInputSurface); + } + + @Override + protected void onGLDraw(ICanvasGL canvas, List producedTextures, List consumedTextures) { + if (onDrawListener != null) { + onDrawListener.onGLDraw(canvas, producedTextures, consumedTextures); + } + } + + @Override + protected int getInitialTexCount() { + return initialTextureCount; + } + } +} diff --git a/stream/src/main/java/com/xypower/stream/muxer/BaseMuxer.java b/stream/src/main/java/com/xypower/stream/muxer/BaseMuxer.java new file mode 100644 index 00000000..6cf7fae3 --- /dev/null +++ b/stream/src/main/java/com/xypower/stream/muxer/BaseMuxer.java @@ -0,0 +1,52 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +package com.xypower.stream.muxer; + +import android.media.MediaCodec; + +import com.xypower.stream.publisher.StreamPublisher; + +/** + * Created by Chilling on 2017/12/23. + */ + +public abstract class BaseMuxer implements IMuxer { + + protected TimeIndexCounter videoTimeIndexCounter = new TimeIndexCounter(); + protected TimeIndexCounter audioTimeIndexCounter = new TimeIndexCounter(); + + @Override + public int open(StreamPublisher.StreamPublisherParam params) { + videoTimeIndexCounter.reset(); + audioTimeIndexCounter.reset(); + return 0; + } + + @Override + public void writeVideo(byte[] buffer, int offset, int length, MediaCodec.BufferInfo bufferInfo) { + videoTimeIndexCounter.calcTotalTime(bufferInfo.presentationTimeUs); + } + + @Override + public void writeAudio(byte[] buffer, int offset, int length, MediaCodec.BufferInfo bufferInfo) { + audioTimeIndexCounter.calcTotalTime(bufferInfo.presentationTimeUs); + } +} diff --git a/stream/src/main/java/com/xypower/stream/muxer/BufferInfoEx.java b/stream/src/main/java/com/xypower/stream/muxer/BufferInfoEx.java new file mode 100644 index 00000000..d935a2ca --- /dev/null +++ b/stream/src/main/java/com/xypower/stream/muxer/BufferInfoEx.java @@ -0,0 +1,45 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +package com.xypower.stream.muxer; + +import android.media.MediaCodec; + +/** + * Created by Chilling on 2017/12/23. + */ + +public class BufferInfoEx { + private MediaCodec.BufferInfo bufferInfo; + private int totalTime; + + public BufferInfoEx(MediaCodec.BufferInfo bufferInfo, int totalTime) { + this.bufferInfo = bufferInfo; + this.totalTime = totalTime; + } + + public MediaCodec.BufferInfo getBufferInfo() { + return bufferInfo; + } + + public int getTotalTime() { + return totalTime; + } +} diff --git a/stream/src/main/java/com/xypower/stream/muxer/FramePool.java b/stream/src/main/java/com/xypower/stream/muxer/FramePool.java new file mode 100644 index 00000000..6737644d --- /dev/null +++ b/stream/src/main/java/com/xypower/stream/muxer/FramePool.java @@ -0,0 +1,155 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +package com.xypower.stream.muxer; + + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * Created by Chilling on 2017/6/10. + */ + +public class FramePool { + + private final ItemsPool frameItemsPool; + + public FramePool(int poolSize) { + frameItemsPool = new ItemsPool<>(poolSize); + } + + public Frame obtain(byte[] data, int offset, int length, BufferInfoEx bufferInfo, int type) { + Frame frame = frameItemsPool.acquire(); + if (frame == null) { + frame = new Frame(data, offset, length, bufferInfo, type); + } else { + frame.set(data, offset, length, bufferInfo, type); + } + return frame; + } + + public void release(Frame frame) { + frameItemsPool.release(frame); + } + + private static class ItemsPool { + + private final Object[] mPool; + + private int mPoolSize; + + /** + * Creates a new instance. + * + * @param maxPoolSize The max pool size. + * @throws IllegalArgumentException If the max pool size is less than zero. + */ + public ItemsPool(int maxPoolSize) { + if (maxPoolSize <= 0) { + throw new IllegalArgumentException("The max pool size must be > 0"); + } + mPool = new Object[maxPoolSize]; + } + + @SuppressWarnings("unchecked") + public T acquire() { + if (mPoolSize > 0) { + final int lastPooledIndex = mPoolSize - 1; + T instance = (T) mPool[lastPooledIndex]; + mPool[lastPooledIndex] = null; + mPoolSize--; + return instance; + } + return null; + } + + public boolean release(T instance) { + if (isInPool(instance)) { + return false; + } + if (mPoolSize < mPool.length) { + mPool[mPoolSize] = instance; + mPoolSize++; + return true; + } + return false; + } + + private boolean isInPool(T instance) { + for (int i = 0; i < mPoolSize; i++) { + if (mPool[i] == instance) { + return true; + } + } + return false; + } + + } + + + + public static class Frame { + public byte[] data; + public int length; + public BufferInfoEx bufferInfo; + public int type; + public static final int TYPE_VIDEO = 1; + public static final int TYPE_AUDIO = 2; + + public Frame(byte[] data, int offset, int length, BufferInfoEx bufferInfo, int type) { + this.data = new byte[length]; + init(data, offset, length, bufferInfo, type); + } + + public void set(byte[] data, int offset, int length, BufferInfoEx bufferInfo, int type) { + if (this.data.length < length) { + this.data = new byte[length]; + } + init(data, offset, length, bufferInfo, type); + } + + private void init(byte[] data, int offset, int length, BufferInfoEx bufferInfo, int type) { + System.arraycopy(data, offset, this.data, 0, length); + this.length = length; + this.bufferInfo = bufferInfo; + this.type = type; + bufferInfo.getBufferInfo().size = length; + bufferInfo.getBufferInfo().offset = 0; + bufferInfo.getBufferInfo().presentationTimeUs = bufferInfo.getTotalTime() * 1000; + } + + public static void sortFrame(List frameQueue) { + Collections.sort(frameQueue, new Comparator() { + @Override + public int compare(Frame left, Frame right) { + if (left.bufferInfo.getTotalTime() < right.bufferInfo.getTotalTime()) { + return -1; + } else if (left.bufferInfo.getTotalTime() == right.bufferInfo.getTotalTime()) { + return 0; + } else { + return 1; + } + } + }); + } + } +} diff --git a/stream/src/main/java/com/xypower/stream/muxer/FrameSender.java b/stream/src/main/java/com/xypower/stream/muxer/FrameSender.java new file mode 100644 index 00000000..6f88ca8b --- /dev/null +++ b/stream/src/main/java/com/xypower/stream/muxer/FrameSender.java @@ -0,0 +1,123 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +package com.xypower.stream.muxer; + +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; + +import java.util.LinkedList; +import java.util.List; + +/** + * Created by Chilling on 2017/12/17. + */ + +public class FrameSender { + + private static final int KEEP_COUNT = 30; + private static final int MESSAGE_READY_TO_CLOSE = 4; + private static final int MSG_ADD_FRAME = 3; + private static final int MSG_START = 2; + + private Handler sendHandler; + private List frameQueue = new LinkedList<>(); + private FramePool framePool = new FramePool(KEEP_COUNT + 10); + private FrameSenderCallback frameSenderCallback; + + + public FrameSender(final FrameSenderCallback frameSenderCallback) { + this.frameSenderCallback = frameSenderCallback; + final HandlerThread sendHandlerThread = new HandlerThread("send_thread"); + sendHandlerThread.start(); + sendHandler = new Handler(sendHandlerThread.getLooper()) { + @Override + public void handleMessage(Message msg) { + super.handleMessage(msg); + + if (msg.what == MESSAGE_READY_TO_CLOSE) { + if (msg.obj != null) { + addFrame((FramePool.Frame) msg.obj); + } + sendFrame(msg.arg1); + + frameSenderCallback.close(); + sendHandlerThread.quitSafely(); + } else if (msg.what == MSG_ADD_FRAME) { + if (msg.obj != null) { + addFrame((FramePool.Frame) msg.obj); + } + sendFrame(msg.arg1); + } else if (msg.what == MSG_START) { + frameSenderCallback.onStart(); + } + } + }; + } + + + private void addFrame(FramePool.Frame frame) { + frameQueue.add(frame); + FramePool.Frame.sortFrame(frameQueue); + } + + private void sendFrame(int keepCount) { + while (frameQueue.size() > keepCount) { + FramePool.Frame sendFrame = frameQueue.remove(0); + if (sendFrame.type == FramePool.Frame.TYPE_VIDEO) { + frameSenderCallback.onSendVideo(sendFrame); + } else if(sendFrame.type == FramePool.Frame.TYPE_AUDIO) { + frameSenderCallback.onSendAudio(sendFrame); + } + framePool.release(sendFrame); + } + } + + public void sendStartMessage() { + Message message = Message.obtain(); + message.what = MSG_START; + sendHandler.sendMessage(message); + } + + public void sendAddFrameMessage(byte[] data, int offset, int length, BufferInfoEx bufferInfo, int type) { + FramePool.Frame frame = framePool.obtain(data, offset, length, bufferInfo, type); + Message message = Message.obtain(); + message.what = MSG_ADD_FRAME; + message.obj = frame; + message.arg1 = KEEP_COUNT; + sendHandler.sendMessage(message); + } + + public void sendCloseMessage() { + Message message = Message.obtain(); + message.arg1 = 0; + message.what = MESSAGE_READY_TO_CLOSE; + sendHandler.sendMessage(message); + } + + public interface FrameSenderCallback { + void onStart(); + void onSendVideo(FramePool.Frame sendFrame); + void onSendAudio(FramePool.Frame sendFrame); + void close(); + + } +} diff --git a/stream/src/main/java/com/xypower/stream/muxer/IMuxer.java b/stream/src/main/java/com/xypower/stream/muxer/IMuxer.java new file mode 100644 index 00000000..94748a9f --- /dev/null +++ b/stream/src/main/java/com/xypower/stream/muxer/IMuxer.java @@ -0,0 +1,45 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +package com.xypower.stream.muxer; + +import android.media.MediaCodec; + +import com.xypower.stream.publisher.StreamPublisher; + +/** + * Created by Chilling on 2017/5/21. + */ + +public interface IMuxer { + + /** + * + * @return 1 if it is connected + * 0 if it is not connected + */ + int open(StreamPublisher.StreamPublisherParam params); + + void writeVideo(byte[] buffer, int offset, int length, MediaCodec.BufferInfo bufferInfo); + + void writeAudio(byte[] buffer, int offset, int length, MediaCodec.BufferInfo bufferInfo); + + int close(); +} diff --git a/stream/src/main/java/com/xypower/stream/muxer/MP4Muxer.java b/stream/src/main/java/com/xypower/stream/muxer/MP4Muxer.java new file mode 100644 index 00000000..5d07325f --- /dev/null +++ b/stream/src/main/java/com/xypower/stream/muxer/MP4Muxer.java @@ -0,0 +1,155 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +package com.xypower.stream.muxer; + +import android.media.MediaCodec; +import android.media.MediaFormat; +import android.media.MediaMuxer; + +import com.chillingvan.canvasgl.util.Loggers; +import com.xypower.stream.publisher.StreamPublisher; + +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * Created by Chilling on 2017/12/17. + */ + +public class MP4Muxer extends BaseMuxer { + private static final String TAG = "MP4Muxer"; + + private MediaMuxer mMuxer; + private Integer videoTrackIndex = null; + private Integer audioTrackIndex = null; + private int trackCnt = 0; + private FrameSender frameSender; + private StreamPublisher.StreamPublisherParam params; + private boolean isStart; + + + public MP4Muxer() { + super(); + } + + @Override + public int open(final StreamPublisher.StreamPublisherParam params) { + isStart = false; + trackCnt = 0; + videoTrackIndex = null; + audioTrackIndex = null; + + this.params = params; + super.open(params); + try { + mMuxer = new MediaMuxer(params.outputFilePath, + MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4); + } catch (IOException e) { + e.printStackTrace(); + } + + frameSender = new FrameSender(new FrameSender.FrameSenderCallback(){ + + @Override + public void onStart() { + isStart = true; + mMuxer.start(); + } + + @Override + public void onSendVideo(FramePool.Frame sendFrame) { + if (isStart) { + mMuxer.writeSampleData(videoTrackIndex, ByteBuffer.wrap(sendFrame.data), sendFrame.bufferInfo.getBufferInfo()); + } + } + + @Override + public void onSendAudio(FramePool.Frame sendFrame) { + if (isStart) { + mMuxer.writeSampleData(audioTrackIndex, ByteBuffer.wrap(sendFrame.data), sendFrame.bufferInfo.getBufferInfo()); + } + } + + @Override + public void close() { + isStart = false; + if (mMuxer != null) { + mMuxer.stop(); + mMuxer.release(); + mMuxer = null; + } + } + }); + + + + return 1; + } + + @Override + public void writeVideo(byte[] buffer, int offset, int length, MediaCodec.BufferInfo bufferInfo) { + super.writeVideo(buffer, offset, length, bufferInfo); + + addTrackAndReadyToStart(FramePool.Frame.TYPE_VIDEO); + + Loggers.d(TAG, "writeVideo: " + " offset:" + offset + " length:" + length); + frameSender.sendAddFrameMessage(buffer, offset, length, new BufferInfoEx(bufferInfo, videoTimeIndexCounter.getTimeIndex()), FramePool.Frame.TYPE_VIDEO); + } + + @Override + public void writeAudio(byte[] buffer, int offset, int length, MediaCodec.BufferInfo bufferInfo) { + super.writeAudio(buffer, offset, length, bufferInfo); + addTrackAndReadyToStart(FramePool.Frame.TYPE_AUDIO); + + Loggers.d(TAG, "writeAudio: "); + frameSender.sendAddFrameMessage(buffer, offset, length, new BufferInfoEx(bufferInfo, audioTimeIndexCounter.getTimeIndex()), FramePool.Frame.TYPE_AUDIO); + } + + private void addTrackAndReadyToStart(int type) { + if (trackCnt == 2) { + return; + } + + if (videoTrackIndex == null && type == FramePool.Frame.TYPE_VIDEO) { + MediaFormat videoOutputMediaFormat = params.getVideoOutputMediaFormat(); + videoOutputMediaFormat.getByteBuffer("csd-0"); // SPS + videoOutputMediaFormat.getByteBuffer("csd-1"); // PPS + videoTrackIndex = mMuxer.addTrack(videoOutputMediaFormat); + trackCnt++; + } else if (audioTrackIndex == null && type == FramePool.Frame.TYPE_AUDIO) { + MediaFormat audioOutputMediaFormat = params.getAudioOutputMediaFormat(); + audioTrackIndex = mMuxer.addTrack(audioOutputMediaFormat); + trackCnt++; + } + + if (trackCnt == 2) { + frameSender.sendStartMessage(); + } + } + + @Override + public int close() { + if (frameSender != null) { + frameSender.sendCloseMessage(); + } + return 0; + } +} diff --git a/stream/src/main/java/com/xypower/stream/muxer/RTMPStreamMuxer.java b/stream/src/main/java/com/xypower/stream/muxer/RTMPStreamMuxer.java new file mode 100644 index 00000000..0208f21e --- /dev/null +++ b/stream/src/main/java/com/xypower/stream/muxer/RTMPStreamMuxer.java @@ -0,0 +1,132 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +package com.xypower.stream.muxer; + +import android.media.MediaCodec; +import android.text.TextUtils; + +import com.chillingvan.canvasgl.util.Loggers; +import com.xypower.stream.publisher.StreamPublisher; + +import net.butterflytv.rtmp_client.RTMPMuxer; + +import java.util.Locale; + +/** + * Created by Chilling on 2017/5/29. + */ + +public class RTMPStreamMuxer extends BaseMuxer { + private RTMPMuxer rtmpMuxer; + + private FrameSender frameSender; + + + public RTMPStreamMuxer() { + super(); + } + + + /** + * + * @return 1 if it is connected + * 0 if it is not connected + */ + @Override + public synchronized int open(final StreamPublisher.StreamPublisherParam params) { + super.open(params); + + if (TextUtils.isEmpty(params.outputUrl)) { + throw new IllegalArgumentException("Param outputUrl is empty"); + } + + rtmpMuxer = new RTMPMuxer(); + // -2 Url format error; -3 Connect error. + int open = rtmpMuxer.open(params.outputUrl, params.width, params.height); + Loggers.d("RTMPStreamMuxer", String.format(Locale.CHINA, "open: open: %d", open)); + boolean connected = rtmpMuxer.isConnected(); + Loggers.d("RTMPStreamMuxer", String.format(Locale.CHINA, "open: isConnected: %b", connected)); + + Loggers.d("RTMPStreamMuxer", String.format("open: %s", params.outputUrl)); + if (!TextUtils.isEmpty(params.outputFilePath)) { + rtmpMuxer.file_open(params.outputFilePath); + rtmpMuxer.write_flv_header(true, true); + } + + frameSender = new FrameSender(new FrameSender.FrameSenderCallback() { + @Override + public void onStart() {} + + @Override + public void onSendVideo(FramePool.Frame sendFrame) { + + rtmpMuxer.writeVideo(sendFrame.data, 0, sendFrame.length, sendFrame.bufferInfo.getTotalTime()); + } + + @Override + public void onSendAudio(FramePool.Frame sendFrame) { + + rtmpMuxer.writeAudio(sendFrame.data, 0, sendFrame.length, sendFrame.bufferInfo.getTotalTime()); + } + + @Override + public void close() { + if (rtmpMuxer != null) { + if (!TextUtils.isEmpty(params.outputFilePath)) { + rtmpMuxer.file_close(); + } + rtmpMuxer.close(); + rtmpMuxer = null; + } + + } + }); + frameSender.sendStartMessage(); + return connected ? 1 : 0; + } + + @Override + public void writeVideo(byte[] buffer, int offset, int length, MediaCodec.BufferInfo bufferInfo) { + super.writeVideo(buffer, offset, length, bufferInfo); + Loggers.d("RTMPStreamMuxer", "writeVideo: " + " time:" + videoTimeIndexCounter.getTimeIndex() + " offset:" + offset + " length:" + length); + frameSender.sendAddFrameMessage(buffer, offset, length, new BufferInfoEx(bufferInfo, videoTimeIndexCounter.getTimeIndex()), FramePool.Frame.TYPE_VIDEO); + } + + + + @Override + public void writeAudio(byte[] buffer, int offset, int length, MediaCodec.BufferInfo bufferInfo) { + super.writeAudio(buffer, offset, length, bufferInfo); + Loggers.d("RTMPStreamMuxer", "writeAudio: "); + frameSender.sendAddFrameMessage(buffer, offset, length, new BufferInfoEx(bufferInfo, audioTimeIndexCounter.getTimeIndex()), FramePool.Frame.TYPE_AUDIO); + } + + @Override + public synchronized int close() { + if (frameSender != null) { + frameSender.sendCloseMessage(); + } + + return 0; + } + + +} diff --git a/stream/src/main/java/com/xypower/stream/muxer/TimeIndexCounter.java b/stream/src/main/java/com/xypower/stream/muxer/TimeIndexCounter.java new file mode 100644 index 00000000..922b949c --- /dev/null +++ b/stream/src/main/java/com/xypower/stream/muxer/TimeIndexCounter.java @@ -0,0 +1,48 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +package com.xypower.stream.muxer; + +/** + * Created by Chilling on 2017/12/23. + */ + +public class TimeIndexCounter { + private long lastTimeUs; + private int timeIndex; + + public void calcTotalTime(long currentTimeUs) { + if (lastTimeUs <= 0) { + this.lastTimeUs = currentTimeUs; + } + int delta = (int) (currentTimeUs - lastTimeUs); + this.lastTimeUs = currentTimeUs; + timeIndex += Math.abs(delta / 1000); + } + + public void reset() { + lastTimeUs = 0; + timeIndex = 0; + } + + public int getTimeIndex() { + return timeIndex; + } +} diff --git a/stream/src/main/java/com/xypower/stream/publisher/CameraStreamPublisher.java b/stream/src/main/java/com/xypower/stream/publisher/CameraStreamPublisher.java new file mode 100644 index 00000000..ec82a6bd --- /dev/null +++ b/stream/src/main/java/com/xypower/stream/publisher/CameraStreamPublisher.java @@ -0,0 +1,126 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +package com.xypower.stream.publisher; + +import android.graphics.SurfaceTexture; + +import com.chillingvan.canvasgl.glview.texture.GLMultiTexProducerView; +import com.chillingvan.canvasgl.glview.texture.GLTexture; +import com.chillingvan.canvasgl.glview.texture.gles.EglContextWrapper; +import com.chillingvan.canvasgl.glview.texture.gles.GLThread; +import com.xypower.stream.camera.CameraInterface; +import com.xypower.stream.encoder.video.H264Encoder; +import com.xypower.stream.muxer.IMuxer; + +import java.io.IOException; +import java.util.List; + +/** + * Data Stream: + * Camera -> SurfaceTexture of GLSurfaceTextureProducerView -> Surface of MediaCodec -> encode data(byte[]) -> RTMPMuxer -> Server + * + */ + +public class CameraStreamPublisher { + + private StreamPublisher streamPublisher; + private IMuxer muxer; + private GLMultiTexProducerView cameraPreviewTextureView; + private CameraInterface instantVideoCamera; + private OnSurfacesCreatedListener onSurfacesCreatedListener; + + public CameraStreamPublisher(IMuxer muxer, GLMultiTexProducerView cameraPreviewTextureView, CameraInterface instantVideoCamera) { + this.muxer = muxer; + this.cameraPreviewTextureView = cameraPreviewTextureView; + this.instantVideoCamera = instantVideoCamera; + } + + private void initCameraTexture() { + cameraPreviewTextureView.setOnCreateGLContextListener(new GLThread.OnCreateGLContextListener() { + @Override + public void onCreate(EglContextWrapper eglContext) { + streamPublisher = new StreamPublisher(eglContext, muxer); + } + }); + cameraPreviewTextureView.setSurfaceTextureCreatedListener(new GLMultiTexProducerView.SurfaceTextureCreatedListener() { + @Override + public void onCreated(List producedTextureList) { + if (onSurfacesCreatedListener != null) { + onSurfacesCreatedListener.onCreated(producedTextureList, streamPublisher); + } + GLTexture texture = producedTextureList.get(0); + SurfaceTexture surfaceTexture = texture.getSurfaceTexture(); + streamPublisher.clearTextures(); + streamPublisher.addSharedTexture(new GLTexture(texture.getRawTexture(), surfaceTexture)); + surfaceTexture.setOnFrameAvailableListener(new SurfaceTexture.OnFrameAvailableListener() { + @Override + public void onFrameAvailable(SurfaceTexture surfaceTexture) { + cameraPreviewTextureView.requestRenderAndWait(); + streamPublisher.drawAFrame(); + } + }); + + instantVideoCamera.setPreview(surfaceTexture); + instantVideoCamera.startPreview(); + } + }); + } + + public void prepareEncoder(StreamPublisher.StreamPublisherParam param, H264Encoder.OnDrawListener onDrawListener) { + streamPublisher.prepareEncoder(param, onDrawListener); + } + + public void resumeCamera() { + if (instantVideoCamera.isOpened()) return; + + instantVideoCamera.openCamera(); + initCameraTexture(); + cameraPreviewTextureView.onResume(); + } + + public boolean isStart() { + return streamPublisher != null && streamPublisher.isStart(); + } + + public void pauseCamera() { + if (!instantVideoCamera.isOpened()) return; + + instantVideoCamera.release(); + cameraPreviewTextureView.onPause(); + } + + public void startPublish() throws IOException { + streamPublisher.start(); + } + + + public void closeAll() { + streamPublisher.close(); + } + + public void setOnSurfacesCreatedListener(OnSurfacesCreatedListener onSurfacesCreatedListener) { + this.onSurfacesCreatedListener = onSurfacesCreatedListener; + } + + public interface OnSurfacesCreatedListener { + void onCreated(List producedTextureList, StreamPublisher streamPublisher); + } +} diff --git a/stream/src/main/java/com/xypower/stream/publisher/StreamPublisher.java b/stream/src/main/java/com/xypower/stream/publisher/StreamPublisher.java new file mode 100644 index 00000000..a285436d --- /dev/null +++ b/stream/src/main/java/com/xypower/stream/publisher/StreamPublisher.java @@ -0,0 +1,350 @@ +/* + * + * * + * * * Copyright (C) 2017 ChillingVan + * * * + * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * you may not use this file except in compliance with the License. + * * * You may obtain a copy of the License at + * * * + * * * http://www.apache.org/licenses/LICENSE-2.0 + * * * + * * * Unless required by applicable law or agreed to in writing, software + * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * See the License for the specific language governing permissions and + * * * limitations under the License. + * * + * + */ + +package com.xypower.stream.publisher; + +import android.media.AudioFormat; +import android.media.AudioRecord; +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaFormat; +import android.media.MediaRecorder; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; + +import com.chillingvan.canvasgl.glview.texture.GLTexture; +import com.chillingvan.canvasgl.glview.texture.gles.EglContextWrapper; +import com.chillingvan.canvasgl.util.Loggers; +import com.xypower.stream.encoder.MediaCodecInputStream; +import com.xypower.stream.encoder.audio.AACEncoder; +import com.xypower.stream.encoder.video.H264Encoder; +import com.xypower.stream.muxer.IMuxer; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Data Stream:
+ * Video:
+ * ... Something that can draw things on Surface(Original Surface) - > The shared Surface Texture + * -> The surface of MediaCodec -> encode data(byte[]) -> RTMPMuxer -> Server + * Audio:
+ * MIC -> AudioRecord -> voice data(byte[]) -> MediaCodec -> encode data(byte[]) -> RTMPMuxer -> Server + */ +public class StreamPublisher { + + public static final int MSG_OPEN = 1; + public static final int MSG_WRITE_VIDEO = 2; + private EglContextWrapper eglCtx; + private IMuxer muxer; + private AACEncoder aacEncoder; + private H264Encoder h264Encoder; + private boolean isStart; + + private HandlerThread writeVideoHandlerThread; + + private Handler writeVideoHandler; + private StreamPublisherParam param; + private List sharedTextureList = new ArrayList<>(); + + public StreamPublisher(EglContextWrapper eglCtx, IMuxer muxer) { + this.eglCtx = eglCtx; + this.muxer = muxer; + } + + + public void prepareEncoder(final StreamPublisherParam param, H264Encoder.OnDrawListener onDrawListener) { + this.param = param; + + try { + h264Encoder = new H264Encoder(param, eglCtx); + for (GLTexture texture :sharedTextureList ) { + h264Encoder.addSharedTexture(texture); + } + h264Encoder.setOnDrawListener(onDrawListener); + aacEncoder = new AACEncoder(param); + aacEncoder.setOnDataComingCallback(new AACEncoder.OnDataComingCallback() { + private byte[] writeBuffer = new byte[param.audioBitRate / 8]; + + @Override + public void onComing() { + MediaCodecInputStream mediaCodecInputStream = aacEncoder.getMediaCodecInputStream(); + MediaCodecInputStream.readAll(mediaCodecInputStream, writeBuffer, new MediaCodecInputStream.OnReadAllCallback() { + @Override + public void onReadOnce(byte[] buffer, int readSize, MediaCodec.BufferInfo bufferInfo) { + if (readSize <= 0) { + return; + } + muxer.writeAudio(buffer, 0, readSize, bufferInfo); + } + }); + } + }); + + } catch (IOException | IllegalStateException e) { + e.printStackTrace(); + } + + writeVideoHandlerThread = new HandlerThread("WriteVideoHandlerThread"); + writeVideoHandlerThread.start(); + writeVideoHandler = new Handler(writeVideoHandlerThread.getLooper()) { + private byte[] writeBuffer = new byte[param.videoBitRate / 8 / 2]; + + @Override + public void handleMessage(Message msg) { + super.handleMessage(msg); + if (msg.what == MSG_WRITE_VIDEO) { + MediaCodecInputStream mediaCodecInputStream = h264Encoder.getMediaCodecInputStream(); + MediaCodecInputStream.readAll(mediaCodecInputStream, writeBuffer, new MediaCodecInputStream.OnReadAllCallback() { + @Override + public void onReadOnce(byte[] buffer, int readSize, MediaCodec.BufferInfo bufferInfo) { + if (readSize <= 0) { + return; + } + Loggers.d("StreamPublisher", String.format("onReadOnce: %d", readSize)); + muxer.writeVideo(buffer, 0, readSize, bufferInfo); + } + }); + } + } + }; + } + + public void clearTextures() { + sharedTextureList.clear(); + } + + public void addSharedTexture(GLTexture outsideTexture) { + sharedTextureList.add(outsideTexture); + } + + + public void start() throws IOException { + if (!isStart) { + if (muxer.open(param) <= 0) { + Loggers.e("StreamPublisher", "muxer open fail"); + throw new IOException("muxer open fail"); + } + h264Encoder.start(); + aacEncoder.start(); + isStart = true; + } + + } + + public void close() { + isStart = false; + if (h264Encoder != null) { + h264Encoder.close(); + } + + if (aacEncoder != null) { + aacEncoder.close(); + } + if (writeVideoHandlerThread != null) { + writeVideoHandlerThread.quitSafely(); + } + if (muxer != null) { + muxer.close(); + } + } + + public boolean isStart() { + return isStart; + } + + + public boolean drawAFrame() { + if (isStart) { + h264Encoder.requestRender(); + writeVideoHandler.sendEmptyMessage(MSG_WRITE_VIDEO); + return true; + } + return false; + } + + public static class StreamPublisherParam { + + public static final int DEFAULT_CHANNEL_CNT = 1; + + public int width = 640; + public int height = 480; + public int videoBitRate = 2949120; + public int frameRate = 30; + public int iframeInterval = 5; + public int samplingRate; + public int audioBitRate; + public int audioSource; + public int channelCfg; + public int channelCnt; + + public String videoMIMEType = "video/avc"; + public String audioMIME = "audio/mp4a-latm"; + public int audioBufferSize; + + public String outputFilePath; + public String outputUrl; + private MediaFormat videoOutputMediaFormat; + private MediaFormat audioOutputMediaFormat; + + private int initialTextureCount = 1; + + public StreamPublisherParam() { + this(640, 480, 2949120, 30, 5, 44100, 32000, MediaRecorder.AudioSource.VOICE_COMMUNICATION, AudioFormat.CHANNEL_IN_MONO, DEFAULT_CHANNEL_CNT); + } + + private StreamPublisherParam(int width, int height, int videoBitRate, int frameRate, + int iframeInterval, int samplingRate, int audioBitRate, int audioSource, int channelCfg, int channelCnt) { + this.width = width; + this.height = height; + this.videoBitRate = videoBitRate; + this.frameRate = frameRate; + this.iframeInterval = iframeInterval; + this.samplingRate = samplingRate; + this.audioBitRate = audioBitRate; + this.audioBufferSize = AudioRecord.getMinBufferSize(samplingRate, channelCfg, AudioFormat.ENCODING_PCM_16BIT) * 2; + this.audioSource = audioSource; + this.channelCfg = channelCfg; + this.channelCnt = channelCnt; + } + + /** + * + * @param initialTextureCount Default is 1 + */ + public void setInitialTextureCount(int initialTextureCount) { + if (initialTextureCount < 1) { + throw new IllegalArgumentException("initialTextureCount must >= 1"); + } + this.initialTextureCount = initialTextureCount; + } + + public int getInitialTextureCount() { + return initialTextureCount; + } + + public MediaFormat createVideoMediaFormat() { + MediaFormat format = MediaFormat.createVideoFormat(videoMIMEType, width, height); + + // Set some properties. Failing to specify some of these can cause the MediaCodec + // configure() call to throw an unhelpful exception. + format.setInteger(MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); + format.setInteger(MediaFormat.KEY_BIT_RATE, videoBitRate); + format.setInteger(MediaFormat.KEY_FRAME_RATE, frameRate); + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iframeInterval); + return format; + } + + public MediaFormat createAudioMediaFormat() { + MediaFormat format = MediaFormat.createAudioFormat(audioMIME, samplingRate, channelCnt); + format.setInteger(MediaFormat.KEY_BIT_RATE, audioBitRate); + format.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); + format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, audioBufferSize); + + return format; + } + + public void setVideoOutputMediaFormat(MediaFormat videoOutputMediaFormat) { + this.videoOutputMediaFormat = videoOutputMediaFormat; + } + + public void setAudioOutputMediaFormat(MediaFormat audioOutputMediaFormat) { + this.audioOutputMediaFormat = audioOutputMediaFormat; + } + + public MediaFormat getVideoOutputMediaFormat() { + return videoOutputMediaFormat; + } + + public MediaFormat getAudioOutputMediaFormat() { + return audioOutputMediaFormat; + } + + public static class Builder { + private int width = 640; + private int height = 480; + private int videoBitRate = 2949120; + private int frameRate = 30; + private int iframeInterval = 5; + private int samplingRate = 44100; + private int audioBitRate = 32000; + private int audioSource = MediaRecorder.AudioSource.VOICE_COMMUNICATION; + private int channelCfg = AudioFormat.CHANNEL_IN_MONO; + private int channelCnt = 1; + + public Builder setWidth(int width) { + this.width = width; + return this; + } + + public Builder setHeight(int height) { + this.height = height; + return this; + } + + public Builder setVideoBitRate(int videoBitRate) { + this.videoBitRate = videoBitRate; + return this; + } + + public Builder setFrameRate(int frameRate) { + this.frameRate = frameRate; + return this; + } + + public Builder setIframeInterval(int iframeInterval) { + this.iframeInterval = iframeInterval; + return this; + } + + public Builder setSamplingRate(int samplingRate) { + this.samplingRate = samplingRate; + return this; + } + + public Builder setAudioBitRate(int audioBitRate) { + this.audioBitRate = audioBitRate; + return this; + } + + public Builder setAudioSource(int audioSource) { + this.audioSource = audioSource; + return this; + } + + public Builder setChannelCfg(int channelCfg) { + this.channelCfg = channelCfg; + return this; + } + + public void setChannelCnt(int channelCnt) { + this.channelCnt = channelCnt; + } + + public StreamPublisherParam createStreamPublisherParam() { + return new StreamPublisherParam(width, height, videoBitRate, frameRate, iframeInterval, samplingRate, audioBitRate, audioSource, channelCfg, channelCnt); + } + } + } + +} diff --git a/stream/src/main/res/values/strings.xml b/stream/src/main/res/values/strings.xml new file mode 100644 index 00000000..d90dd0b6 --- /dev/null +++ b/stream/src/main/res/values/strings.xml @@ -0,0 +1,23 @@ + + + + lib +