diff --git a/KTVAPI/Android/README.md b/KTVAPI/Android/README.md index ddc7896..bb25d88 100644 --- a/KTVAPI/Android/README.md +++ b/KTVAPI/Android/README.md @@ -1,6 +1,6 @@ # K 歌场景化 API 示例 demo -> 本文档主要介绍如何快速跑通 K 歌场景化 API 示例工程,支持加载、播放声网内容中心版权音乐和本地音乐文件。 +> 本文档主要介绍如何快速跑通 K 歌场景化 API 示例工程,本 demo 支持普通合唱、大合唱两种模式, 包含加载、播放声网内容中心版权音乐和本地音乐文件等功能 > > **Demo 效果:** > @@ -10,55 +10,61 @@ ## 1. 环境准备 - 最低兼容 Android 5.0(SDK API Level 21) -- Android Studio 3.5及以上版本。 -- Android 5.0 及以上的手机设备。 +- Android Studio 3.5及以上版本 +- Android 5.0 及以上的手机设备 --- ## 2. 运行示例 -- 获取声网 App ID -------- [声网Agora - 文档中心 - 如何获取 App ID](https://docs.agora.io/cn/Agora%20Platform/get_appid_token?platform=All%20Platforms#%E8%8E%B7%E5%8F%96-app-id) - > - 点击创建应用 - > - > ![xxx](https://accktvpic.oss-cn-beijing.aliyuncs.com/pic/github_readme/create_app_1.jpg) - > - > - 选择你要创建的应用类型 - > - > ![xxx](https://accktvpic.oss-cn-beijing.aliyuncs.com/pic/github_readme/create_app_2.jpg) - > - > - 得到 App ID 与 App 证书 - > - > ![xxx](https://accktvpic.oss-cn-beijing.aliyuncs.com/pic/github_readme/get_app_id.jpg) +- 2.1 进入声网控制台获取 APP ID 和 APP 证书 [控制台入口](https://console.shengwang.cn/overview) -- 获取 App 证书 ----- [声网Agora - 文档中心 - 获取 App 证书](https://docs.agora.io/cn/Agora%20Platform/get_appid_token?platform=All%20Platforms#%E8%8E%B7%E5%8F%96-app-%E8%AF%81%E4%B9%A6) + - 点击创建项目 -- **联系销售给 AppID 开通 K 歌权限(如果您没有销售人员的联系方式可通过智能客服联系销售人员 [Agora 支持](https://agora-ticket.agora.io/))** + ![图片](https://accktvpic.oss-cn-beijing.aliyuncs.com/pic/github_readme/ent-full/sdhy_1.jpg) + + - 选择项目基础配置, 鉴权机制需要选择**安全模式** + + ![图片](https://accktvpic.oss-cn-beijing.aliyuncs.com/pic/github_readme/ent-full/sdhy_2.jpg) + + - 拿到项目 APP ID 与 APP 证书 + + ![图片](https://accktvpic.oss-cn-beijing.aliyuncs.com/pic/github_readme/ent-full/sdhy_3.jpg) + + - **Restful API 服务配置(大合唱)** + ```json + 注: 体验大合唱模式需要填写 Restful API 相关信息 + ``` + ![图片](https://accktvpic.oss-cn-beijing.aliyuncs.com/pic/github_readme/ent-full/sdhy_4.jpg) + ![图片](https://accktvpic.oss-cn-beijing.aliyuncs.com/pic/github_readme/ent-full/sdhy_5.jpg) + ![图片](https://accktvpic.oss-cn-beijing.aliyuncs.com/pic/github_readme/ent-full/sdhy_6.jpg) + + - **联系声网技术支持给 APP ID 开通 K 歌歌单权限和云端转码权限([声网支持](https://ticket.shengwang.cn/form?type_id=&sdk_product=&sdk_platform=&sdk_version=¤t=0&project_id=&call_id=&channel_name=))** ```json - 注: 拉取声网版权榜单、歌单、歌曲、歌词等功能是需要开通权限的, 仅体验本地音乐文件模式可以不用开通 + 注: 拉取声网版权榜单、歌单、歌曲、歌词等功能是需要开通歌单权限的, 仅体验本地音乐文件模式可以不用开通 + 体验大合唱模式需要开通云端转码权限, 仅体验普通合唱可以不用开通 ``` -- 在项目的 [**gradle.properties**](gradle.properties) 里填写需要的声网 App ID 和 App 证书 +- 2.2 在项目的 [**gradle.properties**](gradle.properties) 里填写需要的声网 App ID 和 App 证书、RESTFUL KEY 和 SECRET ``` # RTM RTC SDK key Config - AGORA_APP_ID:声网appid - AGORA_APP_CERTIFICATE:声网Certificate + AGORA_APP_ID:声网 APP ID + AGORA_APP_CERTIFICATE:声网 APP 证书 + RESTFUL_API_KEY:声网RESTful API key + RESTFUL_API_SECRET:声网RESTful API secret ``` -- 用 Android Studio 运行项目即可开始您的体验 +- 2.3 用 Android Studio 运行项目即可开始您的体验 --- ## 3. 如何集成场景化 API 实现 K 歌场景 详见[**官网文档**](https://doc.shengwang.cn/doc/online-ktv/android/implementation/ktv-scenario/get-music) -### 集成遇到困难,该如何联系声网获取协助 - -> 方案1:如果您已经在使用声网服务或者在对接中,可以直接联系对接的销售或服务 -> -> 方案2:发送邮件给 [support@agora.io](mailto:support@agora.io) 咨询 -> -> 方案3:扫码加入我们的微信交流群提问 -> -> ---- +## 4. FAQ +- 集成遇到困难,该如何联系声网获取协助 + - 方案1:可以从智能客服获取帮助或联系技术支持人员 [声网支持](https://ticket.shengwang.cn/form?type_id=&sdk_product=&sdk_platform=&sdk_version=¤t=0&project_id=&call_id=&channel_name=) + - 方案2:加入微信群提问 + + ![](https://download.agora.io/demo/release/SDHY_QA.jpg) diff --git a/KTVAPI/Android/app/.gitignore b/KTVAPI/Android/app/.gitignore index 42afabf..956c004 100644 --- a/KTVAPI/Android/app/.gitignore +++ b/KTVAPI/Android/app/.gitignore @@ -1 +1,2 @@ -/build \ No newline at end of file +/build +/release \ No newline at end of file diff --git a/KTVAPI/Android/app/build.gradle b/KTVAPI/Android/app/build.gradle index c4da17d..241ca82 100644 --- a/KTVAPI/Android/app/build.gradle +++ b/KTVAPI/Android/app/build.gradle @@ -23,6 +23,8 @@ android { buildConfigField "String", "TOOLBOX_SERVER_HOST", "\"${properties.getProperty("TOOLBOX_SERVER_HOST", "")}\"" buildConfigField "String", "AGORA_APP_ID", "\"${properties.getProperty("AGORA_APP_ID", "")}\"" buildConfigField "String", "AGORA_APP_CERTIFICATE", "\"${properties.getProperty("AGORA_APP_CERTIFICATE", "")}\"" + buildConfigField "String", "RESTFUL_API_KEY", "\"${RESTFUL_API_KEY}\"" + buildConfigField "String", "RESTFUL_API_SECRET", "\"${RESTFUL_API_SECRET}\"" } buildTypes { @@ -74,10 +76,13 @@ dependencies { implementation 'androidx.navigation:navigation-ui-ktx:2.4.1' implementation 'com.google.code.gson:gson:2.10.1' implementation 'com.squareup.okhttp3:okhttp:3.12.0' + implementation 'com.squareup.okhttp3:logging-interceptor:4.10.0' implementation 'com.github.mrmike:ok2curl:0.8.0' // 歌词组件 implementation 'com.github.AgoraIO-Community:LyricsView:1.1.1-beta.8' + // + implementation 'io.agora:authentication:1.6.1' // ktvapi api project(":lib_ktvapi") } diff --git a/KTVAPI/Android/app/src/main/AndroidManifest.xml b/KTVAPI/Android/app/src/main/AndroidManifest.xml index 089cccc..a054703 100644 --- a/KTVAPI/Android/app/src/main/AndroidManifest.xml +++ b/KTVAPI/Android/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + + + + 不如跳舞 + 陈慧琳 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git "a/KTVAPI/Android/app/src/main/assets/\346\210\220\351\203\275.mp3" "b/KTVAPI/Android/app/src/main/assets/\346\210\220\351\203\275.mp3" deleted file mode 100644 index da4336d..0000000 Binary files "a/KTVAPI/Android/app/src/main/assets/\346\210\220\351\203\275.mp3" and /dev/null differ diff --git "a/KTVAPI/Android/app/src/main/assets/\346\210\220\351\203\275.xml" "b/KTVAPI/Android/app/src/main/assets/\346\210\220\351\203\275.xml" deleted file mode 100644 index 1328c0f..0000000 --- "a/KTVAPI/Android/app/src/main/assets/\346\210\220\351\203\275.xml" +++ /dev/null @@ -1,1225 +0,0 @@ - - - - 成都 - 赵雷 - 1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 绿 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 绿 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/MyApplication.kt b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/MyApplication.kt index f8e4dd8..e190ed6 100644 --- a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/MyApplication.kt +++ b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/MyApplication.kt @@ -19,8 +19,8 @@ class MyApplication : Application() { super.onCreate() sApp = this try { - initFile("成都.mp3") - initFile("成都.xml") + initFile("不如跳舞.mp4") + initFile("不如跳舞.xml") }catch (e:Exception){ e.printStackTrace() } diff --git a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/api/CloudApiManager.kt b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/api/CloudApiManager.kt new file mode 100644 index 0000000..2d7b2a0 --- /dev/null +++ b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/api/CloudApiManager.kt @@ -0,0 +1,201 @@ +package io.agora.ktvdemo.api + +import android.util.Log +import com.moczul.ok2curl.CurlInterceptor +import com.moczul.ok2curl.logger.Logger +import io.agora.ktvdemo.BuildConfig +import io.agora.ktvdemo.MyApplication +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Request.Builder +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.logging.HttpLoggingInterceptor +import org.json.JSONArray +import org.json.JSONObject +import java.util.* +import java.util.concurrent.TimeUnit + +/** + * 云端合流请求 + */ +class CloudApiManager private constructor() { + + companion object { + fun getInstance(): CloudApiManager { + return InstanceHolder.apiManager + } + + //private const val testIp = "218.205.37.50" + private const val domain = "https://api.sd-rtn.com" + private const val TAG = "ApiManager" + const val outputUid = 20232023 + } + + internal object InstanceHolder { + val apiManager = CloudApiManager() + } + + private var tokenName = "" + private var taskId = "" + private val okHttpClient: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + .addInterceptor(CurlInterceptor(object : Logger { + override fun log(message: String) { + Log.d(TAG, message) + } + })) + .build() + + private fun fetchCloudToken(): String { + var token = "" + try { + val acquireOjb = JSONObject() + acquireOjb.put("instanceId", System.currentTimeMillis().toString() + "") + //acquireOjb.put("testIp", testIp) + val request: Request = Builder() + .url(getTokenUrl(domain, BuildConfig.AGORA_APP_ID)) + .addHeader("Content-Type", "application/json") + .addHeader("Authorization", basicAuth) + .post(acquireOjb.toString().toRequestBody()) + .build() + + val responseToken = okHttpClient.newCall(request).execute() + if (responseToken.isSuccessful) { + val body = responseToken.body!! + val bodyString = body.string() + val jsonToken = JSONObject(bodyString) + if (jsonToken.has("tokenName")) { + token = jsonToken.getString("tokenName") + } + } + } catch (e: Exception) { + Log.e(TAG, "getToken error " + e.message) + } + return token + } + + fun fetchStartCloud(mainChannel: String, inputToken: String, outputToken: String) { + val token = fetchCloudToken() + tokenName = token.ifEmpty { + Log.e(TAG, "云端合流uid 请求报错 token is null") + return + } + var taskId = "" + try { + val transcoderObj = JSONObject() + val inputRetObj = JSONObject() + .put("rtcUid", 0) + .put("rtcToken", inputToken) + .put("rtcChannel", mainChannel) + val intObj = JSONObject() + .put("rtc", inputRetObj) + transcoderObj.put("audioInputs", JSONArray().put(intObj)) + transcoderObj.put("idleTimeout", 300) + val audioOptionObj = JSONObject() + .put("profileType", "AUDIO_PROFILE_MUSIC_HIGH_QUALITY_STEREO") + .put("fullChannelMixer", "native-mixer-weighted") + val outputRetObj = JSONObject() + .put("rtcUid", outputUid) + .put("rtcToken", outputToken) + .put("rtcChannel", mainChannel + "_ad") + val dataStreamObj = JSONObject() + .put("source", JSONObject().put("audioMetaData", true)) + .put("sink", JSONObject()) + val outputsObj = JSONObject() + .put("audioOption", audioOptionObj) + .put("rtc", outputRetObj) + .put("metaDataOption", dataStreamObj) + transcoderObj.put("outputs", JSONArray().put(outputsObj)) + val postBody = JSONObject() + .put( + "services", JSONObject() + .put( + "cloudTranscoder", JSONObject() + .put("serviceType", "cloudTranscoderV2") + .put( + "config", JSONObject() + .put("transcoder", transcoderObj) + ) + ) + ) + val request: Request = Builder() + .url(startTaskUrl(domain, BuildConfig.AGORA_APP_ID, tokenName)) + .addHeader("Content-Type", "application/json") + .addHeader("Authorization", basicAuth) + .post(postBody.toString().toRequestBody()) + .build() + + val responseStart = okHttpClient.newCall(request).execute() + if (responseStart.isSuccessful) { + val body = responseStart.body!! + val bodyString = body.string() + val jsonUid = JSONObject(bodyString) + if (jsonUid.has("taskId")) { + taskId = jsonUid.getString("taskId") + } + } + } catch (e: Exception) { + Log.e(TAG, "云端合流uid 请求报错 " + e.message) + } + if (taskId.isNotEmpty()) { + this.taskId = taskId + } + } + + fun fetchStopCloud() { + if (taskId.isEmpty() || tokenName.isEmpty()) { + Log.e(TAG, "云端合流任务停止失败 taskId || tokenName is null") + return + } + try { + val request: Request = Builder() + .url(deleteTaskUrl(domain, BuildConfig.AGORA_APP_ID, taskId, tokenName)) + .addHeader("Content-Type", "application/json") + .addHeader("Authorization", basicAuth) + .delete() + .build() + val response = okHttpClient.newCall(request).execute() + if (response.isSuccessful) { + val body = response.body!! + val bodyString = body.string() + } + } catch (e: Exception) { + Log.e(TAG, "云端合流任务停止失败 " + e.message) + } + } + + private fun getTokenUrl(domain: String, appId: String): String { + return String.format("%s/v1/projects/%s/rtsc/cloud-transcoder/builderTokens", domain, appId) + } + + private fun startTaskUrl(domain: String, appId: String, tokenName: String): String { + return String.format("%s/v1/projects/%s/rtsc/cloud-transcoder/tasks?builderToken=%s", domain, appId, tokenName) + } + + private fun deleteTaskUrl(domain: String, appid: String, taskid: String, tokenName: String): String { + return String.format( + "%s/v1/projects/%s/rtsc/cloud-transcoder/tasks/%s?builderToken=%s", + domain, + appid, + taskid, + tokenName + ) + } + + private val basicAuth: String + private get() { + // 拼接客户 ID 和客户密钥并使用 base64 编码 + val plainCredentials = BuildConfig.RESTFUL_API_KEY + ":" + BuildConfig.RESTFUL_API_SECRET + var base64Credentials: String? = null + base64Credentials = String(Base64.getEncoder().encode(plainCredentials.toByteArray())) + // 创建 authorization header + return "Basic $base64Credentials" + } + + private fun getString(resId:Int):String{ + return MyApplication().getString(resId) + } +} \ No newline at end of file diff --git a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/rtc/RtcEngineController.kt b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/rtc/RtcEngineController.kt index 50bf199..79bf301 100644 --- a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/rtc/RtcEngineController.kt +++ b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/rtc/RtcEngineController.kt @@ -50,7 +50,8 @@ object RtcEngineController { return innerRtcEngine!! } - var rtcToken: String = "" var chorusChannelRtcToken = "" + var audienceChannelToken = "" + var musicStreamToken = "" var rtmToken = "" } \ No newline at end of file diff --git a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/LivingFragment.kt b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/LivingFragment.kt index 6b6344b..70c7608 100644 --- a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/LivingFragment.kt +++ b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/LivingFragment.kt @@ -1,28 +1,28 @@ package io.agora.ktvdemo.ui import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.text.TextUtils import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast import androidx.navigation.fragment.findNavController import io.agora.karaoke_view.v11.KaraokeView import io.agora.ktvapi.* import io.agora.ktvdemo.BuildConfig -import io.agora.ktvdemo.MyApplication import io.agora.ktvdemo.R +import io.agora.ktvdemo.api.CloudApiManager import io.agora.ktvdemo.databinding.FragmentLivingBinding import io.agora.ktvdemo.rtc.RtcEngineController import io.agora.ktvdemo.utils.DownloadUtils import io.agora.ktvdemo.utils.KeyCenter +import io.agora.ktvdemo.utils.TokenGenerator import io.agora.ktvdemo.utils.ZipUtils import io.agora.rtc2.ChannelMediaOptions +import io.agora.rtc2.IRtcEngineEventHandler +import io.agora.rtc2.RtcConnection import java.io.File -import kotlin.random.Random +import java.util.concurrent.Executors /* * K 歌体验页面 @@ -37,15 +37,15 @@ class LivingFragment : BaseFragment() { /* * KTVAPI 实例 */ - private val ktvApi: KTVApi by lazy { - createKTVApi() - } + private lateinit var ktvApi: KTVApi /* * KTVAPI 事件 */ private val ktvApiEventHandler = object : IKTVApiEventHandler() {} + private val scheduledThreadPool = Executors.newScheduledThreadPool(5) + override fun getViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLivingBinding { return FragmentLivingBinding.inflate(inflater) } @@ -53,15 +53,43 @@ class LivingFragment : BaseFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + // 大合唱模式下主唱需要启动云端合流 + if (KeyCenter.role == KTVSingRole.LeadSinger && !KeyCenter.isNormalChorus) { + + TokenGenerator.generateToken("${KeyCenter.channelId}_ad", CloudApiManager.outputUid.toString(), + TokenGenerator.TokenGeneratorType.token007, TokenGenerator.AgoraTokenType.rtc, + success = { outputToken -> + TokenGenerator.generateToken(KeyCenter.channelId, "0", + TokenGenerator.TokenGeneratorType.token007, TokenGenerator.AgoraTokenType.rtc, + success = { inputToken -> + scheduledThreadPool.execute { + CloudApiManager.getInstance().fetchStartCloud( + KeyCenter.channelId, + inputToken, + outputToken + ) + } + }, + failure = { + toast("云端合流启动失败, token获取失败") + } + ) + }, + failure = { + toast("云端合流启动失败, token获取失败") + } + ) + } + initView() initKTVApi() joinChannel() - loadMusic() // 设置麦克风初始状态,主唱默认开麦 if (KeyCenter.role == KTVSingRole.LeadSinger) { ktvApi.muteMic(false) } + loadMusic() } override fun onDestroy() { @@ -82,6 +110,9 @@ class LivingFragment : BaseFragment() { ktvApi.removeEventHandler(ktvApiEventHandler) ktvApi.release() RtcEngineController.rtcEngine.leaveChannel() + scheduledThreadPool.execute { + CloudApiManager.getInstance().fetchStopCloud() + } findNavController().popBackStack() } if (KeyCenter.role == KTVSingRole.LeadSinger) { @@ -95,21 +126,86 @@ class LivingFragment : BaseFragment() { if (KeyCenter.role == KTVSingRole.LeadSinger) { toast(getString(R.string.app_no_premission)) } else { - ktvApi.switchSingerRole(KTVSingRole.CoSinger, object : ISwitchRoleStateListener { - override fun onSwitchRoleSuccess() { - mainHandler.post { - toast("加入合唱成功") - tvSinger.text = getString(R.string.app_co_singer) - KeyCenter.role = KTVSingRole.CoSinger + if (KeyCenter.isMcc) { + // 使用声网版权中心歌单 + val musicConfiguration = KTVLoadMusicConfiguration( + KeyCenter.songCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode + KeyCenter.LeadSingerUid, + KTVLoadMusicMode.LOAD_MUSIC_ONLY + ) + ktvApi.loadMusic(KeyCenter.songCode, musicConfiguration, object : IMusicLoadStateListener { + override fun onMusicLoadSuccess(songCode: Long, lyricUrl: String) { + Log.d("Music", "onMusicLoadSuccess, songCode: $songCode, lyricUrl: $lyricUrl") + // 切换身份为合唱者 + ktvApi.switchSingerRole(KTVSingRole.CoSinger, object : ISwitchRoleStateListener { + override fun onSwitchRoleSuccess() { + mainHandler.post { + toast("加入合唱成功,自动开麦") + ktvApi.muteMic(false) + btMicStatus.text = "麦克风开" + tvSinger.text = getString(R.string.app_co_singer) + KeyCenter.role = KTVSingRole.CoSinger + } + } + + override fun onSwitchRoleFail(reason: SwitchRoleFailReason) { + mainHandler.post { + toast("加入合唱失败") + } + } + }) } - } - override fun onSwitchRoleFail(reason: SwitchRoleFailReason) { - mainHandler.post { - toast("加入合唱失败") + override fun onMusicLoadFail(songCode: Long, reason: KTVLoadMusicFailReason) { + Log.d("Music", "onMusicLoadFail, songCode: $songCode, reason: $reason") } - } - }) + + override fun onMusicLoadProgress( + songCode: Long, + percent: Int, + status: MusicLoadStatus, + msg: String?, + lyricUrl: String? + ) { + Log.d("Music", "onMusicLoadProgress, songCode: $songCode, percent: $percent") + mainHandler.post { + binding?.btLoadProgress?.text = "下载进度:$percent%" + } + } + }) + } else { + // 使用本地音乐文件 + val musicConfiguration = KTVLoadMusicConfiguration( + KeyCenter.songCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode + KeyCenter.LeadSingerUid, + KTVLoadMusicMode.LOAD_NONE + ) + val songPath = requireActivity().filesDir.absolutePath + File.separator + val songName = "不如跳舞" + ktvApi.loadMusic("$songPath$songName.mp4", musicConfiguration) + val fileLrc = File("$songPath$songName.xml") + val lyricsModel = KaraokeView.parseLyricsData(fileLrc) + karaokeView?.lyricsData = lyricsModel + + // 切换身份为合唱者 + ktvApi.switchSingerRole(KTVSingRole.CoSinger, object : ISwitchRoleStateListener { + override fun onSwitchRoleSuccess() { + mainHandler.post { + toast("加入合唱成功,自动开麦") + ktvApi.muteMic(false) + btMicStatus.text = "麦克风开" + tvSinger.text = getString(R.string.app_co_singer) + KeyCenter.role = KTVSingRole.CoSinger + } + } + + override fun onSwitchRoleFail(reason: SwitchRoleFailReason) { + mainHandler.post { + toast("加入合唱失败") + } + } + }) + } } } @@ -127,47 +223,17 @@ class LivingFragment : BaseFragment() { // 开原唱:仅领唱和合唱者可以做这项操作 btOriginal.setOnClickListener { - when (KeyCenter.role) { - KTVSingRole.LeadSinger -> { - ktvApi.getMediaPlayer().selectMultiAudioTrack(0, 0) - } - KTVSingRole.CoSinger -> { - ktvApi.getMediaPlayer().selectAudioTrack(0) - } - else -> { - toast(getString(R.string.app_no_premission)) - } - } + ktvApi.switchAudioTrack(AudioTrackMode.YUAN_CHANG) } // 开伴奏:仅领唱和合唱者可以做这项操作 btAcc.setOnClickListener { - when (KeyCenter.role) { - KTVSingRole.LeadSinger -> { - ktvApi.getMediaPlayer().selectMultiAudioTrack(1, 1) - } - KTVSingRole.CoSinger -> { - ktvApi.getMediaPlayer().selectAudioTrack(1) - } - else -> { - toast(getString(R.string.app_no_premission)) - } - } + ktvApi.switchAudioTrack(AudioTrackMode.BAN_ZOU) } // 开导唱:仅领唱可以做这项操作,开启后领唱本地听到歌曲原唱,但观众听到仍为伴奏 btDaoChang.setOnClickListener { - when (KeyCenter.role) { - KTVSingRole.LeadSinger -> { - ktvApi.getMediaPlayer().selectMultiAudioTrack(0, 1) - } - KTVSingRole.CoSinger -> { - toast(getString(R.string.app_no_premission)) - } - else -> { - toast(getString(R.string.app_no_premission)) - } - } + ktvApi.switchAudioTrack(AudioTrackMode.DAO_CHANG) } // 加载音乐 @@ -175,7 +241,8 @@ class LivingFragment : BaseFragment() { if (KeyCenter.isMcc) { // 使用声网版权中心歌单 val musicConfiguration = KTVLoadMusicConfiguration( - KeyCenter.songCode.toString(), false, KeyCenter.LeadSingerUid, + KeyCenter.songCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode + KeyCenter.LeadSingerUid, if (KeyCenter.role == KTVSingRole.Audience) KTVLoadMusicMode.LOAD_LRC_ONLY else KTVLoadMusicMode.LOAD_MUSIC_AND_LRC ) ktvApi.loadMusic(KeyCenter.songCode, musicConfiguration, object : IMusicLoadStateListener { @@ -206,7 +273,7 @@ class LivingFragment : BaseFragment() { } } - override fun onMusicLoadFail(songCode: Long, reason: KTVLoadSongFailReason) { + override fun onMusicLoadFail(songCode: Long, reason: KTVLoadMusicFailReason) { Log.d("Music", "onMusicLoadFail, songCode: $songCode, reason: $reason") } @@ -226,18 +293,20 @@ class LivingFragment : BaseFragment() { } else { // 使用本地音乐文件 val musicConfiguration = KTVLoadMusicConfiguration( - KeyCenter.songCode.toString(), false, KeyCenter.LeadSingerUid, KTVLoadMusicMode.LOAD_NONE + KeyCenter.songCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode + KeyCenter.LeadSingerUid, + KTVLoadMusicMode.LOAD_NONE ) val songPath = requireActivity().filesDir.absolutePath + File.separator - val songName = "成都" - ktvApi.loadMusic("$songPath$songName.mp3", musicConfiguration) + val songName = "不如跳舞" + ktvApi.loadMusic("$songPath$songName.mp4", musicConfiguration) val fileLrc = File("$songPath$songName.xml") val lyricsModel = KaraokeView.parseLyricsData(fileLrc) karaokeView?.lyricsData = lyricsModel if (KeyCenter.role == KTVSingRole.LeadSinger) { ktvApi.switchSingerRole(KTVSingRole.LeadSinger, object : ISwitchRoleStateListener { override fun onSwitchRoleSuccess() { - ktvApi.startSing("$songPath$songName.mp3", 0) + ktvApi.startSing("$songPath$songName.mp4", 0) } override fun onSwitchRoleFail(reason: SwitchRoleFailReason) { @@ -293,20 +362,41 @@ class LivingFragment : BaseFragment() { * 初始化 KTVAPI */ private fun initKTVApi() { - val ktvApiConfig = KTVApiConfig( - BuildConfig.AGORA_APP_ID, - RtcEngineController.rtmToken, - RtcEngineController.rtcEngine, - KeyCenter.channelId, - KeyCenter.localUid, - "${KeyCenter.channelId}_ex", - RtcEngineController.chorusChannelRtcToken, - 10, - KTVType.Normal, - if (KeyCenter.isMcc) KTVMusicType.SONG_CODE else KTVMusicType.SONG_URL - ) - // 初始化 ktvapi 模块 - ktvApi.initialize(ktvApiConfig) + if (KeyCenter.isNormalChorus) { + // 创建普通合唱ktvapi实例 + ktvApi = createKTVApi( + KTVApiConfig( + appId = BuildConfig.AGORA_APP_ID, + rtmToken = RtcEngineController.rtmToken, + engine = RtcEngineController.rtcEngine, + channelName = KeyCenter.channelId, + localUid = KeyCenter.localUid, + chorusChannelName = "${KeyCenter.channelId}_ex", + chorusChannelToken = RtcEngineController.chorusChannelRtcToken, + maxCacheSize = 10, + type = KTVType.Normal, + musicType = if (KeyCenter.isMcc) KTVMusicType.SONG_CODE else KTVMusicType.SONG_URL + ) + ) + } else { + // 创建大合唱ktvapi实例 + ktvApi = createKTVGiantChorusApi( + KTVGiantChorusApiConfig( + appId = BuildConfig.AGORA_APP_ID, + rtmToken = RtcEngineController.rtmToken, + engine = RtcEngineController.rtcEngine, + localUid = KeyCenter.localUid, + audienceChannelName = KeyCenter.channelId + "_ad", + audienceChannelToken = RtcEngineController.audienceChannelToken, + chorusChannelName = KeyCenter.channelId, + chorusChannelToken = RtcEngineController.chorusChannelRtcToken, + musicStreamUid = 2023, + musicStreamToken = RtcEngineController.musicStreamToken, + maxCacheSize = 10, + musicType = if (KeyCenter.isMcc) KTVMusicType.SONG_CODE else KTVMusicType.SONG_URL + ) + ) + } // 注册 ktvapi 事件 ktvApi.addEventHandler(ktvApiEventHandler) // 设置歌词组件 @@ -343,12 +433,34 @@ class LivingFragment : BaseFragment() { publishMicrophoneTrack = KeyCenter.role != KTVSingRole.Audience clientRoleType = if (KeyCenter.role == KTVSingRole.Audience) io.agora.rtc2.Constants.CLIENT_ROLE_AUDIENCE else io.agora.rtc2.Constants.CLIENT_ROLE_BROADCASTER } - RtcEngineController.rtcEngine.joinChannel( - RtcEngineController.rtcToken, - KeyCenter.channelId, - KeyCenter.localUid, - channelMediaOptions - ) + + if (KeyCenter.isNormalChorus) { + // 普通合唱或独唱加入频道 + RtcEngineController.rtcEngine.joinChannel( + RtcEngineController.audienceChannelToken, + KeyCenter.channelId, + KeyCenter.localUid, + channelMediaOptions + ) + } else { + // 大合唱加入频道 + RtcEngineController.rtcEngine.joinChannelEx( + RtcEngineController.audienceChannelToken, + RtcConnection(KeyCenter.channelId + "_ad", KeyCenter.localUid), + channelMediaOptions, + object : IRtcEngineEventHandler() { + override fun onStreamMessage(uid: Int, streamId: Int, data: ByteArray?) { + (ktvApi as KTVGiantChorusApiImpl).setAudienceStreamMessage(uid, streamId, data) + } + + override fun onAudioMetadataReceived(uid: Int, data: ByteArray?) { + super.onAudioMetadataReceived(uid, data) + (ktvApi as KTVGiantChorusApiImpl).setAudienceAudioMetadataReceived(uid, data) + } + } + ) + RtcEngineController.rtcEngine.setParametersEx("{\"rtc.use_audio4\": true}", RtcConnection(KeyCenter.channelId + "_ad", KeyCenter.localUid)) + } // 加入频道后需要更新数据传输通道 ktvApi.renewInnerDataStreamId() @@ -361,7 +473,8 @@ class LivingFragment : BaseFragment() { if (KeyCenter.isMcc) { // 使用声网版权中心歌单 val musicConfiguration = KTVLoadMusicConfiguration( - KeyCenter.songCode.toString(), false, KeyCenter.LeadSingerUid, + KeyCenter.songCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode + KeyCenter.LeadSingerUid, if (KeyCenter.role == KTVSingRole.Audience) KTVLoadMusicMode.LOAD_LRC_ONLY else KTVLoadMusicMode.LOAD_MUSIC_AND_LRC ) ktvApi.loadMusic(KeyCenter.songCode, musicConfiguration, object : IMusicLoadStateListener { @@ -382,7 +495,7 @@ class LivingFragment : BaseFragment() { } } - override fun onMusicLoadFail(songCode: Long, reason: KTVLoadSongFailReason) { + override fun onMusicLoadFail(songCode: Long, reason: KTVLoadMusicFailReason) { Log.d("Music", "onMusicLoadFail, songCode: $songCode, reason: $reason") } @@ -402,18 +515,20 @@ class LivingFragment : BaseFragment() { } else { // 使用本地音乐文件 val musicConfiguration = KTVLoadMusicConfiguration( - KeyCenter.songCode.toString(), false, KeyCenter.LeadSingerUid, KTVLoadMusicMode.LOAD_NONE + KeyCenter.songCode.toString(), // 需要传入唯一的歌曲id,demo 简化逻辑传了songCode + KeyCenter.LeadSingerUid, + KTVLoadMusicMode.LOAD_NONE ) val songPath = requireActivity().filesDir.absolutePath + File.separator - val songName = "成都" - ktvApi.loadMusic("$songPath$songName.mp3", musicConfiguration) + val songName = "不如跳舞" + ktvApi.loadMusic("$songPath$songName.mp4", musicConfiguration) val fileLrc = File("$songPath$songName.xml") val lyricsModel = KaraokeView.parseLyricsData(fileLrc) karaokeView?.lyricsData = lyricsModel if (KeyCenter.role == KTVSingRole.LeadSinger) { ktvApi.switchSingerRole(KTVSingRole.LeadSinger, object : ISwitchRoleStateListener { override fun onSwitchRoleSuccess() { - ktvApi.startSing("$songPath$songName.mp3", 0) + ktvApi.startSing("$songPath$songName.mp4", 0) } override fun onSwitchRoleFail(reason: SwitchRoleFailReason) { diff --git a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/MainFragment.kt b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/MainFragment.kt index b368f6f..38703a5 100644 --- a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/MainFragment.kt +++ b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/ui/MainFragment.kt @@ -8,6 +8,7 @@ import androidx.core.content.res.ResourcesCompat import androidx.core.widget.doAfterTextChanged import androidx.navigation.fragment.findNavController import io.agora.ktvapi.KTVSingRole +import io.agora.ktvdemo.BuildConfig import io.agora.ktvdemo.rtc.IChannelEventListener import io.agora.ktvdemo.R import io.agora.ktvdemo.rtc.RtcEngineController @@ -40,6 +41,7 @@ class MainFragment : BaseFragment() { btnLeadSinger.setOnClickListener { resetRoleView() KeyCenter.role = KTVSingRole.LeadSinger + KeyCenter.localUid = KeyCenter.LeadSingerUid setRoleView() } @@ -54,15 +56,26 @@ class MainFragment : BaseFragment() { // 选择加载歌曲的类型, MCC 声网歌曲中心或者本地歌曲 groupSongType.setOnCheckedChangeListener { _, checkedId -> KeyCenter.isMcc = checkedId == R.id.rbtMccSong } + // 选择体验 KTVApi 的类型, 普通合唱或者大合唱 + ktvApiType.setOnCheckedChangeListener { _, checkedId -> KeyCenter.isNormalChorus = checkedId == R.id.rbtNormalChorus} + // 开始体验按钮 btnStartChorus.setOnClickListener { + if (BuildConfig.AGORA_APP_ID.isEmpty()) { + toast(getString(R.string.app_appid_check)) + return@setOnClickListener + } +// if (!KeyCenter.isNormalChorus && BuildConfig.RESTFUL_API_KEY.isEmpty()) { +// toast(getString(R.string.app_restful_check)) +// return@setOnClickListener +// } if (KeyCenter.channelId.isEmpty()){ toast(getString(R.string.app_input_channel_name)) return@setOnClickListener } RtcEngineController.eventListener = IChannelEventListener() - // 这里一共获取了三个 Token + // 这里一共获取了四个 Token // 1、加入主频道使用的 Rtc Token // 2、如果要使用 MCC 模块获取歌单、下载歌曲,需要 RTM Token 进行鉴权,如果您有自己的歌单就不需要获取该 token // 3、合唱需要用到的合唱子频道 token,如果您只需要独唱就不需要获取该 token @@ -76,21 +89,47 @@ class MainFragment : BaseFragment() { success = { ret -> val rtcToken = ret[TokenGenerator.AgoraTokenType.rtc] ?: "" val rtmToken = ret[TokenGenerator.AgoraTokenType.rtm] ?: "" - TokenGenerator.generateToken("${KeyCenter.channelId}_ex", KeyCenter.localUid.toString(), - TokenGenerator.TokenGeneratorType.token007, TokenGenerator.AgoraTokenType.rtc, - success = { chorusToken -> - RtcEngineController.rtcToken = rtcToken - RtcEngineController.rtmToken = rtmToken - RtcEngineController.chorusChannelRtcToken = chorusToken - findNavController().navigate(R.id.action_mainFragment_to_livingFragment) - }, - failure = { - toast("获取 token 异常") - } - ) + + if (KeyCenter.isNormalChorus) { + TokenGenerator.generateToken("${KeyCenter.channelId}_ex", KeyCenter.localUid.toString(), + TokenGenerator.TokenGeneratorType.token007, TokenGenerator.AgoraTokenType.rtc, + success = { chorusToken -> + RtcEngineController.audienceChannelToken = rtcToken + RtcEngineController.rtmToken = rtmToken + RtcEngineController.chorusChannelRtcToken = chorusToken + findNavController().navigate(R.id.action_mainFragment_to_livingFragment) + }, + failure = { + toast("获取 token 异常1") + } + ) + } else { + TokenGenerator.generateToken("${KeyCenter.channelId}_ad", KeyCenter.localUid.toString(), + TokenGenerator.TokenGeneratorType.token007, TokenGenerator.AgoraTokenType.rtc, + success = { audienceToken -> + TokenGenerator.generateToken(KeyCenter.channelId, "2023", + TokenGenerator.TokenGeneratorType.token007, TokenGenerator.AgoraTokenType.rtc, + success = { musicToken -> + RtcEngineController.chorusChannelRtcToken = rtcToken + RtcEngineController.rtmToken = rtmToken + RtcEngineController.audienceChannelToken = audienceToken + RtcEngineController.musicStreamToken = musicToken + findNavController().navigate(R.id.action_mainFragment_to_livingFragment) + }, + failure = { + toast("获取 token 异常2") + } + ) + }, + failure = { + toast("获取 token 异常3") + } + ) + } + }, failure = { - toast("获取 token 异常") + toast("获取 token 异常4") } ) } diff --git a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/utils/KeyCenter.kt b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/utils/KeyCenter.kt index 6c95983..25bc09d 100644 --- a/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/utils/KeyCenter.kt +++ b/KTVAPI/Android/app/src/main/java/io/agora/ktvdemo/utils/KeyCenter.kt @@ -12,7 +12,7 @@ object KeyCenter { /* * 测试歌曲的 songCode */ - const val songCode: Long = 6625526607662280 + const val songCode: Long = 7162848697922600 /* * 加入的频道名 @@ -29,6 +29,11 @@ object KeyCenter { */ var isMcc: Boolean = true + /* + * 体验 KTVAPI 的类型, true为普通合唱、false为大合唱 + */ + var isNormalChorus: Boolean = true + /* * 当前演唱中的身份 */ diff --git a/KTVAPI/Android/app/src/main/res/layout/fragment_main.xml b/KTVAPI/Android/app/src/main/res/layout/fragment_main.xml index 5f6e4e3..e548c44 100644 --- a/KTVAPI/Android/app/src/main/res/layout/fragment_main.xml +++ b/KTVAPI/Android/app/src/main/res/layout/fragment_main.xml @@ -87,6 +87,31 @@ android:text="@string/app_local_music_tag" /> + + + + + + + + app:layout_constraintTop_toBottomOf="@id/ktvApiType" /> \ No newline at end of file diff --git a/KTVAPI/Android/app/src/main/res/values-zh/strings.xml b/KTVAPI/Android/app/src/main/res/values-zh/strings.xml index 1ca7588..51ca264 100644 --- a/KTVAPI/Android/app/src/main/res/values-zh/strings.xml +++ b/KTVAPI/Android/app/src/main/res/values-zh/strings.xml @@ -19,5 +19,9 @@ 请输入 channel name MCC 声网歌曲中心 Local 本地音乐 + 独唱、小合唱 + 大合唱 开始体验 + 请检查 gradle.properties 文件内 APPID APP 证书是否正确填写 + 请检查 gradle.properties 文件内 RESTFUL API KEY 是否正确填写 \ No newline at end of file diff --git a/KTVAPI/Android/app/src/main/res/values/strings.xml b/KTVAPI/Android/app/src/main/res/values/strings.xml index 21fa8a9..4324cec 100644 --- a/KTVAPI/Android/app/src/main/res/values/strings.xml +++ b/KTVAPI/Android/app/src/main/res/values/strings.xml @@ -18,5 +18,9 @@ 请输入 channel name MCC 声网歌曲中心 Local 本地音乐 + 独唱、小合唱 + 大合唱 开始体验 + 请检查 gradle.properties 文件内 APPID APP 证书是否正确填写 + 请检查 gradle.properties 文件内 RESTFUL API KEY 是否正确填写 \ No newline at end of file diff --git a/KTVAPI/Android/app/src/main/res/xml/network_security_config.xml b/KTVAPI/Android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..dca93c0 --- /dev/null +++ b/KTVAPI/Android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/KTVAPI/Android/build.gradle b/KTVAPI/Android/build.gradle index d690d5c..dfa5f96 100644 --- a/KTVAPI/Android/build.gradle +++ b/KTVAPI/Android/build.gradle @@ -15,6 +15,7 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:7.1.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "com.google.protobuf:protobuf-gradle-plugin:0.8.19" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } diff --git a/KTVAPI/Android/gradle.properties b/KTVAPI/Android/gradle.properties index 368969b..ddc3612 100644 --- a/KTVAPI/Android/gradle.properties +++ b/KTVAPI/Android/gradle.properties @@ -29,4 +29,8 @@ TOOLBOX_SERVER_HOST=https://service.agora.io/toolbox #----------- Config Agora Keys ----------- # RTM RTC SDK key Config AGORA_APP_ID= -AGORA_APP_CERTIFICATE= \ No newline at end of file +AGORA_APP_CERTIFICATE= + +# Restful Api Config (Giant Chorus Only) +RESTFUL_API_KEY= +RESTFUL_API_SECRET= \ No newline at end of file diff --git a/KTVAPI/Android/lib_ktvapi/build.gradle b/KTVAPI/Android/lib_ktvapi/build.gradle index c8c685d..45697fa 100644 --- a/KTVAPI/Android/lib_ktvapi/build.gradle +++ b/KTVAPI/Android/lib_ktvapi/build.gradle @@ -2,6 +2,7 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' apply plugin: 'maven-publish' +//apply plugin: 'com.google.protobuf' android { namespace 'io.agora.ktv' @@ -38,8 +39,34 @@ android { checkReleaseBuilds false abortOnError false } +// sourceSets { +// main { +// //实际测试指不指定无所谓,不影响 Java 文件生成 +// proto { +// srcDir 'src/main' +// } +// } +// } } +//protobuf { +// //配置 protoc 编译器 +// protoc { +// artifact = 'com.google.protobuf:protoc:3.19.2' +// } +// //配置生成目录,编译后会在 build 的目录下生成对应的java文件 +// generateProtoTasks { +// all().each { task -> +// task.builtins { +// remove java +// } +// task.builtins { +// java {} +// } +// } +// } +//} + dependencies { implementation 'androidx.core:core-ktx:1.7.0' @@ -49,7 +76,9 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' - api "io.agora.rtc:agora-special-full:4.1.1.23" + api "io.agora.rtc:agora-special-full:4.3.2.4" + implementation 'com.google.protobuf:protobuf-java:3.19.3' + implementation 'com.google.protobuf:protobuf-java-util:3.19.3' } // Because the components are created only during the afterEvaluate phase, you must diff --git a/KTVAPI/Android/lib_ktvapi/src/main/java/LrcTime.proto b/KTVAPI/Android/lib_ktvapi/src/main/java/LrcTime.proto new file mode 100644 index 0000000..d468305 --- /dev/null +++ b/KTVAPI/Android/lib_ktvapi/src/main/java/LrcTime.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +enum MsgType { + UNKNOWN_TYPE = 0; + LRC_TIME = 1001; +} + +message LrcTime { + MsgType type = 1; + bool forward = 2; + int64 ts = 3; + string songId = 4; + int32 uid = 5; +} \ No newline at end of file diff --git a/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/APIReporter.kt b/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/APIReporter.kt new file mode 100644 index 0000000..b0b0560 --- /dev/null +++ b/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/APIReporter.kt @@ -0,0 +1,141 @@ +package io.agora.ktvapi + +import android.util.Log +import io.agora.rtc2.Constants +import io.agora.rtc2.RtcEngine +import org.json.JSONObject +import java.util.HashMap + +enum class APIType(val value: Int) { + KTV(1), // K歌 + CALL(2), // 呼叫连麦 + BEAUTY(3), // 美颜 + VIDEO_LOADER(4), // 秒开秒切 + PK(5), // 团战 + VIRTUAL_SPACE(6), // + SCREEN_SPACE(7), // 屏幕共享 + AUDIO_SCENARIO(8) // 音频 +} + +enum class ApiEventType(val value: Int) { + API(0), + COST(1), + CUSTOM(2) +} + +object ApiEventKey { + const val TYPE = "type" + const val DESC = "desc" + const val API_VALUE = "apiValue" + const val TIMESTAMP = "ts" + const val EXT = "ext" +} + +object ApiCostEvent { + const val CHANNEL_USAGE = "channelUsage" //频道使用耗时 + const val FIRST_FRAME_ACTUAL = "firstFrameActual" //首帧实际耗时 + const val FIRST_FRAME_PERCEIVED = "firstFramePerceived" //首帧感官耗时 +} + +class APIReporter( + private val type: APIType, + private val version: String, + private val rtcEngine: RtcEngine +) { + private val tag = "APIReporter" + private val messageId = "agora:scenarioAPI" + private val durationEventStartMap = HashMap() + private val category = "${type.value}_Android_$version" + + init { + configParameters() + } + + // 上报普通场景化API + fun reportFuncEvent(name: String, value: Map, ext: Map) { + writeLog("reportFuncEvent: $name value: $value ext: $ext", Constants.LOG_LEVEL_INFO) + val eventMap = mapOf(ApiEventKey.TYPE to ApiEventType.API.value, ApiEventKey.DESC to name) + val labelMap = mapOf(ApiEventKey.API_VALUE to value, ApiEventKey.TIMESTAMP to getCurrentTs(), ApiEventKey.EXT to ext) + val event = convertToJSONString(eventMap) ?: "" + val label = convertToJSONString(labelMap) ?: "" + rtcEngine.sendCustomReportMessage(messageId, category, event, label, 0) + } + + fun startDurationEvent(name: String) { + Log.d(tag, "startDurationEvent: $name") + durationEventStartMap[name] = getCurrentTs() + } + + fun endDurationEvent(name: String, ext: Map) { + Log.d(tag, "endDurationEvent: $name") + val beginTs = durationEventStartMap[name] ?: return + durationEventStartMap.remove(name) + val ts = getCurrentTs() + val cost = (ts - beginTs).toInt() + + innerReportCostEvent(ts, name, cost, ext) + } + + // 上报耗时打点信息 + fun reportCostEvent(name: String, cost: Int, ext: Map) { + durationEventStartMap.remove(name) + innerReportCostEvent( + ts = getCurrentTs(), + name = name, + cost = cost, + ext = ext + ) + } + + // 上报自定义信息 + fun reportCustomEvent(name: String, ext: Map) { + Log.d(tag, "reportCustomEvent: $name ext: $ext") + val eventMap = mapOf(ApiEventKey.TYPE to ApiEventType.CUSTOM.value, ApiEventKey.DESC to name) + val labelMap = mapOf(ApiEventKey.TIMESTAMP to getCurrentTs(), ApiEventKey.EXT to ext) + val event = convertToJSONString(eventMap) ?: "" + val label = convertToJSONString(labelMap) ?: "" + rtcEngine.sendCustomReportMessage(messageId, category, event, label, 0) + } + + fun writeLog(content: String, level: Int) { + Log.d(tag, content) + rtcEngine.writeLog(level, content) + } + + fun cleanCache() { + durationEventStartMap.clear() + } + + // ---------------------- private ---------------------- + + private fun configParameters() { + //rtcEngine.setParameters("{\"rtc.qos_for_test_purpose\": true}") //测试环境使用 + // 数据上报 + rtcEngine.setParameters("{\"rtc.direct_send_custom_event\": true}") + // 日志写入 + rtcEngine.setParameters("{\"rtc.log_external_input\": true}") + } + + private fun getCurrentTs(): Long { + return System.currentTimeMillis() + } + + private fun innerReportCostEvent(ts: Long, name: String, cost: Int, ext: Map) { + Log.d(tag, "reportCostEvent: $name cost: $cost ms ext: $ext") + writeLog("reportCostEvent: $name cost: $cost ms", Constants.LOG_LEVEL_INFO) + val eventMap = mapOf(ApiEventKey.TYPE to ApiEventType.COST.value, ApiEventKey.DESC to name) + val labelMap = mapOf(ApiEventKey.TIMESTAMP to ts, ApiEventKey.EXT to ext) + val event = convertToJSONString(eventMap) ?: "" + val label = convertToJSONString(labelMap) ?: "" + rtcEngine.sendCustomReportMessage(messageId, category, event, label, cost) + } + + private fun convertToJSONString(dictionary: Map): String? { + return try { + JSONObject(dictionary).toString() + } catch (e: Exception) { + writeLog("[$tag]convert to json fail: $e dictionary: $dictionary", Constants.LOG_LEVEL_WARNING) + null + } + } +} \ No newline at end of file diff --git a/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/KTVApi.kt b/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/KTVApi.kt index 9ecff9a..fe22674 100644 --- a/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/KTVApi.kt +++ b/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/KTVApi.kt @@ -12,12 +12,12 @@ import io.agora.rtc2.RtcEngine * KTV场景类型 * @param Normal 普通独唱或多人合唱 * @param SingBattle 嗨歌抢唱 + * @param SingRelay 抢麦接唱 */ enum class KTVType(val value: Int) { Normal(0), SingBattle(1), - Cantata(2), - SingRelay(3) + SingRelay(2) } /** @@ -33,9 +33,9 @@ enum class KTVMusicType(val value: Int) { /** * 在KTVApi中的身份 * @param SoloSinger 独唱者: 当前只有自己在唱歌 - * @param CoSinger 合唱者: 加入合唱需要通过调用switchSingerRole将切换身份成合唱 + * @param CoSinger 伴唱: 加入合唱需要通过调用switchSingerRole将切换身份成合唱 * @param LeadSinger 主唱: 有合唱者加入后,需要通过调用switchSingerRole切换身份成主唱 - * @param Audience 观众: 默认状态 + * @param Audience 听众: 默认状态 */ enum class KTVSingRole(val value: Int) { SoloSinger(0), @@ -45,15 +45,16 @@ enum class KTVSingRole(val value: Int) { } /** - * loadSong失败的原因 + * loadMusic失败的原因 * @param NO_LYRIC_URL 没有歌词,不影响音乐正常播放 * @param MUSIC_PRELOAD_FAIL 音乐加载失败 * @param CANCELED 本次加载已终止 */ -enum class KTVLoadSongFailReason(val value: Int) { +enum class KTVLoadMusicFailReason(val value: Int) { NO_LYRIC_URL(0), MUSIC_PRELOAD_FAIL(1), - CANCELED(2) + CANCELED(2), + GET_SIMPLE_INFO_FAIL(3) } /** @@ -66,6 +67,17 @@ enum class SwitchRoleFailReason(val value: Int) { NO_PERMISSION(1) } +/** + * 加入合唱错误原因 + * @param JOIN_CHANNEL_FAIL 加入合唱子频道失败 + * @param MUSIC_OPEN_FAIL 歌曲open失败 + */ +enum class KTVJoinChorusFailReason(val value: Int) { + JOIN_CHANNEL_FAIL(0), + MUSIC_OPEN_FAIL(1) +} + + /** * 加载音乐的模式 * @param LOAD_MUSIC_ONLY 只加载音乐(通常加入合唱前使用此模式) @@ -92,7 +104,43 @@ enum class MusicLoadStatus(val value: Int) { } /** - * 歌词组件接口,您setLrcView传入的歌词组件需要继承此接口类,并实现以下三个方法 + * 音乐音轨模式 + * @param YUAN_CHANG 原唱:主唱开启原唱后,自己听到原唱,听众听到原唱 + * @param BAN_ZOU 伴奏:主唱开启伴奏后,自己听到伴奏,听众听到伴奏 + * @param DAO_CHANG 导唱:主唱开启导唱后,自己听到原唱,听众听到伴奏 + */ +enum class AudioTrackMode(val value: Int) { + YUAN_CHANG(0), + BAN_ZOU(1), + DAO_CHANG(2), +} + +/** + * 大合唱中演唱者互相收听对方音频流的选路策略 + * @param RANDOM 随机选取几条流 + * @param BY_DELAY 根据延迟选择最低的几条流 + * @param TOP_N 根据音强选流 + * @param BY_DELAY_AND_TOP_N 同时开始延迟选路和音强选流 + */ +enum class GiantChorusRouteSelectionType(val value: Int) { + RANDOM(0), + BY_DELAY(1), + TOP_N(2), + BY_DELAY_AND_TOP_N(3) +} + +/** + * 大合唱中演唱者互相收听对方音频流的选路配置 + * @param type 选路策略 + * @param streamNum 最大选取的流个数(推荐6) + */ +data class GiantChorusRouteSelectionConfig constructor( + val type: GiantChorusRouteSelectionType, + val streamNum: Int +) + +/** + * 歌词组件接口,您setLrcView传入的歌词组件需要继承此接口类,并实现以下几个方法 */ interface ILrcView { /** @@ -109,11 +157,14 @@ interface ILrcView { /** * ktvApi获取到歌词地址时会主动调用此方法将歌词地址url传给你的歌词组件,您需要在这个回调内完成歌词的下载 + * @param url 歌词地址 */ fun onDownloadLrcData(url: String?) /** * ktvApi获取到抢唱切片歌曲副歌片段时间时,会调用此方法回调给歌词组件 + * @param highStartTime 副歌片段起始时间 + * @param highEndTime 副歌片段终止时间 */ fun onHighPartTime(highStartTime: Long, highEndTime: Long) } @@ -124,24 +175,25 @@ interface ILrcView { interface IMusicLoadStateListener { /** * 音乐加载成功 - * @param songCode 歌曲编码, 和你loadMusic传入的songCode一致 + * @param songCode 歌曲编码,和loadMusic传入的songCode一致 * @param lyricUrl 歌词地址 */ fun onMusicLoadSuccess(songCode: Long, lyricUrl: String) /** * 音乐加载失败 + * @param songCode 加载失败的歌曲编码 * @param reason 歌曲加载失败的原因 */ - fun onMusicLoadFail(songCode: Long, reason: KTVLoadSongFailReason) + fun onMusicLoadFail(songCode: Long, reason: KTVLoadMusicFailReason) /** * 音乐加载进度 * @param songCode 歌曲编码 * @param percent 歌曲加载进度 * @param status 歌曲加载的状态 - * @param msg - * @param lyricUrl + * @param msg 相关信息 + * @param lyricUrl 歌词地址 */ fun onMusicLoadProgress(songCode: Long, percent: Int, status: MusicLoadStatus, msg: String?, lyricUrl: String?) } @@ -162,6 +214,11 @@ interface ISwitchRoleStateListener { fun onSwitchRoleFail(reason: SwitchRoleFailReason) } +interface OnJoinChorusStateListener { + fun onJoinChorusSuccess() + fun onJoinChorusFail(reason: KTVJoinChorusFailReason) +} + /** * KTVApi事件回调 */ @@ -169,11 +226,11 @@ abstract class IKTVApiEventHandler { /** * 播放器状态变化 * @param state MediaPlayer 播放状态 - * @param error MediaPlayer Error 信息 + * @param reason MediaPlayer Error 信息 * @param isLocal 本地还是主唱端的 Player 信息 */ open fun onMusicPlayerStateChanged( - state: Constants.MediaPlayerState, error: Constants.MediaPlayerError, isLocal: Boolean + state: Constants.MediaPlayerState, reason: Constants.MediaPlayerReason, isLocal: Boolean ) { } @@ -229,33 +286,97 @@ data class KTVApiConfig constructor( val maxCacheSize: Int = 10, val type: KTVType = KTVType.Normal, val musicType: KTVMusicType = KTVMusicType.SONG_CODE -) +) { + override fun toString(): String { + return "channelName:$channelName, localUid:$localUid, chorusChannelName:$chorusChannelName, type:$type, musicType:$musicType" + } +} + +/** + * 初始化KTVGiantChorusApi的配置 + * @param appId 用来初始化 Mcc Engine + * @param rtmToken 创建 Mcc Engine 需要 + * @param engine RTC engine 对象 + * @param localUid 创建 Mcc engine 和 加入子频道需要用到 + * @param audienceChannelName 观众频道名 加入听众频道需要用到 + * @param chorusChannelToken 观众频道token 加入听众频道需要用到 + * @param chorusChannelName 演唱频道名 加入演唱频道需要用到 + * @param chorusChannelToken 演唱频道token 加入演唱频道需要用到 + * @param musicStreamUid 音乐Uid 主唱推入频道 + * @param musicStreamToken 音乐流token + * @param maxCacheSize 最大缓存歌曲数 + * @param musicType 音乐类型 + * @param routeSelectionConfig 选路配置 + */ +data class KTVGiantChorusApiConfig constructor( + val appId: String, + val rtmToken: String, + val engine: RtcEngine, + val localUid: Int, + val audienceChannelName: String, + val audienceChannelToken: String, + val chorusChannelName: String, + val chorusChannelToken: String, + val musicStreamUid: Int, + val musicStreamToken: String, + val maxCacheSize: Int = 10, + val musicType: KTVMusicType = KTVMusicType.SONG_CODE +) { + override fun toString(): String { + return "audienceChannelName:$audienceChannelName, localUid:$localUid, chorusChannelName:$chorusChannelName, musicStreamUid:$musicStreamUid, musicType:$musicType" + } +} /** * 加载歌曲的配置,不允许在一首歌没有load完成前(成功/失败均算完成)进行下一首歌的加载 - * @param autoPlay 是否自动播放歌曲(通常主唱选择true)默认为false - * @param mode 歌曲加载的模式, 默认为音乐和歌词均加载 - * @param songCode 歌曲 id + * @param songIdentifier 歌曲 id,通常由业务方给每首歌设置一个不同的SongId用于区分 * @param mainSingerUid 主唱的 Uid,如果是伴唱,伴唱需要根据这个信息 mute 主频道主唱的声音 + * @param mode 歌曲加载的模式,默认为音乐和歌词均加载 + * @param needPrelude 播放切片歌曲情况下,是否播放 */ data class KTVLoadMusicConfiguration( val songIdentifier: String, - val autoPlay: Boolean = false, val mainSingerUid: Int, - val mode: KTVLoadMusicMode = KTVLoadMusicMode.LOAD_MUSIC_AND_LRC -) + val mode: KTVLoadMusicMode = KTVLoadMusicMode.LOAD_MUSIC_AND_LRC, + val needPrelude: Boolean = false +) { + override fun toString(): String { + return "songIdentifier:$songIdentifier, mainSingerUid:$mainSingerUid, mode:$mode, needPrelude:$needPrelude" + } +} /** - * 获取 KTVApi 实例 + * 创建普通合唱KTVApi实例 */ -fun createKTVApi(): KTVApi = KTVApiImpl() +fun createKTVApi(config: KTVApiConfig): KTVApi = KTVApiImpl(config) +/** + * 创建大合唱KTVApi实例 + */ +fun createKTVGiantChorusApi(config: KTVGiantChorusApiConfig): KTVApi = KTVGiantChorusApiImpl(config) + +/** + * KTVApi 接口 + */ interface KTVApi { - /** - * 初始化内部变量/缓存数据,并注册相应的监听,必须在其他KTVApi调用前调用initialize初始化KTVApi - * @param config 初始化KTVApi的配置 - */ - fun initialize(config: KTVApiConfig) + + companion object { + // 听到远端的音量 + var remoteVolume: Int = 30 + // 本地mpk播放音量 + var mpkPlayoutVolume: Int = 50 + // mpk发布音量 + var mpkPublishVolume: Int = 50 + + // 是否使用音频自采集 + var useCustomAudioSource = false + // 调试使用,会输出更多的日志 + var debugMode = false + // 内部测试使用,无需关注 + var mccDomain = "" + // 大合唱的选路策略 + var routeSelectionConfig = GiantChorusRouteSelectionConfig(GiantChorusRouteSelectionType.BY_DELAY, 6) + } /** * 更新ktvapi内部使用的streamId,每次加入频道需要更新内部streamId @@ -279,11 +400,6 @@ interface KTVApi { */ fun release() - /** - * 开启关闭专业模式 - */ - fun enableProfessionalStreamerMode(enable: Boolean) - /** * 收到 IKTVApiEventHandler.onTokenPrivilegeWillExpire 回调时需要主动调用方法更新Token * @param rtmToken musicContentCenter模块需要的rtm token @@ -300,7 +416,7 @@ interface KTVApi { */ fun fetchMusicCharts( onMusicChartResultListener: ( - requestId: String?, // TODO 不需要? + requestId: String?, status: Int, // status=2 时token过期 list: Array? ) -> Unit @@ -320,7 +436,7 @@ interface KTVApi { pageSize: Int, jsonOption: String, onMusicCollectionResultListener: ( - requestId: String?, // TODO 不需要? + requestId: String?, status: Int, // status=2 时token过期 page: Int, pageSize: Int, @@ -341,7 +457,7 @@ interface KTVApi { page: Int, pageSize: Int, jsonOption: String, onMusicCollectionResultListener: ( - requestId: String?, // TODO 不需要? + requestId: String?, status: Int, // status=2 时token过期 page: Int, pageSize: Int, @@ -358,10 +474,10 @@ interface KTVApi { * * 推荐调用: * 歌曲开始时: - * 主唱 loadMusic(KTVLoadMusicConfiguration(autoPlay=true, mode=LOAD_MUSIC_AND_LRC, songCode, mainSingerUid)) switchSingerRole(SoloSinger) - * 观众 loadMusic(KTVLoadMusicConfiguration(autoPlay=false, mode=LOAD_LRC_ONLY, songCode, mainSingerUid)) + * 主唱 loadMusic(KTVLoadMusicConfiguration(mode=LOAD_MUSIC_AND_LRC, songCode, mainSingerUid)) switchSingerRole(SoloSinger) + * 观众 loadMusic(KTVLoadMusicConfiguration(mode=LOAD_LRC_ONLY, songCode, mainSingerUid)) * 加入合唱时: - * 准备加入合唱者:loadMusic(KTVLoadMusicConfiguration(autoPlay=false, mode=LOAD_MUSIC_ONLY, songCode, mainSingerUid)) + * 准备加入合唱者:loadMusic(KTVLoadMusicConfiguration(mode=LOAD_MUSIC_ONLY, songCode, mainSingerUid)) * loadMusic成功后switchSingerRole(CoSinger) */ fun loadMusic( @@ -378,15 +494,15 @@ interface KTVApi { /** * 加载歌曲,同时只能为一首歌loadSong,同步调用, 一般使用此loadSong是歌曲已经preload成功(url为本地文件地址) - * @param config 加载歌曲配置 * @param url 歌曲地址 + * @param config 加载歌曲配置 * * 推荐调用: * 歌曲开始时: - * 主唱 loadMusic(KTVLoadMusicConfiguration(autoPlay=true, mode=LOAD_MUSIC_AND_LRC, url, mainSingerUid)) switchSingerRole(SoloSinger) - * 观众 loadMusic(KTVLoadMusicConfiguration(autoPlay=false, mode=LOAD_LRC_ONLY, url, mainSingerUid)) + * 主唱 loadMusic(KTVLoadMusicConfiguration(mode=LOAD_MUSIC_AND_LRC, url, mainSingerUid)) switchSingerRole(SoloSinger) + * 观众 loadMusic(KTVLoadMusicConfiguration(mode=LOAD_LRC_ONLY, url, mainSingerUid)) * 加入合唱时: - * 准备加入合唱者:loadMusic(KTVLoadMusicConfiguration(autoPlay=false, mode=LOAD_MUSIC_ONLY, url, mainSingerUid)) + * 准备加入合唱者:loadMusic(KTVLoadMusicConfiguration(mode=LOAD_MUSIC_ONLY, url, mainSingerUid)) * loadMusic成功后switchSingerRole(CoSinger) */ fun loadMusic( @@ -396,17 +512,17 @@ interface KTVApi { /** * 加载歌曲,同时只能为一首歌loadSong,同步调用, 一般使用此loadSong是歌曲已经preload成功(url为本地文件地址) - * @param config 加载歌曲配置,config.autoPlay = true,默认播放url1 + * @param config 加载歌曲配置,默认播放url1 * @param url1 歌曲地址1 * @param url2 歌曲地址2 * * * 推荐调用: * 歌曲开始时: - * 主唱 loadMusic(KTVLoadMusicConfiguration(autoPlay=true, mode=LOAD_MUSIC_AND_LRC, url, mainSingerUid)) switchSingerRole(SoloSinger) - * 观众 loadMusic(KTVLoadMusicConfiguration(autoPlay=false, mode=LOAD_LRC_ONLY, url, mainSingerUid)) + * 主唱 loadMusic(KTVLoadMusicConfiguration(mode=LOAD_MUSIC_AND_LRC, url, mainSingerUid)) switchSingerRole(SoloSinger) + * 观众 loadMusic(KTVLoadMusicConfiguration(mode=LOAD_LRC_ONLY, url, mainSingerUid)) * 加入合唱时: - * 准备加入合唱者:loadMusic(KTVLoadMusicConfiguration(autoPlay=false, mode=LOAD_MUSIC_ONLY, url, mainSingerUid)) + * 准备加入合唱者:loadMusic(KTVLoadMusicConfiguration(mode=LOAD_MUSIC_ONLY, url, mainSingerUid)) * loadMusic成功后switchSingerRole(CoSinger) */ fun load2Music( @@ -446,9 +562,6 @@ interface KTVApi { * 播放歌曲 * @param songCode 歌曲唯一编码 * @param startPos 开始播放的位置 - * 对于主唱: - * 如果loadMusic时你选择了autoPlay = true 则不需要主动调用startSing - * 如果loadMusic时你选择了autoPlay = false 则需要在loadMusic成功后调用startSing */ fun startSing(songCode: Long, startPos: Long) @@ -456,9 +569,6 @@ interface KTVApi { * 播放歌曲 * @param url 歌曲地址 * @param startPos 开始播放的位置 - * 对于主唱: - * 如果loadMusic时你选择了autoPlay = true 则不需要主动调用startSing - * 如果loadMusic时你选择了autoPlay = false 则需要在loadMusic成功后调用startSing */ fun startSing(url: String, startPos: Long) @@ -504,4 +614,19 @@ interface KTVApi { * 获取mcc实例 */ fun getMusicContentCenter() : IAgoraMusicContentCenter + + /** + * 切换音轨, 原唱/伴奏/导唱 + */ + fun switchAudioTrack(mode: AudioTrackMode) + + /** + * 开启关闭专业模式,默认关 + */ + fun enableProfessionalStreamerMode(enable: Boolean) + + /** + * 开启 Multipathing, 默认开 + */ + fun enableMulitpathing(enable: Boolean) } \ No newline at end of file diff --git a/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/KTVApiImpl.kt b/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/KTVApiImpl.kt index 798d9e8..732ce74 100644 --- a/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/KTVApiImpl.kt +++ b/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/KTVApiImpl.kt @@ -6,48 +6,34 @@ import io.agora.mediaplayer.Constants import io.agora.mediaplayer.Constants.MediaPlayerState import io.agora.mediaplayer.IMediaPlayer import io.agora.mediaplayer.IMediaPlayerObserver +import io.agora.mediaplayer.data.CacheStatistics +import io.agora.mediaplayer.data.PlayerPlaybackStats import io.agora.mediaplayer.data.PlayerUpdatedInfo import io.agora.mediaplayer.data.SrcInfo import io.agora.musiccontentcenter.* import io.agora.rtc2.* import io.agora.rtc2.Constants.* -import io.agora.rtc2.internal.Logging import org.json.JSONException import org.json.JSONObject import java.util.concurrent.* -/** - * 加入合唱错误原因 - */ -enum class KTVJoinChorusFailReason(val value: Int) { - JOIN_CHANNEL_FAIL(0), // 加入channel2失败 - MUSIC_OPEN_FAIL(1) // 歌曲open失败 -} +class KTVApiImpl( + val ktvApiConfig: KTVApiConfig +) : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver, IRtcEngineEventHandler() { -interface OnJoinChorusStateListener { - fun onJoinChorusSuccess() - fun onJoinChorusFail(reason: KTVJoinChorusFailReason) -} - -class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver, - IRtcEngineEventHandler() { - private val tag: String = "KTV_API_LOG" - var debugMode = false - - // 外部可修改 - var useCustomAudioSource:Boolean = false - - // 音频最佳实践 - var remoteVolume: Int = 30 // 远端音频 - var mpkPlayoutVolume: Int = 50 - var mpkPublishVolume: Int = 50 + companion object { + private val scheduledThreadPool: ScheduledExecutorService = Executors.newScheduledThreadPool(5) + const val tag = "KTV_API_LOG" + const val version = "5.0.0" + const val lyricSyncVersion = 2 + } private val mainHandler by lazy { Handler(Looper.getMainLooper()) } - private lateinit var mRtcEngine: RtcEngineEx + private var mRtcEngine: RtcEngineEx = ktvApiConfig.engine as RtcEngineEx private lateinit var mMusicCenter: IAgoraMusicContentCenter - private lateinit var mPlayer: IMediaPlayer + private var mPlayer: IMediaPlayer + private val apiReporter: APIReporter = APIReporter(APIType.KTV, version, mRtcEngine) - private lateinit var ktvApiConfig: KTVApiConfig private var innerDataStreamId: Int = 0 private var subChorusConnection: RtcConnection? = null @@ -60,6 +46,7 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver private val lyricCallbackMap = mutableMapOf Unit>() // (requestId, callback) private val lyricSongCodeMap = mutableMapOf() // (requestId, songCode) + private val simpleInfoCallbackMap = mutableMapOf Unit>() // (requestId, callback) private val loadMusicCallbackMap = mutableMapOf? = null + private val displayLrcTask = object : Runnable { + override fun run() { + if (!mStopDisplayLrc) { + if (singerRole == KTVSingRole.Audience && !recvFromDataStream) return // audioMetaData方案观众return + val lastReceivedTime = mLastReceivedPlayPosTime ?: return + val curTime = System.currentTimeMillis() + val offset = curTime - lastReceivedTime + if (offset <= 1000) { + val curTs = mReceivedPlayPosition + offset + highStartTime + if (singerRole == KTVSingRole.LeadSinger || singerRole == KTVSingRole.SoloSinger) { + val lrcTime = LrcTimeOuterClass.LrcTime.newBuilder() + .setTypeValue(LrcTimeOuterClass.MsgType.LRC_TIME.number) + .setForward(true) + .setSongId(songIdentifier) + .setTs(curTs) + .setUid(ktvApiConfig.localUid) + .build() + + mRtcEngine.sendAudioMetadata(lrcTime.toByteArray()) + } + runOnMainThread { + lrcView?.onUpdatePitch(pitch.toFloat()) + // (fix ENT-489)Make lyrics delay for 200ms + // Per suggestion from Bob, it has a intrinsic buffer/delay between sound and `onPositionChanged(Player)`, + // such as AEC/Player/Device buffer. + // We choose the estimated 200ms. + lrcView?.onUpdateProgress(if (curTs > 200) (curTs - 200) else curTs) // The delay here will impact both singer and audience side + } + } + } + } } - override fun initialize( - config: KTVApiConfig - ) { - this.mRtcEngine = config.engine as RtcEngineEx - - reportCallScenarioApi("initialize", JSONObject().put("config", config)) - this.ktvApiConfig = config + // 音高同步 + private var mStopSyncPitch = true + private var mSyncPitchFuture :ScheduledFuture<*>? = null + private val mSyncPitchTask = Runnable { + if (!mStopSyncPitch) { + if (ktvApiConfig.type == KTVType.SingRelay && + (singerRole == KTVSingRole.LeadSinger || singerRole == KTVSingRole.SoloSinger || singerRole == KTVSingRole.CoSinger) && + isOnMicOpen) { + sendSyncPitch(pitch) + } else if (mediaPlayerState == MediaPlayerState.PLAYER_STATE_PLAYING && + (singerRole == KTVSingRole.LeadSinger || singerRole == KTVSingRole.SoloSinger)) { + sendSyncPitch(pitch) + } + } + } - // ------------------ 初始化内容中心 ------------------ - if (config.musicType == KTVMusicType.SONG_CODE) { + init { + apiReporter.reportFuncEvent("initialize", mapOf("config" to ktvApiConfig.toString()), mapOf()) + if (ktvApiConfig.musicType == KTVMusicType.SONG_CODE) { val contentCenterConfiguration = MusicContentCenterConfiguration() - contentCenterConfiguration.appId = config.appId + contentCenterConfiguration.appId = ktvApiConfig.appId contentCenterConfiguration.mccUid = ktvApiConfig.localUid.toLong() - contentCenterConfiguration.token = config.rtmToken - contentCenterConfiguration.maxCacheSize = config.maxCacheSize - if (debugMode) { - contentCenterConfiguration.mccDomain = "api-test.agora.io" + contentCenterConfiguration.token = ktvApiConfig.rtmToken + contentCenterConfiguration.maxCacheSize = ktvApiConfig.maxCacheSize + if (KTVApi.debugMode) { + contentCenterConfiguration.mccDomain = KTVApi.mccDomain } mMusicCenter = IAgoraMusicContentCenter.create(mRtcEngine) mMusicCenter.initialize(contentCenterConfiguration) @@ -152,8 +169,8 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver } else { mPlayer = mRtcEngine.createMediaPlayer() } - mPlayer.adjustPublishSignalVolume(mpkPublishVolume) - mPlayer.adjustPlayoutVolume(mpkPlayoutVolume) + mPlayer.adjustPublishSignalVolume(KTVApi.mpkPublishVolume) + mPlayer.adjustPlayoutVolume(KTVApi.mpkPlayoutVolume) // 注册回调 mRtcEngine.addHandler(this) @@ -165,13 +182,26 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver startSyncPitch() isRelease = false - if (config.type == KTVType.SingRelay) { - this.remoteVolume = 100 + if (ktvApiConfig.type == KTVType.SingRelay) { + KTVApi.remoteVolume = 100 } + mPlayer.setPlayerOption("play_pos_change_callback", 100) + } + + // 日志输出 + private fun ktvApiLog(msg: String) { + if (isRelease) return + apiReporter.writeLog("[$tag][${ktvApiConfig.type}] $msg", LOG_LEVEL_INFO) + } + + // 日志输出 + private fun ktvApiLogError(msg: String) { + if (isRelease) return + apiReporter.writeLog("[$tag][${ktvApiConfig.type}] $msg", LOG_LEVEL_ERROR) } override fun renewInnerDataStreamId() { - reportCallScenarioApi("renewInnerDataStreamId", JSONObject()) + apiReporter.reportFuncEvent("renewInnerDataStreamId", mapOf(), mapOf()) val innerCfg = DataStreamConfig() innerCfg.syncWithAudio = true @@ -209,24 +239,44 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver if (ktvApiConfig.type == KTVType.SingRelay) { mRtcEngine.setParameters("{\"che.audio.aiaec.working_mode\":1}") } + + // 歌词强同步需要在audio4环境 + mRtcEngine.setParameters("{\"rtc.use_audio4\": true}") + + // mutipath + enableMultipathing = true + //mRtcEngine.setParameters("{\"rtc.enableMultipath\": true}") + mRtcEngine.setParameters("{\"rtc.enable_tds_request_on_join\": true}") + //mRtcEngine.setParameters("{\"rtc.remote_path_scheduling_strategy\": 0}") + //mRtcEngine.setParameters("{\"rtc.path_scheduling_strategy\": 0}") + } + + private fun resetParameters() { + mRtcEngine.setAudioScenario(AUDIO_SCENARIO_GAME_STREAMING) + mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 80000}") // 兼容之前的profile = 3设置 + mRtcEngine.setParameters("{\"che.audio.max_mixed_participants\": 3}") // 正常3路下行流混流 + mRtcEngine.setParameters("{\"che.audio.neteq.prebuffer\": false}") // 关闭 接收端快速对齐模式 + mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp\": false}") // 观众关闭 多端同步 + mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\": false}") //主播关闭多端同步 } override fun addEventHandler(ktvApiEventHandler: IKTVApiEventHandler) { - reportCallScenarioApi("addEventHandler", JSONObject()) + apiReporter.reportFuncEvent("addEventHandler", mapOf("ktvApiEventHandler" to ktvApiEventHandler), mapOf()) ktvApiEventHandlerList.add(ktvApiEventHandler) } override fun removeEventHandler(ktvApiEventHandler: IKTVApiEventHandler) { - reportCallScenarioApi("removeEventHandler", JSONObject()) + apiReporter.reportFuncEvent("removeEventHandler", mapOf("ktvApiEventHandler" to ktvApiEventHandler), mapOf()) ktvApiEventHandlerList.remove(ktvApiEventHandler) } override fun release() { - reportCallScenarioApi("release", JSONObject()) + apiReporter.reportFuncEvent("release", mapOf(), mapOf()) if (isRelease) return isRelease = true singerRole = KTVSingRole.Audience + resetParameters() stopSyncPitch() stopDisplayLrc() this.mLastReceivedPlayPosTime = null @@ -237,6 +287,7 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver loadMusicCallbackMap.clear() musicChartsCallbackMap.clear() musicCollectionCallbackMap.clear() + simpleInfoCallbackMap.clear() lrcView = null mRtcEngine.removeHandler(this) @@ -257,30 +308,31 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver } override fun enableProfessionalStreamerMode(enable: Boolean) { - reportCallScenarioApi("enableProfessionalStreamerMode", JSONObject()) + apiReporter.reportFuncEvent("enableProfessionalStreamerMode", mapOf("enable" to enable), mapOf()) this.professionalModeOpen = enable processAudioProfessionalProfile() } + // 专业模式 private fun processAudioProfessionalProfile() { ktvApiLog("processAudioProfessionalProfile: audioRouting: $audioRouting, professionalModeOpen: $professionalModeOpen, isPublishAudio:$isPublishAudio") if (!isPublishAudio) return // 必须为麦上者 if (professionalModeOpen) { // 专业 - if (audioRouting == 0 || audioRouting == 2 || audioRouting == 5 || audioRouting == 6) { + if (audioRouting == AUDIO_ROUTE_HEADSET || audioRouting == AUDIO_ROUTE_HEADSETNOMIC || audioRouting == AUDIO_ROUTE_BLUETOOTH_DEVICE_HFP || audioRouting == AUDIO_ROUTE_USBDEVICE || audioRouting == AUDIO_ROUTE_BLUETOOTH_DEVICE_A2DP) { // 耳机 关闭3A 关闭md mRtcEngine.setParameters("{\"che.audio.aec.enable\": false}") mRtcEngine.setParameters("{\"che.audio.agc.enable\": false}") mRtcEngine.setParameters("{\"che.audio.ans.enable\": false}") mRtcEngine.setParameters("{\"che.audio.md.enable\": false}") - mRtcEngine.setAudioProfile(5) // AgoraAudioProfileMusicHighQualityStereo + mRtcEngine.setAudioProfile(AUDIO_PROFILE_MUSIC_HIGH_QUALITY_STEREO) // AgoraAudioProfileMusicHighQualityStereo } else { // 非耳机 开启3A 关闭md mRtcEngine.setParameters("{\"che.audio.aec.enable\": true}") mRtcEngine.setParameters("{\"che.audio.agc.enable\": true}") mRtcEngine.setParameters("{\"che.audio.ans.enable\": true}") mRtcEngine.setParameters("{\"che.audio.md.enable\": false}") - mRtcEngine.setAudioProfile(5) // AgoraAudioProfileMusicHighQualityStereo + mRtcEngine.setAudioProfile(AUDIO_PROFILE_MUSIC_HIGH_QUALITY_STEREO) // AgoraAudioProfileMusicHighQualityStereo } } else { // 非专业 开启3A 关闭md @@ -288,12 +340,44 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver mRtcEngine.setParameters("{\"che.audio.agc.enable\": true}") mRtcEngine.setParameters("{\"che.audio.ans.enable\": true}") mRtcEngine.setParameters("{\"che.audio.md.enable\": false}") - mRtcEngine.setAudioProfile(3) // AgoraAudioProfileMusicStandardStereo + mRtcEngine.setAudioProfile(AUDIO_PROFILE_MUSIC_STANDARD_STEREO) // AgoraAudioProfileMusicStandardStereo + } + } + + override fun enableMulitpathing(enable: Boolean) { + apiReporter.reportFuncEvent("enableMulitpathing", mapOf("enable" to enable), mapOf()) + this.enableMultipathing = enable + + if (singerRole == KTVSingRole.LeadSinger || singerRole == KTVSingRole.CoSinger) { + subChorusConnection?.let { + mRtcEngine.setParametersEx("{\"rtc.enableMultipath\": $enable, \"rtc.path_scheduling_strategy\": 0, \"rtc.remote_path_scheduling_strategy\": 0}", it) + } + } + } + + override fun switchAudioTrack(mode: AudioTrackMode) { + apiReporter.reportFuncEvent("switchAudioTrack", mapOf("mode" to mode), mapOf()) + when (singerRole) { + KTVSingRole.LeadSinger, KTVSingRole.SoloSinger -> { + when (mode) { + AudioTrackMode.YUAN_CHANG -> mPlayer.selectMultiAudioTrack(0, 0) + AudioTrackMode.BAN_ZOU -> mPlayer.selectMultiAudioTrack(1, 1) + AudioTrackMode.DAO_CHANG -> mPlayer.selectMultiAudioTrack(0, 1) + } + } + KTVSingRole.CoSinger -> { + when (mode) { + AudioTrackMode.YUAN_CHANG -> mPlayer.selectAudioTrack(0) + AudioTrackMode.BAN_ZOU -> mPlayer.selectAudioTrack(1) + AudioTrackMode.DAO_CHANG -> ktvApiLogError("CoSinger can not switch to DAO_CHANG") + } + } + KTVSingRole.Audience -> ktvApiLogError("CoSinger can not switch audio track") } } override fun renewToken(rtmToken: String, chorusChannelRtcToken: String) { - reportCallScenarioApi("renewToken", JSONObject().put("rtmToken", rtmToken).put("chorusChannelRtcToken", chorusChannelRtcToken)) + apiReporter.reportFuncEvent("renewToken", mapOf(), mapOf()) // 更新RtmToken mMusicCenter.renewToken(rtmToken) // 更新合唱频道RtcToken @@ -319,16 +403,18 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver newRole: KTVSingRole, switchRoleStateListener: ISwitchRoleStateListener? ) { - reportCallScenarioApi("switchSingerRole", JSONObject().put("newRole", newRole)) + apiReporter.reportFuncEvent("switchSingerRole", mapOf("newRole" to newRole), mapOf()) val oldRole = singerRole // 调整开关麦状态 - if ((oldRole == KTVSingRole.LeadSinger || oldRole == KTVSingRole.SoloSinger) && (newRole == KTVSingRole.CoSinger || newRole == KTVSingRole.Audience) && !isOnMicOpen) { - mRtcEngine.muteLocalAudioStream(true) - mRtcEngine.adjustRecordingSignalVolume(100) - } else if ((oldRole == KTVSingRole.Audience || oldRole == KTVSingRole.CoSinger) && (newRole == KTVSingRole.LeadSinger || newRole == KTVSingRole.SoloSinger) && !isOnMicOpen) { - mRtcEngine.adjustRecordingSignalVolume(0) - mRtcEngine.muteLocalAudioStream(false) + if (ktvApiConfig.type != KTVType.SingRelay) { + if ((oldRole == KTVSingRole.LeadSinger || oldRole == KTVSingRole.SoloSinger) && (newRole == KTVSingRole.CoSinger || newRole == KTVSingRole.Audience) && !isOnMicOpen) { + mRtcEngine.muteLocalAudioStream(true) + mRtcEngine.adjustRecordingSignalVolume(100) + } else if ((oldRole == KTVSingRole.Audience || oldRole == KTVSingRole.CoSinger) && (newRole == KTVSingRole.LeadSinger || newRole == KTVSingRole.SoloSinger) && !isOnMicOpen) { + mRtcEngine.adjustRecordingSignalVolume(0) + mRtcEngine.muteLocalAudioStream(false) + } } if (this.singerRole == KTVSingRole.Audience && newRole == KTVSingRole.SoloSinger) { @@ -342,16 +428,20 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver becomeSoloSinger() joinChorus(newRole, ktvApiConfig.chorusChannelToken, object : OnJoinChorusStateListener { override fun onJoinChorusSuccess() { - ktvApiLog("onJoinChorusSuccess") - singerRole = newRole - ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) } - switchRoleStateListener?.onSwitchRoleSuccess() + runOnMainThread { + ktvApiLog("onJoinChorusSuccess") + singerRole = newRole + ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) } + switchRoleStateListener?.onSwitchRoleSuccess() + } } override fun onJoinChorusFail(reason: KTVJoinChorusFailReason) { - ktvApiLog("onJoinChorusFail reason:$reason") - leaveChorus(newRole) - switchRoleStateListener?.onSwitchRoleFail(SwitchRoleFailReason.JOIN_CHANNEL_FAIL) + runOnMainThread { + ktvApiLog("onJoinChorusFail reason:$reason") + leaveChorus(newRole) + switchRoleStateListener?.onSwitchRoleFail(SwitchRoleFailReason.JOIN_CHANNEL_FAIL) + } } }) } else if (this.singerRole == KTVSingRole.SoloSinger && newRole == KTVSingRole.Audience) { @@ -366,16 +456,20 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver // 4、Audience -》CoSinger joinChorus(newRole, ktvApiConfig.chorusChannelToken, object : OnJoinChorusStateListener { override fun onJoinChorusSuccess() { - ktvApiLog("onJoinChorusSuccess") - singerRole = newRole - switchRoleStateListener?.onSwitchRoleSuccess() - ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) } + runOnMainThread { + ktvApiLog("onJoinChorusSuccess") + singerRole = newRole + switchRoleStateListener?.onSwitchRoleSuccess() + ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) } + } } override fun onJoinChorusFail(reason: KTVJoinChorusFailReason) { - ktvApiLog("onJoinChorusFail reason:$reason") - leaveChorus(newRole) - switchRoleStateListener?.onSwitchRoleFail(SwitchRoleFailReason.JOIN_CHANNEL_FAIL) + runOnMainThread { + ktvApiLog("onJoinChorusFail reason:$reason") + leaveChorus(newRole) + switchRoleStateListener?.onSwitchRoleFail(SwitchRoleFailReason.JOIN_CHANNEL_FAIL) + } } }) @@ -392,16 +486,20 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver joinChorus(newRole, ktvApiConfig.chorusChannelToken, object : OnJoinChorusStateListener { override fun onJoinChorusSuccess() { - ktvApiLog("onJoinChorusSuccess") - singerRole = newRole - switchRoleStateListener?.onSwitchRoleSuccess() - ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) } + runOnMainThread { + ktvApiLog("onJoinChorusSuccess") + singerRole = newRole + switchRoleStateListener?.onSwitchRoleSuccess() + ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) } + } } override fun onJoinChorusFail(reason: KTVJoinChorusFailReason) { - ktvApiLog("onJoinChorusFail reason:$reason") - leaveChorus(newRole) - switchRoleStateListener?.onSwitchRoleFail(SwitchRoleFailReason.JOIN_CHANNEL_FAIL) + runOnMainThread { + ktvApiLog("onJoinChorusFail reason:$reason") + leaveChorus(newRole) + switchRoleStateListener?.onSwitchRoleFail(SwitchRoleFailReason.JOIN_CHANNEL_FAIL) + } } }) } else if (this.singerRole == KTVSingRole.LeadSinger && newRole == KTVSingRole.SoloSinger) { @@ -450,7 +548,7 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver } override fun fetchMusicCharts(onMusicChartResultListener: (requestId: String?, status: Int, list: Array?) -> Unit) { - reportCallScenarioApi("fetchMusicCharts", JSONObject()) + apiReporter.reportFuncEvent("fetchMusicCharts", mapOf(), mapOf()) val requestId = mMusicCenter.musicCharts musicChartsCallbackMap[requestId] = onMusicChartResultListener } @@ -462,7 +560,7 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver jsonOption: String, onMusicCollectionResultListener: (requestId: String?, status: Int, page: Int, pageSize: Int, total: Int, list: Array?) -> Unit ) { - reportCallScenarioApi("searchMusicByMusicChartId", JSONObject()) + apiReporter.reportFuncEvent("searchMusicByMusicChartId", mapOf("musicChartId" to musicChartId, "page" to page, "pageSize" to pageSize, "jsonOption" to jsonOption), mapOf()) val requestId = mMusicCenter.getMusicCollectionByMusicChartId(musicChartId, page, pageSize, jsonOption) musicCollectionCallbackMap[requestId] = onMusicCollectionResultListener @@ -475,7 +573,7 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver jsonOption: String, onMusicCollectionResultListener: (requestId: String?, status: Int, page: Int, pageSize: Int, total: Int, list: Array?) -> Unit ) { - reportCallScenarioApi("searchMusicByKeyword", JSONObject()) + apiReporter.reportFuncEvent("searchMusicByKeyword", mapOf(), mapOf()) val requestId = mMusicCenter.searchMusic(keyword, page, pageSize, jsonOption) musicCollectionCallbackMap[requestId] = onMusicCollectionResultListener } @@ -485,15 +583,14 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver config: KTVLoadMusicConfiguration, musicLoadStateListener: IMusicLoadStateListener ) { - reportCallScenarioApi("loadMusic", JSONObject().put("songCode", songCode).put("config", config)) + apiReporter.reportFuncEvent("loadMusic", mapOf("songCode" to songCode, "config" to config), mapOf()) ktvApiLog("loadMusic called: songCode $songCode") - if (this.ktvApiConfig.type == KTVType.SingBattle) { - mMusicCenter.getSongSimpleInfo(songCode) - } + // 设置到全局, 连续调用以最新的为准 this.songCode = songCode this.songIdentifier = config.songIdentifier this.mainSingerUid = config.mainSingerUid + this.needPrelude = config.needPrelude mLastReceivedPlayPosTime = null mReceivedPlayPosition = 0 @@ -507,19 +604,31 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver if (this.songCode != song) { // 当前歌曲已发生变化,以最新load歌曲为准 ktvApiLogError("loadMusic failed: CANCELED") - musicLoadStateListener.onMusicLoadFail(song, KTVLoadSongFailReason.CANCELED) + musicLoadStateListener.onMusicLoadFail(song, KTVLoadMusicFailReason.CANCELED) return@loadLyric } if (lyricUrl == null) { // 加载歌词失败 ktvApiLogError("loadMusic failed: NO_LYRIC_URL") - musicLoadStateListener.onMusicLoadFail(song, KTVLoadSongFailReason.NO_LYRIC_URL) + musicLoadStateListener.onMusicLoadFail(song, KTVLoadMusicFailReason.NO_LYRIC_URL) } else { // 加载歌词成功 ktvApiLog("loadMusic success") lrcView?.onDownloadLrcData(lyricUrl) - musicLoadStateListener.onMusicLoadSuccess(song, lyricUrl) + if (this.ktvApiConfig.type != KTVType.SingBattle) { + musicLoadStateListener.onMusicLoadSuccess(song, lyricUrl) + } else { + getSongSimpleInfo(songCode) { code, success -> + if (success) { + musicLoadStateListener.onMusicLoadSuccess(song, lyricUrl) + } else { + musicLoadStateListener.onMusicLoadFail(code, + KTVLoadMusicFailReason.GET_SIMPLE_INFO_FAIL + ) + } + } + } } } return @@ -532,7 +641,7 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver if (this.songCode != song) { // 当前歌曲已发生变化,以最新load歌曲为准 ktvApiLogError("loadMusic failed: CANCELED") - musicLoadStateListener.onMusicLoadFail(song, KTVLoadSongFailReason.CANCELED) + musicLoadStateListener.onMusicLoadFail(song, KTVLoadMusicFailReason.CANCELED) return@preLoadMusic } if (config.mode == KTVLoadMusicMode.LOAD_MUSIC_AND_LRC) { @@ -541,59 +650,68 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver if (this.songCode != song) { // 当前歌曲已发生变化,以最新load歌曲为准 ktvApiLogError("loadMusic failed: CANCELED") - musicLoadStateListener.onMusicLoadFail(song, KTVLoadSongFailReason.CANCELED) + musicLoadStateListener.onMusicLoadFail(song, KTVLoadMusicFailReason.CANCELED) return@loadLyric } if (lyricUrl == null) { // 加载歌词失败 ktvApiLogError("loadMusic failed: NO_LYRIC_URL") - musicLoadStateListener.onMusicLoadFail(song, KTVLoadSongFailReason.NO_LYRIC_URL) + musicLoadStateListener.onMusicLoadFail(song, KTVLoadMusicFailReason.NO_LYRIC_URL) } else { // 加载歌词成功 ktvApiLog("loadMusic success") lrcView?.onDownloadLrcData(lyricUrl) musicLoadStateListener.onMusicLoadProgress(song, 100, MusicLoadStatus.COMPLETED, msg, lrcUrl) - musicLoadStateListener.onMusicLoadSuccess(song, lyricUrl) - } - - if (config.autoPlay) { - // 主唱自动播放歌曲 - if (this.singerRole != KTVSingRole.LeadSinger) { - switchSingerRole(KTVSingRole.SoloSinger, null) + if (this.ktvApiConfig.type != KTVType.SingBattle) { + musicLoadStateListener.onMusicLoadSuccess(song, lyricUrl) + } else { + getSongSimpleInfo(songCode) { code, success -> + if (success) { + musicLoadStateListener.onMusicLoadSuccess(song, lyricUrl) + } else { + musicLoadStateListener.onMusicLoadFail(code, + KTVLoadMusicFailReason.GET_SIMPLE_INFO_FAIL + ) + } + } } - startSing(song, 0) } } } else if (config.mode == KTVLoadMusicMode.LOAD_MUSIC_ONLY) { // 不需要加载歌词 ktvApiLog("loadMusic success") - if (config.autoPlay) { - // 主唱自动播放歌曲 - if (this.singerRole != KTVSingRole.LeadSinger) { - switchSingerRole(KTVSingRole.SoloSinger, null) + musicLoadStateListener.onMusicLoadProgress(song, 100, MusicLoadStatus.COMPLETED, msg, lrcUrl) + if (this.ktvApiConfig.type != KTVType.SingBattle) { + musicLoadStateListener.onMusicLoadSuccess(song, "") + } else { + getSongSimpleInfo(songCode) { code, success -> + if (success) { + musicLoadStateListener.onMusicLoadSuccess(song, "") + } else { + musicLoadStateListener.onMusicLoadFail(code, + KTVLoadMusicFailReason.GET_SIMPLE_INFO_FAIL + ) + } } - startSing(song, 0) } - musicLoadStateListener.onMusicLoadProgress(song, 100, MusicLoadStatus.COMPLETED, msg, lrcUrl) - musicLoadStateListener.onMusicLoadSuccess(song, "") } } else if (status == 2) { // 预加载歌曲加载中 musicLoadStateListener.onMusicLoadProgress(song, percent, MusicLoadStatus.values().firstOrNull { it.value == status } ?: MusicLoadStatus.FAILED, msg, lrcUrl) } else if (status == 3) { // 主动停止下载 - musicLoadStateListener.onMusicLoadFail(song, KTVLoadSongFailReason.CANCELED) + musicLoadStateListener.onMusicLoadFail(song, KTVLoadMusicFailReason.CANCELED) } else { // 预加载歌曲失败 ktvApiLogError("loadMusic failed: MUSIC_PRELOAD_FAIL") - musicLoadStateListener.onMusicLoadFail(song, KTVLoadSongFailReason.MUSIC_PRELOAD_FAIL) + musicLoadStateListener.onMusicLoadFail(song, KTVLoadMusicFailReason.MUSIC_PRELOAD_FAIL) } } } override fun removeMusic(songCode: Long) { - reportCallScenarioApi("removeMusic", JSONObject().put("songCode", songCode)) + apiReporter.reportFuncEvent("removeMusic", mapOf("songCode" to songCode), mapOf()) val ret = mMusicCenter.removeCache(songCode) if (ret < 0) { ktvApiLogError("removeMusic failed, ret: $ret") @@ -604,38 +722,24 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver url: String, config: KTVLoadMusicConfiguration ) { - reportCallScenarioApi("loadMusic", JSONObject().put("url", url).put("config", config)) + apiReporter.reportFuncEvent("loadMusic", mapOf("url" to url, "config" to config), mapOf()) this.songIdentifier = config.songIdentifier this.songUrl = url this.mainSingerUid = config.mainSingerUid - - if (config.autoPlay) { - // 主唱自动播放歌曲 - if (this.singerRole != KTVSingRole.LeadSinger) { - switchSingerRole(KTVSingRole.SoloSinger, null) - } - startSing(url, 0) - } + this.needPrelude = config.needPrelude } override fun load2Music(url1: String, url2: String, config: KTVLoadMusicConfiguration) { - reportCallScenarioApi("load2Music", JSONObject().put("url1", url1).put("url2", url2).put("config", config)) + apiReporter.reportFuncEvent("load2Music", mapOf("url1" to url1, "url2" to url2, "config" to config), mapOf()) this.songIdentifier = config.songIdentifier this.songUrl = url1 this.songUrl2 = url2 this.mainSingerUid = config.mainSingerUid - - if (config.autoPlay) { - // 主唱自动播放歌曲 - if (this.singerRole != KTVSingRole.LeadSinger) { - switchSingerRole(KTVSingRole.SoloSinger, null) - } - startSing(url1, 0) - } + this.needPrelude = config.needPrelude } override fun switchPlaySrc(url: String, syncPts: Boolean) { - reportCallScenarioApi("switchPlaySrc", JSONObject().put("url", url).put("syncPts", syncPts)) + apiReporter.reportFuncEvent("switchPlaySrc", mapOf("url" to url, "syncPts" to syncPts), mapOf()) if (this.songUrl != url && this.songUrl2 != url) { ktvApiLogError("switchPlaySrc failed: canceled") return @@ -646,69 +750,96 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver } override fun startSing(songCode: Long, startPos: Long) { - reportCallScenarioApi("startSing", JSONObject().put("songCode", songCode).put("startPos", startPos)) + apiReporter.reportFuncEvent("startSing", mapOf("songCode" to songCode, "startPos" to startPos), mapOf()) ktvApiLog("playSong called: $singerRole") + if (singerRole != KTVSingRole.SoloSinger && singerRole != KTVSingRole.LeadSinger) { + ktvApiLogError("startSing failed: error singerRole") + return + } + if (this.songCode != songCode) { ktvApiLogError("startSing failed: canceled") return } - mRtcEngine.adjustPlaybackSignalVolume(remoteVolume) + mRtcEngine.adjustPlaybackSignalVolume(KTVApi.remoteVolume) // 导唱 mPlayer.setPlayerOption("enable_multi_audio_track", 1) - (mPlayer as IAgoraMusicPlayer).open(songCode, startPos) + val ret = (mPlayer as IAgoraMusicPlayer).open(songCode, startPos) + if (ret != 0) { + ktvApiLogError("mpk open failed: $ret") + } } override fun startSing(url: String, startPos: Long) { - reportCallScenarioApi("startSing", JSONObject().put("url", url).put("startPos", startPos)) + apiReporter.reportFuncEvent("startSing", mapOf("url" to url, "startPos" to startPos), mapOf()) + if (singerRole != KTVSingRole.SoloSinger && singerRole != KTVSingRole.LeadSinger) { + ktvApiLogError("startSing failed: error singerRole") + return + } + if (this.songUrl != url && this.songUrl2 != url) { ktvApiLogError("startSing failed: canceled") return } - mRtcEngine.adjustPlaybackSignalVolume(remoteVolume) + mRtcEngine.adjustPlaybackSignalVolume(KTVApi.remoteVolume) // 导唱 mPlayer.setPlayerOption("enable_multi_audio_track", 1) - mPlayer.open(url, startPos) + val ret = mPlayer.open(url, startPos) + if (ret != 0) { + ktvApiLogError("mpk open failed: $ret") + } } override fun resumeSing() { - reportCallScenarioApi("resumeSing", JSONObject()) + apiReporter.reportFuncEvent("resumeSing", mapOf(), mapOf()) mPlayer.resume() } override fun pauseSing() { - reportCallScenarioApi("pauseSing", JSONObject()) + apiReporter.reportFuncEvent("pauseSing", mapOf(), mapOf()) mPlayer.pause() } override fun seekSing(time: Long) { - reportCallScenarioApi("seekSing", JSONObject().put("time", time)) + apiReporter.reportFuncEvent("seekSing", mapOf("time" to time), mapOf()) mPlayer.seek(time) syncPlayProgress(time) } override fun setLrcView(view: ILrcView) { - reportCallScenarioApi("setLrcView", JSONObject()) + apiReporter.reportFuncEvent("setLrcView", mapOf("view" to view), mapOf()) this.lrcView = view } override fun muteMic(mute: Boolean) { - reportCallScenarioApi("muteMic", JSONObject().put("mute", isOnMicOpen)) + apiReporter.reportFuncEvent("muteMic", mapOf("mute" to mute), mapOf()) this.isOnMicOpen = !mute - if (this.singerRole == KTVSingRole.SoloSinger || this.singerRole == KTVSingRole.LeadSinger) { - mRtcEngine.adjustRecordingSignalVolume(if (isOnMicOpen) 100 else 0) + if (ktvApiConfig.type != KTVType.SingRelay) { + if (this.singerRole == KTVSingRole.SoloSinger || this.singerRole == KTVSingRole.LeadSinger) { + mRtcEngine.adjustRecordingSignalVolume(if (isOnMicOpen) 100 else 0) + if (isOnMicOpen) { + val channelMediaOption = ChannelMediaOptions() + channelMediaOption.publishMicrophoneTrack = true + channelMediaOption.clientRoleType = CLIENT_ROLE_BROADCASTER + mRtcEngine.updateChannelMediaOptions(channelMediaOption) + mRtcEngine.muteLocalAudioStream(!isOnMicOpen) + } + } else { + val channelMediaOption = ChannelMediaOptions() + channelMediaOption.publishMicrophoneTrack = isOnMicOpen + channelMediaOption.clientRoleType = CLIENT_ROLE_BROADCASTER + mRtcEngine.updateChannelMediaOptions(channelMediaOption) + mRtcEngine.muteLocalAudioStream(!isOnMicOpen) + } } else { - val channelMediaOption = ChannelMediaOptions() - channelMediaOption.publishMicrophoneTrack = isOnMicOpen - channelMediaOption.clientRoleType = CLIENT_ROLE_BROADCASTER - mRtcEngine.updateChannelMediaOptions(channelMediaOption) - mRtcEngine.muteLocalAudioStream(!isOnMicOpen) + mRtcEngine.adjustRecordingSignalVolume(if (isOnMicOpen) 100 else 0) } } override fun setAudioPlayoutDelay(audioPlayoutDelay: Int) { - reportCallScenarioApi("setAudioPlayoutDelay", JSONObject().put("audioPlayoutDelay", audioPlayoutDelay)) + apiReporter.reportFuncEvent("setAudioPlayoutDelay", mapOf("audioPlayoutDelay" to audioPlayoutDelay), mapOf()) this.audioPlayoutDelay = audioPlayoutDelay } @@ -757,10 +888,16 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver // 预加载歌曲成功 if (ktvApiConfig.musicType == KTVMusicType.SONG_CODE) { mPlayer.setPlayerOption("enable_multi_audio_track", 0) - (mPlayer as IAgoraMusicPlayer).open(songCode, 0) // TODO open failed + val ret = (mPlayer as IAgoraMusicPlayer).open(songCode, 0) // TODO open failed + if (ret != 0) { + ktvApiLogError("mpk open failed: $ret") + } } else { mPlayer.setPlayerOption("enable_multi_audio_track", 0) - mPlayer.open(songUrl, 0) // TODO open failed + val ret = mPlayer.open(songUrl, 0) // TODO open failed + if (ret != 0) { + ktvApiLogError("mpk open failed: $ret") + } } // 预加载成功后加入第二频道:预加载时间>>joinChannel时间 @@ -840,12 +977,12 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver private fun syncPlayState( state: MediaPlayerState, - error: Constants.MediaPlayerError + reason: Constants.MediaPlayerReason ) { val msg: MutableMap = HashMap() msg["cmd"] = "PlayerState" msg["state"] = MediaPlayerState.getValue(state) - msg["error"] = Constants.MediaPlayerError.getValue(error) + msg["error"] = Constants.MediaPlayerReason.getValue(reason) val jsonMsg = JSONObject(msg) sendStreamMessageWithJsonObject(jsonMsg) {} } @@ -859,6 +996,7 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver } // 合唱 + private var handlerEx :IRtcEngineEventHandler? = null private fun joinChorus2ndChannel( newRole: KTVSingRole, token: String, @@ -898,53 +1036,61 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver token, rtcConnection, channelMediaOption, - object : IRtcEngineEventHandler() { - override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) { - ktvApiLog("onJoinChannel2Success: channel:$channel, uid:$uid") - if (isRelease) return - super.onJoinChannelSuccess(channel, uid, elapsed) - if (newRole == KTVSingRole.LeadSinger) { - mainSingerHasJoinChannelEx = true - } - onJoinChorus2ndChannelCallback(0) - mRtcEngine.enableAudioVolumeIndicationEx(50, 10, true, rtcConnection) - } - - override fun onLeaveChannel(stats: RtcStats?) { - ktvApiLog("onLeaveChannel2") - if (isRelease) return - super.onLeaveChannel(stats) - if (newRole == KTVSingRole.LeadSinger) { - mainSingerHasJoinChannelEx = false - } + null + ) + val handler = object : IRtcEngineEventHandler() { + override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) { + if (isRelease) return + ktvApiLog("onJoinChannel2Success: channel:$channel, uid:$uid") + super.onJoinChannelSuccess(channel, uid, elapsed) + if (newRole == KTVSingRole.LeadSinger) { + mainSingerHasJoinChannelEx = true } + onJoinChorus2ndChannelCallback(0) + mRtcEngine.enableAudioVolumeIndicationEx(50, 10, true, rtcConnection) + } - override fun onError(err: Int) { - super.onError(err) - if (isRelease) return - if (err == ERR_JOIN_CHANNEL_REJECTED) { - ktvApiLogError("joinChorus2ndChannel failed: ERR_JOIN_CHANNEL_REJECTED") - onJoinChorus2ndChannelCallback(ERR_JOIN_CHANNEL_REJECTED) - } else if (err == ERR_LEAVE_CHANNEL_REJECTED) { - ktvApiLogError("leaveChorus2ndChannel failed: ERR_LEAVE_CHANNEL_REJECTED") - } + override fun onLeaveChannel(stats: RtcStats?) { + if (isRelease) return + //ktvApiLog("onLeaveChannel2") + super.onLeaveChannel(stats) + if (newRole == KTVSingRole.LeadSinger) { + mainSingerHasJoinChannelEx = false } + } - override fun onTokenPrivilegeWillExpire(token: String?) { - super.onTokenPrivilegeWillExpire(token) - ktvApiEventHandlerList.forEach { it.onTokenPrivilegeWillExpire() } + override fun onError(err: Int) { + super.onError(err) + if (isRelease) return + if (err == ERR_JOIN_CHANNEL_REJECTED) { + ktvApiLogError("joinChorus2ndChannel failed: ERR_JOIN_CHANNEL_REJECTED") + onJoinChorus2ndChannelCallback(ERR_JOIN_CHANNEL_REJECTED) + } else if (err == ERR_LEAVE_CHANNEL_REJECTED) { + ktvApiLogError("leaveChorus2ndChannel failed: ERR_LEAVE_CHANNEL_REJECTED") } + } - override fun onAudioVolumeIndication( - speakers: Array?, - totalVolume: Int - ) { - super.onAudioVolumeIndication(speakers, totalVolume) - ktvApiEventHandlerList.forEach { it.onChorusChannelAudioVolumeIndication(speakers, totalVolume) } - } + override fun onTokenPrivilegeWillExpire(token: String?) { + super.onTokenPrivilegeWillExpire(token) + ktvApiEventHandlerList.forEach { it.onTokenPrivilegeWillExpire() } } - ) + override fun onAudioVolumeIndication( + speakers: Array?, + totalVolume: Int + ) { + super.onAudioVolumeIndication(speakers, totalVolume) + ktvApiEventHandlerList.forEach { it.onChorusChannelAudioVolumeIndication(speakers, totalVolume) } + } + } + handlerEx = handler + mRtcEngine.addHandlerEx(handler, rtcConnection) + if (enableMultipathing) { + mRtcEngine.setParametersEx( + "{\"rtc.path_scheduling_strategy\":0, \"rtc.enableMultipath\": true, \"rtc.remote_path_scheduling_strategy\": 0}", + rtcConnection + ) + } if (ret != 0) { ktvApiLogError("joinChorus2ndChannel failed: $ret") } @@ -956,6 +1102,7 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver } private fun leaveChorus2ndChannel(role: KTVSingRole) { + mRtcEngine.removeHandlerEx(handlerEx, subChorusConnection) if (role == KTVSingRole.LeadSinger) { mRtcEngine.leaveChannelEx(subChorusConnection) } else if (role == KTVSingRole.CoSinger) { @@ -974,29 +1121,6 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver } // ------------------ 歌词播放、同步 ------------------ - // 开始播放歌词 - private val displayLrcTask = object : Runnable { - override fun run() { - if (!mStopDisplayLrc){ - val lastReceivedTime = mLastReceivedPlayPosTime ?: return - val curTime = System.currentTimeMillis() - val offset = curTime - lastReceivedTime - if (offset <= 1000) { - val curTs = mReceivedPlayPosition + offset + highStartTime - runOnMainThread { - lrcView?.onUpdatePitch(pitch.toFloat()) - // (fix ENT-489)Make lyrics delay for 200ms - // Per suggestion from Bob, it has a intrinsic buffer/delay between sound and `onPositionChanged(Player)`, - // such as AEC/Player/Device buffer. - // We choose the estimated 200ms. - lrcView?.onUpdateProgress(if (curTs > 200) (curTs - 200) else curTs) // The delay here will impact both singer and audience side - } - } - } - } - } - - private var displayLrcFuture: ScheduledFuture<*>? = null private fun startDisplayLrc() { ktvApiLog("startDisplayLrc called") mStopDisplayLrc = false @@ -1015,22 +1139,6 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver } // ------------------ 音高pitch同步 ------------------ -// private var mSyncPitchThread: Thread? = null - private var mStopSyncPitch = true - - private val mSyncPitchTask = Runnable { - if (!mStopSyncPitch) { - if (ktvApiConfig.type == KTVType.SingRelay && - (singerRole == KTVSingRole.LeadSinger || singerRole == KTVSingRole.SoloSinger || singerRole == KTVSingRole.CoSinger) && - isOnMicOpen) { - sendSyncPitch(pitch) - } else if (mediaPlayerState == MediaPlayerState.PLAYER_STATE_PLAYING && - (singerRole == KTVSingRole.LeadSinger || singerRole == KTVSingRole.SoloSinger)) { - sendSyncPitch(pitch) - } - } - } - private fun sendSyncPitch(pitch: Double) { val msg: MutableMap = java.util.HashMap() msg["cmd"] = "setVoicePitch" @@ -1040,7 +1148,6 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver } // 开始同步音高 - private var mSyncPitchFuture :ScheduledFuture<*>? = null private fun startSyncPitch() { mStopSyncPitch = false mSyncPitchFuture = scheduledThreadPool.scheduleAtFixedRate(mSyncPitchTask,0,50,TimeUnit.MILLISECONDS) @@ -1092,6 +1199,16 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver loadMusicCallbackMap[songNo.toString()] = onLoadMusicCallback } + private fun getSongSimpleInfo(songNo: Long, onSongSimpleInfoResult: (songCode: Long, success: Boolean) -> Unit) { + ktvApiLog("getSongSimpleInfo: $songNo") + val requestId = mMusicCenter.getSongSimpleInfo(songNo) + if (requestId == null || requestId.isEmpty()) { + onSongSimpleInfoResult.invoke(songNo, false) + return + } + simpleInfoCallbackMap[requestId] = onSongSimpleInfoResult + } + private fun getNtpTimeInMs(): Long { val currentNtpTime = mRtcEngine.ntpWallTimeInMs return if (currentNtpTime != 0L) { @@ -1114,6 +1231,34 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver override fun onStreamMessage(uid: Int, streamId: Int, data: ByteArray?) { super.onStreamMessage(uid, streamId, data) if (uid != mainSingerUid) return + dealWithStreamMessage(data) + } + + override fun onAudioMetadataReceived(uid: Int, data: ByteArray?) { + super.onAudioMetadataReceived(uid, data) + val messageData = data ?: return + try { + + val lrcTime = LrcTimeOuterClass.LrcTime.parseFrom(messageData) + if (lrcTime.type == LrcTimeOuterClass.MsgType.LRC_TIME) { //同步歌词 + val realPosition = lrcTime.ts + val songId = lrcTime.songId + val curTs = if (this.songIdentifier == songId) realPosition else 0 + runOnMainThread { + lrcView?.onUpdatePitch(pitch.toFloat()) + // (fix ENT-489)Make lyrics delay for 200ms + // Per suggestion from Bob, it has a intrinsic buffer/delay between sound and `onPositionChanged(Player)`, + // such as AEC/Player/Device buffer. + // We choose the estimated 200ms. + lrcView?.onUpdateProgress(if (curTs > 200) (curTs - 200) else curTs) // The delay here will impact both singer and audience side + } + } + } catch (exp: JSONException) { + ktvApiLog("onStreamMessage:$exp") + } + } + + private fun dealWithStreamMessage(data: ByteArray?) { val jsonMsg: JSONObject val messageData = data ?: return try { @@ -1131,7 +1276,7 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver // 本地BGM校准逻辑 if (this.mediaPlayerState == MediaPlayerState.PLAYER_STATE_OPEN_COMPLETED) { // 合唱者开始播放音乐前调小远端人声 - mRtcEngine.adjustPlaybackSignalVolume(remoteVolume) + mRtcEngine.adjustPlaybackSignalVolume(KTVApi.remoteVolume) // 收到leadSinger第一次播放位置消息时开启本地播放(先通过seek校准) val delta = getNtpTimeInMs() - remoteNtp val expectPosition = position + delta + audioPlayoutDelay @@ -1146,9 +1291,11 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver val expectPosition = localNtpTime - remoteNtp + position + audioPlayoutDelay // 实际主唱的播放时间 val diff = expectPosition - localPosition - if (debugMode) { - ktvApiLog("play_status_seek: " + diff + " audioPlayoutDelay:" + audioPlayoutDelay + " localNtpTime: " + localNtpTime + " expectPosition: " + expectPosition + - " localPosition: " + localPosition + " ntp diff: " + (localNtpTime - remoteNtp)) + if (KTVApi.debugMode) { + ktvApiLog( + "play_status_seek: " + diff + " audioPlayoutDelay:" + audioPlayoutDelay + " localNtpTime: " + localNtpTime + " expectPosition: " + expectPosition + + " localPosition: " + localPosition + " ntp diff: " + (localNtpTime - remoteNtp) + ) } if ((diff > 50 || diff < -50) && expectPosition < duration) { //设置阈值为50ms,避免频繁seek ktvApiLog("player seek: $diff") @@ -1164,21 +1311,30 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver MediaPlayerState.PLAYER_STATE_PAUSED -> { mPlayer.pause() } + MediaPlayerState.PLAYER_STATE_PLAYING -> { mPlayer.resume() } + else -> {} } } } else { // 独唱观众 - if (this.songIdentifier == songId) { - mLastReceivedPlayPosTime = System.currentTimeMillis() - mReceivedPlayPosition = realPosition - ktvApiEventHandlerList.forEach { it.onMusicPlayerPositionChanged(realPosition, 0) } + if (jsonMsg.has("ver")) { + // 发送端是新发送端, 歌词信息需要从 audioMetadata 里取 + recvFromDataStream = false } else { - mLastReceivedPlayPosTime = null - mReceivedPlayPosition = 0 + // 发送端是老发送端, 歌词信息需要从 dataStreamMessage 里取 + recvFromDataStream = true + if (this.songIdentifier == songId) { + mLastReceivedPlayPosTime = System.currentTimeMillis() + mReceivedPlayPosition = realPosition + ktvApiEventHandlerList.forEach { it.onMusicPlayerPositionChanged(realPosition, 0) } + } else { + mLastReceivedPlayPosTime = null + mReceivedPlayPosition = 0 + } } } } else if (jsonMsg.getString("cmd") == "Seek") { @@ -1196,19 +1352,23 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver MediaPlayerState.PLAYER_STATE_PAUSED -> { mPlayer.pause() } + MediaPlayerState.PLAYER_STATE_PLAYING -> { mPlayer.resume() } + else -> {} } } else if (this.singerRole == KTVSingRole.Audience) { this.mediaPlayerState = MediaPlayerState.getStateByValue(state) } - ktvApiEventHandlerList.forEach { it.onMusicPlayerStateChanged( - MediaPlayerState.getStateByValue(state), - Constants.MediaPlayerError.getErrorByValue(error), - false - ) } + ktvApiEventHandlerList.forEach { + it.onMusicPlayerStateChanged( + MediaPlayerState.getStateByValue(state), + Constants.MediaPlayerReason.getErrorByValue(error), + false + ) + } } else if (jsonMsg.getString("cmd") == "setVoicePitch") { val pitch = jsonMsg.getDouble("pitch") if (ktvApiConfig.type == KTVType.SingRelay && !isOnMicOpen && this.singerRole != KTVSingRole.Audience) { @@ -1224,7 +1384,8 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver mRtcEngine.muteRemoteAudioStream(mainSingerUid, true) } } - } catch (_: JSONException) { } + } catch (_: JSONException) { + } } override fun onAudioVolumeIndication(speakers: Array?, totalVolume: Int) { @@ -1251,7 +1412,7 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver // 用于合唱校准 override fun onLocalAudioStats(stats: LocalAudioStats?) { super.onLocalAudioStats(stats) - if (useCustomAudioSource) return + if (KTVApi.useCustomAudioSource) return val audioState = stats ?: return audioPlayoutDelay = audioState.audioPlayoutDelay } @@ -1356,24 +1517,40 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver errorCode: Int ) { if (this.ktvApiConfig.type == KTVType.Normal) return - val jsonMsg = JSONObject(simpleInfo) - val format = jsonMsg.getJSONObject("format") - val highPart = format.getJSONArray("highPart") - val highStartTime = JSONObject(highPart[0].toString()) - val time = highStartTime.getLong("highStartTime") - val endTime = highStartTime.getLong("highEndTime") - this.highStartTime = time - lrcView?.onHighPartTime(time, endTime) + val callback = simpleInfoCallbackMap[requestId] ?: return + if (errorCode != 0) { + ktvApiLogError("onSongSimpleInfoResult failed, requestId: $requestId, songCode: $songCode, errorCode: $errorCode") + callback.invoke(songCode, false) + return + } + try { + val jsonMsg = JSONObject(simpleInfo) + val format = jsonMsg.getJSONObject("format") + val highPart = format.getJSONArray("highPart") + val highStartTime = JSONObject(highPart[0].toString()) + val time = highStartTime.getLong("highStartTime") + val endTime = highStartTime.getLong("highEndTime") + val preludeDuration = highStartTime.getLong("preludeDuration") + this.highStartTime = time + if (needPrelude) { + this.highStartTime -= preludeDuration + } + lrcView?.onHighPartTime(time, endTime) + callback.invoke(songCode, true) + } catch (e: JSONException) { + ktvApiLogError("onSongSimpleInfoResult: ${e.message}") + callback.invoke(songCode, false) + } } // ------------------------ AgoraRtcMediaPlayerDelegate ------------------------ private var duration: Long = 0 override fun onPlayerStateChanged( state: MediaPlayerState?, - error: Constants.MediaPlayerError? + reason: Constants.MediaPlayerReason? ) { val mediaPlayerState = state ?: return - val mediaPlayerError = error ?: return + val mediaPlayerError = reason ?: return ktvApiLog("onPlayerStateChanged: $state") this.mediaPlayerState = mediaPlayerState when (mediaPlayerState) { @@ -1391,7 +1568,7 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver } } MediaPlayerState.PLAYER_STATE_PLAYING -> { - mRtcEngine.adjustPlaybackSignalVolume(remoteVolume) + mRtcEngine.adjustPlaybackSignalVolume(KTVApi.remoteVolume) } MediaPlayerState.PLAYER_STATE_PAUSED -> { mRtcEngine.adjustPlaybackSignalVolume(100) @@ -1425,6 +1602,7 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver msg["playerState"] = MediaPlayerState.getValue(this.mediaPlayerState) msg["pitch"] = pitch msg["songIdentifier"] = songIdentifier + msg["ver"] = lyricSyncVersion val jsonMsg = JSONObject(msg) sendStreamMessageWithJsonObject(jsonMsg) {} } @@ -1459,5 +1637,9 @@ class KTVApiImpl : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver override fun onPlayerInfoUpdated(info: PlayerUpdatedInfo?) {} + override fun onPlayerCacheStats(stats: CacheStatistics?) {} + + override fun onPlayerPlaybackStats(stats: PlayerPlaybackStats?) {} + override fun onAudioVolumeIndication(volume: Int) {} } \ No newline at end of file diff --git a/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/KTVGiantChorusApiImpl.kt b/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/KTVGiantChorusApiImpl.kt new file mode 100644 index 0000000..61bc711 --- /dev/null +++ b/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/KTVGiantChorusApiImpl.kt @@ -0,0 +1,1569 @@ +package io.agora.ktvapi + +import android.os.Handler +import android.os.Looper +import io.agora.mediaplayer.Constants +import io.agora.mediaplayer.Constants.MediaPlayerState +import io.agora.mediaplayer.IMediaPlayer +import io.agora.mediaplayer.IMediaPlayerObserver +import io.agora.mediaplayer.data.CacheStatistics +import io.agora.mediaplayer.data.PlayerPlaybackStats +import io.agora.mediaplayer.data.PlayerUpdatedInfo +import io.agora.mediaplayer.data.SrcInfo +import io.agora.musiccontentcenter.* +import io.agora.rtc2.* +import io.agora.rtc2.Constants.* +import org.json.JSONException +import org.json.JSONObject +import java.util.concurrent.* + +class KTVGiantChorusApiImpl( + val giantChorusApiConfig: KTVGiantChorusApiConfig +) : KTVApi, IMusicContentCenterEventHandler, IMediaPlayerObserver, IRtcEngineEventHandler() { + + companion object { + private val scheduledThreadPool: ScheduledExecutorService = Executors.newScheduledThreadPool(5) + private const val tag = "KTV_API_LOG_GIANT" + private const val version = "5.0.0" + private const val lyricSyncVersion = 2 + } + + private val mainHandler by lazy { Handler(Looper.getMainLooper()) } + private var mRtcEngine: RtcEngineEx = giantChorusApiConfig.engine as RtcEngineEx + private lateinit var mMusicCenter: IAgoraMusicContentCenter + private var mPlayer: IMediaPlayer + private val apiReporter: APIReporter = APIReporter(APIType.KTV, version, mRtcEngine) + + private var innerDataStreamId: Int = 0 + private var singChannelRtcConnection: RtcConnection? = null + private var subChorusConnection: RtcConnection? = null + private var mpkConnection: RtcConnection? = null + + private var mainSingerUid: Int = 0 + private var songCode: Long = 0 + private var songUrl: String = "" + private var songUrl2: String = "" + private var songIdentifier: String = "" + + private val lyricCallbackMap = + mutableMapOf Unit>() // (requestId, callback) + private val lyricSongCodeMap = mutableMapOf() // (requestId, songCode) + private val loadMusicCallbackMap = + mutableMapOf Unit>() // (songNo, callback) + private val musicChartsCallbackMap = + mutableMapOf?) -> Unit>() + private val musicCollectionCallbackMap = + mutableMapOf?) -> Unit>() + + private var lrcView: ILrcView? = null + + private var localPlayerPosition: Long = 0 + private var localPlayerSystemTime: Long = 0 + + //歌词实时刷新 + private var mReceivedPlayPosition: Long = 0 //播放器播放position,ms + private var mLastReceivedPlayPosTime: Long? = null + + // event + private var ktvApiEventHandlerList = mutableListOf() + private var mainSingerHasJoinChannelEx: Boolean = false + + // 合唱校准 + private var audioPlayoutDelay = 0 + + // 音高 + private var pitch = 0.0 + + // 是否在麦上 + private var isOnMicOpen = false + private var isRelease = false + + // mpk状态 + private var mediaPlayerState: MediaPlayerState = MediaPlayerState.PLAYER_STATE_IDLE + + private var professionalModeOpen = false + private var audioRouting = 0 + private var isPublishAudio = false // 通过是否发音频流判断 + + // 演唱分数 + private var singingScore = 0 + + // multipath + private var enableMultipathing = true + + // 歌词信息是否来源于 dataStream + private var recvFromDataStream = false + + // 开始播放歌词 + private var mStopDisplayLrc = true + private var displayLrcFuture: ScheduledFuture<*>? = null + private val displayLrcTask = object : Runnable { + override fun run() { + if (!mStopDisplayLrc){ + if (singerRole == KTVSingRole.Audience && !recvFromDataStream) return // audioMetaData方案观众return + val lastReceivedTime = mLastReceivedPlayPosTime ?: return + val curTime = System.currentTimeMillis() + val offset = curTime - lastReceivedTime + if (offset <= 100) { + val curTs = mReceivedPlayPosition + offset + if (singerRole == KTVSingRole.LeadSinger || singerRole == KTVSingRole.SoloSinger) { + val lrcTime = LrcTimeOuterClass.LrcTime.newBuilder() + .setTypeValue(LrcTimeOuterClass.MsgType.LRC_TIME.number) + .setForward(true) + .setSongId(songIdentifier) + .setTs(curTs) + .setUid(giantChorusApiConfig.musicStreamUid) + .build() + + mRtcEngine.sendAudioMetadataEx(lrcTime.toByteArray(), mpkConnection) + } + runOnMainThread { + lrcView?.onUpdatePitch(pitch.toFloat()) + // (fix ENT-489)Make lyrics delay for 200ms + // Per suggestion from Bob, it has a intrinsic buffer/delay between sound and `onPositionChanged(Player)`, + // such as AEC/Player/Device buffer. + // We choose the estimated 200ms. + lrcView?.onUpdateProgress(if (curTs > 200) (curTs - 200) else curTs) // The delay here will impact both singer and audience side + } + } + } + } + } + + // 评分驱动混音 + private var mSyncScoreFuture :ScheduledFuture<*>? = null + private var mStopSyncScore = true + private val mSyncScoreTask = Runnable { + if (!mStopSyncScore) { + if (mediaPlayerState == MediaPlayerState.PLAYER_STATE_PLAYING && + (singerRole == KTVSingRole.LeadSinger || singerRole == KTVSingRole.CoSinger)) { + sendSyncScore() + } + } + } + + // 云端合流信息 + private var mSyncCloudConvergenceStatusFuture :ScheduledFuture<*>? = null + private var mStopSyncCloudConvergenceStatus = true + private val mSyncCloudConvergenceStatusTask = Runnable { + if (!mStopSyncCloudConvergenceStatus && singerRole == KTVSingRole.LeadSinger) { + sendSyncCloudConvergenceStatus() + } + } + + init { + apiReporter.reportFuncEvent("initialize", mapOf("config" to giantChorusApiConfig), mapOf()) + this.singChannelRtcConnection = RtcConnection(giantChorusApiConfig.chorusChannelName, giantChorusApiConfig.localUid) + + // ------------------ 初始化内容中心 ------------------ + if (giantChorusApiConfig.musicType == KTVMusicType.SONG_CODE) { + val contentCenterConfiguration = MusicContentCenterConfiguration() + contentCenterConfiguration.appId = giantChorusApiConfig.appId + contentCenterConfiguration.mccUid = giantChorusApiConfig.localUid.toLong() + contentCenterConfiguration.token = giantChorusApiConfig.rtmToken + contentCenterConfiguration.maxCacheSize = giantChorusApiConfig.maxCacheSize + if (KTVApi.debugMode) { + contentCenterConfiguration.mccDomain = KTVApi.mccDomain + } + mMusicCenter = IAgoraMusicContentCenter.create(mRtcEngine) + mMusicCenter.initialize(contentCenterConfiguration) + mMusicCenter.registerEventHandler(this) + + // ------------------ 初始化音乐播放器实例 ------------------ + mPlayer = mMusicCenter.createMusicPlayer() + } else { + mPlayer = mRtcEngine.createMediaPlayer() + } + mPlayer.adjustPublishSignalVolume(KTVApi.mpkPublishVolume) + mPlayer.adjustPlayoutVolume(KTVApi.mpkPlayoutVolume) + + // 注册回调 + mPlayer.registerPlayerObserver(this) + setKTVParameters() + startDisplayLrc() + startSyncScore() + startSyncCloudConvergenceStatus() + isRelease = false + + mPlayer.setPlayerOption("play_pos_change_callback", 100) + } + + // 日志输出 + private fun ktvApiLog(msg: String) { + if (isRelease) return + apiReporter.writeLog("[${tag}] $msg", LOG_LEVEL_INFO) + } + + // 日志输出 + private fun ktvApiLogError(msg: String) { + if (isRelease) return + apiReporter.writeLog("[${tag}] $msg", LOG_LEVEL_ERROR) + } + + override fun renewInnerDataStreamId() { + apiReporter.reportFuncEvent("renewInnerDataStreamId", mapOf(), mapOf()) + + val innerCfg = DataStreamConfig() + innerCfg.syncWithAudio = true + innerCfg.ordered = false + this.innerDataStreamId = mRtcEngine.createDataStreamEx(innerCfg, singChannelRtcConnection) + } + + private fun setKTVParameters() { + mRtcEngine.setParameters("{\"rtc.enable_nasa2\": true}") + mRtcEngine.setParameters("{\"rtc.ntp_delay_drop_threshold\":1000}") + mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp\": true}") + mRtcEngine.setParameters("{\"rtc.net.maxS2LDelay\": 800}") + mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":true}") + + mRtcEngine.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}") + mRtcEngine.setParameters("{\"che.audio.neteq.targetlevel_offset\": 20}") + + mRtcEngine.setParameters("{\"rtc.net.maxS2LDelayBroadcast\":400}") + mRtcEngine.setParameters("{\"che.audio.neteq.prebuffer\":true}") + mRtcEngine.setParameters("{\"che.audio.neteq.prebuffer_max_delay\":600}") + mRtcEngine.setParameters("{\"che.audio.max_mixed_participants\": 8}") + mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 48000}") + mRtcEngine.setParameters("{\"che.audio.uplink_apm_async_process\": true}") + + // 标准音质 + mRtcEngine.setParameters("{\"che.audio.aec.split_srate_for_48k\": 16000}") + + // ENT-901 + mRtcEngine.setParameters("{\"che.audio.ans.noise_gate\": 20}") + + // Android Only + mRtcEngine.setParameters("{\"che.audio.enable_estimated_device_delay\":false}") + + // TopN + SendAudioMetadata + mRtcEngine.setParameters("{\"rtc.use_audio4\": true}") + + // mutipath + enableMultipathing = false + //mRtcEngine.setParameters("{\"rtc.enableMultipath\": true}") + mRtcEngine.setParameters("{\"rtc.enable_tds_request_on_join\": true}") + //mRtcEngine.setParameters("{\"rtc.remote_path_scheduling_strategy\": 0}") + //mRtcEngine.setParameters("{\"rtc.path_scheduling_strategy\": 0}") + + // 数据上报 + mRtcEngine.setParameters("{\"rtc.direct_send_custom_event\": true}") + } + + private fun resetParameters() { + mRtcEngine.setAudioScenario(AUDIO_SCENARIO_GAME_STREAMING) + mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 80000}") // 兼容之前的profile = 3设置 + mRtcEngine.setParameters("{\"che.audio.max_mixed_participants\": 3}") // 正常3路下行流混流 + mRtcEngine.setParameters("{\"che.audio.neteq.prebuffer\": false}") // 关闭 接收端快速对齐模式 + mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp\": false}") // 观众关闭 多端同步 + mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\": false}") //主播关闭多端同步 + } + + override fun addEventHandler(ktvApiEventHandler: IKTVApiEventHandler) { + apiReporter.reportFuncEvent("addEventHandler", mapOf("ktvApiEventHandler" to ktvApiEventHandler), mapOf()) + ktvApiEventHandlerList.add(ktvApiEventHandler) + } + + override fun removeEventHandler(ktvApiEventHandler: IKTVApiEventHandler) { + apiReporter.reportFuncEvent("removeEventHandler", mapOf("ktvApiEventHandler" to ktvApiEventHandler), mapOf()) + ktvApiEventHandlerList.remove(ktvApiEventHandler) + } + + override fun release() { + apiReporter.reportFuncEvent("release", mapOf(), mapOf()) + if (isRelease) return + isRelease = true + singerRole = KTVSingRole.Audience + + resetParameters() + stopSyncCloudConvergenceStatus() + stopSyncScore() + stopDisplayLrc() + this.mLastReceivedPlayPosTime = null + this.mReceivedPlayPosition = 0 + this.innerDataStreamId = 0 + this.singingScore = 0 + + lyricCallbackMap.clear() + loadMusicCallbackMap.clear() + musicChartsCallbackMap.clear() + musicCollectionCallbackMap.clear() + lrcView = null + + mPlayer.unRegisterPlayerObserver(this) + + if (giantChorusApiConfig.musicType == KTVMusicType.SONG_CODE) { + mMusicCenter.unregisterEventHandler() + } + + mPlayer.stop() + mPlayer.destroy() + IAgoraMusicContentCenter.destroy() + + mainSingerHasJoinChannelEx = false + professionalModeOpen = false + audioRouting = 0 + isPublishAudio = false + } + + override fun enableProfessionalStreamerMode(enable: Boolean) { + apiReporter.reportFuncEvent("enableProfessionalStreamerMode", mapOf("enable" to enable), mapOf()) + this.professionalModeOpen = enable + processAudioProfessionalProfile() + } + + private fun processAudioProfessionalProfile() { + ktvApiLog("processAudioProfessionalProfile: audioRouting: $audioRouting, professionalModeOpen: $professionalModeOpen, isPublishAudio:$isPublishAudio") + if (!isPublishAudio) return // 必须为麦上者 + if (professionalModeOpen) { + // 专业 + if (audioRouting == AUDIO_ROUTE_HEADSET || audioRouting == AUDIO_ROUTE_HEADSETNOMIC || audioRouting == AUDIO_ROUTE_BLUETOOTH_DEVICE_HFP || audioRouting == AUDIO_ROUTE_USBDEVICE || audioRouting == AUDIO_ROUTE_BLUETOOTH_DEVICE_A2DP) { + // 耳机 关闭3A 关闭md + mRtcEngine.setParameters("{\"che.audio.aec.enable\": false}") + mRtcEngine.setParameters("{\"che.audio.agc.enable\": false}") + mRtcEngine.setParameters("{\"che.audio.ans.enable\": false}") + mRtcEngine.setParameters("{\"che.audio.md.enable\": false}") + mRtcEngine.setAudioProfile(AUDIO_PROFILE_MUSIC_HIGH_QUALITY_STEREO) // AgoraAudioProfileMusicHighQualityStereo + } else { + // 非耳机 开启3A 关闭md + mRtcEngine.setParameters("{\"che.audio.aec.enable\": true}") + mRtcEngine.setParameters("{\"che.audio.agc.enable\": true}") + mRtcEngine.setParameters("{\"che.audio.ans.enable\": true}") + mRtcEngine.setParameters("{\"che.audio.md.enable\": false}") + mRtcEngine.setAudioProfile(AUDIO_PROFILE_MUSIC_HIGH_QUALITY_STEREO) // AgoraAudioProfileMusicHighQualityStereo + } + } else { + // 非专业 开启3A 关闭md + mRtcEngine.setParameters("{\"che.audio.aec.enable\": true}") + mRtcEngine.setParameters("{\"che.audio.agc.enable\": true}") + mRtcEngine.setParameters("{\"che.audio.ans.enable\": true}") + mRtcEngine.setParameters("{\"che.audio.md.enable\": false}") + mRtcEngine.setAudioProfile(AUDIO_PROFILE_MUSIC_STANDARD_STEREO) // AgoraAudioProfileMusicStandardStereo + } + } + + override fun enableMulitpathing(enable: Boolean) { + apiReporter.reportFuncEvent("enableMulitpathing", mapOf("enable" to enable), mapOf()) + this.enableMultipathing = enable + + if (singerRole == KTVSingRole.LeadSinger || singerRole == KTVSingRole.CoSinger) { + subChorusConnection?.let { + mRtcEngine.setParametersEx("{\"rtc.enableMultipath\": $enable, \"rtc.path_scheduling_strategy\": 0, \"rtc.remote_path_scheduling_strategy\": 0}", it) + } + } + } + + override fun renewToken(rtmToken: String, chorusChannelRtcToken: String) { + apiReporter.reportFuncEvent("renewToken", mapOf(), mapOf()) + // 更新RtmToken + mMusicCenter.renewToken(rtmToken) + // 更新合唱频道RtcToken + if (subChorusConnection != null) { + val channelMediaOption = ChannelMediaOptions() + channelMediaOption.token = chorusChannelRtcToken + mRtcEngine.updateChannelMediaOptionsEx(channelMediaOption, subChorusConnection) + } + } + + // 1、Audience -》SoloSinger + // 2、Audience -》LeadSinger + // 3、SoloSinger -》Audience + // 4、Audience -》CoSinger + // 5、CoSinger -》Audience + // 6、SoloSinger -》LeadSinger + // 7、LeadSinger -》SoloSinger + // 8、LeadSinger -》Audience + var singerRole: KTVSingRole = KTVSingRole.Audience + + override fun switchSingerRole( + newRole: KTVSingRole, + switchRoleStateListener: ISwitchRoleStateListener? + ) { + apiReporter.reportFuncEvent("switchSingerRole", mapOf("newRole" to newRole), mapOf()) + ktvApiLog("switchSingerRole oldRole: $singerRole, newRole: $newRole") + val oldRole = singerRole + if (this.singerRole == KTVSingRole.Audience && newRole == KTVSingRole.LeadSinger) { + // 1、Audience -》LeadSinger + // 离开观众频道 + mRtcEngine.leaveChannelEx(RtcConnection(giantChorusApiConfig.audienceChannelName, giantChorusApiConfig.localUid)) + joinChorus(newRole) + singerRole = newRole + ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) } + switchRoleStateListener?.onSwitchRoleSuccess() + } else if (this.singerRole == KTVSingRole.Audience && newRole == KTVSingRole.CoSinger) { + // 2、Audience -》CoSinger + // 离开观众频道 + mRtcEngine.leaveChannelEx(RtcConnection(giantChorusApiConfig.audienceChannelName, giantChorusApiConfig.localUid)) + joinChorus(newRole) + singerRole = newRole + switchRoleStateListener?.onSwitchRoleSuccess() + ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) } + } else if (this.singerRole == KTVSingRole.CoSinger && newRole == KTVSingRole.Audience) { + // 3、CoSinger -》Audience + leaveChorus2(singerRole) + // 加入观众频道 + mRtcEngine.joinChannelEx(giantChorusApiConfig.audienceChannelToken, RtcConnection(giantChorusApiConfig.audienceChannelName, giantChorusApiConfig.localUid), ChannelMediaOptions(), object : IRtcEngineEventHandler() { + override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) { + super.onJoinChannelSuccess(channel, uid, elapsed) + } + + override fun onStreamMessage(uid: Int, streamId: Int, data: ByteArray?) { + super.onStreamMessage(uid, streamId, data) + dealWithStreamMessage(uid, streamId, data) + } + + override fun onAudioMetadataReceived(uid: Int, data: ByteArray?) { + super.onAudioMetadataReceived(uid, data) + dealWithAudioMetadata(uid, data) + } + }) + mRtcEngine.setParametersEx("{\"rtc.use_audio4\": true}", RtcConnection(giantChorusApiConfig.audienceChannelName, giantChorusApiConfig.localUid)) + + singerRole = newRole + ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) } + switchRoleStateListener?.onSwitchRoleSuccess() + } else if (this.singerRole == KTVSingRole.LeadSinger && newRole == KTVSingRole.Audience) { + // 4、LeadSinger -》Audience + stopSing() + leaveChorus2(singerRole) + + // 加入观众频道 + mRtcEngine.joinChannelEx(giantChorusApiConfig.audienceChannelToken, RtcConnection(giantChorusApiConfig.audienceChannelName, giantChorusApiConfig.localUid), ChannelMediaOptions(), object : IRtcEngineEventHandler() { + override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) { + super.onJoinChannelSuccess(channel, uid, elapsed) + } + + override fun onStreamMessage(uid: Int, streamId: Int, data: ByteArray?) { + super.onStreamMessage(uid, streamId, data) + dealWithStreamMessage(uid, streamId, data) + } + + override fun onAudioMetadataReceived(uid: Int, data: ByteArray?) { + super.onAudioMetadataReceived(uid, data) + dealWithAudioMetadata(uid, data) + } + }) + mRtcEngine.setParametersEx("{\"rtc.use_audio4\": true}", RtcConnection(giantChorusApiConfig.audienceChannelName, giantChorusApiConfig.localUid)) + + singerRole = newRole + ktvApiEventHandlerList.forEach { it.onSingerRoleChanged(oldRole, newRole) } + switchRoleStateListener?.onSwitchRoleSuccess() + } else { + switchRoleStateListener?.onSwitchRoleFail(SwitchRoleFailReason.NO_PERMISSION) + ktvApiLogError("Error!You can not switch role from $singerRole to $newRole!") + } + } + + override fun fetchMusicCharts(onMusicChartResultListener: (requestId: String?, status: Int, list: Array?) -> Unit) { + apiReporter.reportFuncEvent("fetchMusicCharts", mapOf(), mapOf()) + val requestId = mMusicCenter.musicCharts + musicChartsCallbackMap[requestId] = onMusicChartResultListener + } + + override fun searchMusicByMusicChartId( + musicChartId: Int, + page: Int, + pageSize: Int, + jsonOption: String, + onMusicCollectionResultListener: (requestId: String?, status: Int, page: Int, pageSize: Int, total: Int, list: Array?) -> Unit + ) { + apiReporter.reportFuncEvent("searchMusicByMusicChartId", mapOf(), mapOf()) + val requestId = + mMusicCenter.getMusicCollectionByMusicChartId(musicChartId, page, pageSize, jsonOption) + musicCollectionCallbackMap[requestId] = onMusicCollectionResultListener + } + + override fun searchMusicByKeyword( + keyword: String, + page: Int, + pageSize: Int, + jsonOption: String, + onMusicCollectionResultListener: (requestId: String?, status: Int, page: Int, pageSize: Int, total: Int, list: Array?) -> Unit + ) { + apiReporter.reportFuncEvent("searchMusicByKeyword", mapOf(), mapOf()) + val requestId = mMusicCenter.searchMusic(keyword, page, pageSize, jsonOption) + musicCollectionCallbackMap[requestId] = onMusicCollectionResultListener + } + + override fun loadMusic( + songCode: Long, + config: KTVLoadMusicConfiguration, + musicLoadStateListener: IMusicLoadStateListener + ) { + apiReporter.reportFuncEvent("loadMusic", mapOf("songCode" to songCode, "config" to config), mapOf()) + ktvApiLog("loadMusic called: songCode $songCode") + // 设置到全局, 连续调用以最新的为准 + this.songCode = songCode + this.songIdentifier = config.songIdentifier + this.mainSingerUid = config.mainSingerUid + mLastReceivedPlayPosTime = null + mReceivedPlayPosition = 0 + + if (config.mode == KTVLoadMusicMode.LOAD_NONE) { + return + } + + if (config.mode == KTVLoadMusicMode.LOAD_LRC_ONLY) { + // 只加载歌词 + loadLyric(songCode) { song, lyricUrl -> + if (this.songCode != song) { + // 当前歌曲已发生变化,以最新load歌曲为准 + ktvApiLogError("loadMusic failed: CANCELED") + musicLoadStateListener.onMusicLoadFail(song, KTVLoadMusicFailReason.CANCELED) + return@loadLyric + } + + if (lyricUrl == null) { + // 加载歌词失败 + ktvApiLogError("loadMusic failed: NO_LYRIC_URL") + musicLoadStateListener.onMusicLoadFail(song, KTVLoadMusicFailReason.NO_LYRIC_URL) + } else { + // 加载歌词成功 + ktvApiLog("loadMusic success") + lrcView?.onDownloadLrcData(lyricUrl) + musicLoadStateListener.onMusicLoadSuccess(song, lyricUrl) + } + } + return + } + + // 预加载歌曲 + preLoadMusic(songCode) { song, percent, status, msg, lrcUrl -> + if (status == 0) { + // 预加载歌曲成功 + if (this.songCode != song) { + // 当前歌曲已发生变化,以最新load歌曲为准 + ktvApiLogError("loadMusic failed: CANCELED") + musicLoadStateListener.onMusicLoadFail(song, KTVLoadMusicFailReason.CANCELED) + return@preLoadMusic + } + if (config.mode == KTVLoadMusicMode.LOAD_MUSIC_AND_LRC) { + // 需要加载歌词 + loadLyric(song) { _, lyricUrl -> + if (this.songCode != song) { + // 当前歌曲已发生变化,以最新load歌曲为准 + ktvApiLogError("loadMusic failed: CANCELED") + musicLoadStateListener.onMusicLoadFail(song, KTVLoadMusicFailReason.CANCELED) + return@loadLyric + } + + if (lyricUrl == null) { + // 加载歌词失败 + ktvApiLogError("loadMusic failed: NO_LYRIC_URL") + musicLoadStateListener.onMusicLoadFail(song, KTVLoadMusicFailReason.NO_LYRIC_URL) + } else { + // 加载歌词成功 + ktvApiLog("loadMusic success") + lrcView?.onDownloadLrcData(lyricUrl) + musicLoadStateListener.onMusicLoadProgress(song, 100, MusicLoadStatus.COMPLETED, msg, lrcUrl) + musicLoadStateListener.onMusicLoadSuccess(song, lyricUrl) + } + } + } else if (config.mode == KTVLoadMusicMode.LOAD_MUSIC_ONLY) { + // 不需要加载歌词 + ktvApiLog("loadMusic success") + musicLoadStateListener.onMusicLoadProgress(song, 100, MusicLoadStatus.COMPLETED, msg, lrcUrl) + musicLoadStateListener.onMusicLoadSuccess(song, "") + } + } else if (status == 2) { + // 预加载歌曲加载中 + musicLoadStateListener.onMusicLoadProgress(song, percent, MusicLoadStatus.values().firstOrNull { it.value == status } ?: MusicLoadStatus.FAILED, msg, lrcUrl) + } else { + // 预加载歌曲失败 + ktvApiLogError("loadMusic failed: MUSIC_PRELOAD_FAIL") + musicLoadStateListener.onMusicLoadFail(song, KTVLoadMusicFailReason.MUSIC_PRELOAD_FAIL) + } + } + } + + override fun loadMusic( + url: String, + config: KTVLoadMusicConfiguration + ) { + apiReporter.reportFuncEvent("loadMusic", mapOf("url" to url, "config" to config), mapOf()) + ktvApiLog("loadMusic called: songCode $songCode") + this.songIdentifier = config.songIdentifier + this.songUrl = url + this.mainSingerUid = config.mainSingerUid + } + + override fun removeMusic(songCode: Long) { + apiReporter.reportFuncEvent("removeMusic", mapOf("songCode" to songCode), mapOf()) + val ret = mMusicCenter.removeCache(songCode) + if (ret < 0) { + ktvApiLogError("removeMusic failed, ret: $ret") + } + } + + override fun load2Music(url1: String, url2: String, config: KTVLoadMusicConfiguration) { + apiReporter.reportFuncEvent("load2Music", mapOf("url1" to url1, "url2" to url2, "config" to config), mapOf()) + this.songIdentifier = config.songIdentifier + this.songUrl = url1 + this.songUrl2 = url2 + this.mainSingerUid = config.mainSingerUid + } + + override fun switchPlaySrc(url: String, syncPts: Boolean) { + apiReporter.reportFuncEvent("switchPlaySrc", mapOf("url" to url, "syncPts" to syncPts), mapOf()) + if (this.songUrl != url && this.songUrl2 != url) { + ktvApiLogError("switchPlaySrc failed: canceled") + return + } + val curPlayPosition = if (syncPts) mPlayer.playPosition else 0 + mPlayer.stop() + startSing(url, curPlayPosition) + } + + override fun startSing(songCode: Long, startPos: Long) { + apiReporter.reportFuncEvent("startSing", mapOf("songCode" to songCode, "startPos" to startPos), mapOf()) + ktvApiLog("playSong called: $singerRole") + if (singerRole != KTVSingRole.SoloSinger && singerRole != KTVSingRole.LeadSinger) { + ktvApiLogError("startSing failed: error singerRole") + return + } + if (this.songCode != songCode) { + ktvApiLogError("startSing failed: canceled") + return + } + mRtcEngine.adjustPlaybackSignalVolume(KTVApi.remoteVolume) + + // 导唱 + mPlayer.setPlayerOption("enable_multi_audio_track", 1) + val ret = (mPlayer as IAgoraMusicPlayer).open(songCode, startPos) + if (ret != 0) { + ktvApiLogError("mpk open failed: $ret") + } + } + + override fun startSing(url: String, startPos: Long) { + apiReporter.reportFuncEvent("startSing", mapOf("url" to url, "startPos" to startPos), mapOf()) + ktvApiLog("playSong called: $singerRole") + if (singerRole != KTVSingRole.SoloSinger && singerRole != KTVSingRole.LeadSinger) { + ktvApiLogError("startSing failed: error singerRole") + return + } + if (this.songUrl != url && this.songUrl2 != url) { + ktvApiLogError("startSing failed: canceled") + return + } + mRtcEngine.adjustPlaybackSignalVolume(KTVApi.remoteVolume) + + // 导唱 + mPlayer.setPlayerOption("enable_multi_audio_track", 1) + val ret = mPlayer.open(url, startPos) + if (ret != 0) { + ktvApiLogError("mpk open failed: $ret") + } + } + + override fun resumeSing() { + apiReporter.reportFuncEvent("resumeSing", mapOf(), mapOf()) + ktvApiLog("resumePlay called") + mPlayer.resume() + } + + override fun pauseSing() { + apiReporter.reportFuncEvent("pauseSing", mapOf(), mapOf()) + ktvApiLog("pausePlay called") + mPlayer.pause() + } + + override fun seekSing(time: Long) { + apiReporter.reportFuncEvent("seekSing", mapOf("time" to time), mapOf()) + ktvApiLog("seek called") + mPlayer.seek(time) + syncPlayProgress(time) + } + + override fun setLrcView(view: ILrcView) { + apiReporter.reportFuncEvent("setLrcView", mapOf(), mapOf()) + ktvApiLog("setLrcView called") + this.lrcView = view + } + + override fun muteMic(mute: Boolean) { + apiReporter.reportFuncEvent("muteMic", mapOf("mute" to mute), mapOf()) + this.isOnMicOpen = !mute + if (singerRole == KTVSingRole.Audience) return + val channelMediaOption = ChannelMediaOptions() + channelMediaOption.publishMicrophoneTrack = isOnMicOpen + channelMediaOption.clientRoleType = CLIENT_ROLE_BROADCASTER + mRtcEngine.updateChannelMediaOptions(channelMediaOption) + mRtcEngine.muteLocalAudioStreamEx(!isOnMicOpen, singChannelRtcConnection) + } + + override fun setAudioPlayoutDelay(audioPlayoutDelay: Int) { + apiReporter.reportFuncEvent("setAudioPlayoutDelay", mapOf("audioPlayoutDelay" to audioPlayoutDelay), mapOf()) + this.audioPlayoutDelay = audioPlayoutDelay + } + + fun setSingingScore(score: Int) { + this.singingScore = score + } + + fun setAudienceStreamMessage(uid: Int, streamId: Int, data: ByteArray?) { + dealWithStreamMessage(uid, streamId, data) + } + + fun setAudienceAudioMetadataReceived(uid: Int, data: ByteArray?) { + dealWithAudioMetadata(uid, data) + } + + override fun getMediaPlayer(): IMediaPlayer { + return mPlayer + } + + override fun getMusicContentCenter(): IAgoraMusicContentCenter { + return mMusicCenter + } + + override fun switchAudioTrack(mode: AudioTrackMode) { + apiReporter.reportFuncEvent("switchAudioTrack", mapOf("mode" to mode), mapOf()) + when (singerRole) { + KTVSingRole.LeadSinger, KTVSingRole.SoloSinger -> { + when (mode) { + AudioTrackMode.YUAN_CHANG -> mPlayer.selectMultiAudioTrack(0, 0) + AudioTrackMode.BAN_ZOU -> mPlayer.selectMultiAudioTrack(1, 1) + AudioTrackMode.DAO_CHANG -> mPlayer.selectMultiAudioTrack(0, 1) + } + } + KTVSingRole.CoSinger -> { + when (mode) { + AudioTrackMode.YUAN_CHANG -> mPlayer.selectAudioTrack(0) + AudioTrackMode.BAN_ZOU -> mPlayer.selectAudioTrack(1) + AudioTrackMode.DAO_CHANG -> ktvApiLogError("CoSinger can not switch to DAO_CHANG") + } + } + KTVSingRole.Audience -> ktvApiLogError("CoSinger can not switch audio track") + } + } + + // ------------------ inner KTVApi -------------------- + private fun stopSing() { + ktvApiLog("stopSong called") + + val channelMediaOption = ChannelMediaOptions() + channelMediaOption.autoSubscribeAudio = true + channelMediaOption.publishMediaPlayerAudioTrack = false + mRtcEngine.updateChannelMediaOptionsEx(channelMediaOption, singChannelRtcConnection) + + mPlayer.stop() + + // 更新音频配置 + mRtcEngine.setAudioScenario(AUDIO_SCENARIO_GAME_STREAMING) + mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":true}") + mRtcEngine.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}") + mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 48000}") + } + + private val subScribeSingerMap = mutableMapOf() // + private val singerList = mutableListOf() // + private var mainSingerDelay = 0 + private fun joinChorus(newRole: KTVSingRole) { + ktvApiLog("joinChorus: $newRole") + val singChannelMediaOptions = ChannelMediaOptions() + singChannelMediaOptions.autoSubscribeAudio = true + singChannelMediaOptions.publishMicrophoneTrack = true + singChannelMediaOptions.clientRoleType = CLIENT_ROLE_BROADCASTER + singChannelMediaOptions.isAudioFilterable = newRole != KTVSingRole.LeadSinger // 主唱不参加TopN + + // 加入演唱频道 + mRtcEngine.joinChannelEx(giantChorusApiConfig.chorusChannelToken, singChannelRtcConnection, singChannelMediaOptions, object : + IRtcEngineEventHandler() { + override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) { + super.onJoinChannelSuccess(channel, uid, elapsed) + ktvApiLog("singChannel onJoinChannelSuccess: $newRole") + } + + override fun onStreamMessage(uid: Int, streamId: Int, data: ByteArray?) { + super.onStreamMessage(uid, streamId, data) + dealWithStreamMessage(uid, streamId, data) + } + + override fun onAudioVolumeIndication(speakers: Array?, totalVolume: Int) { + val allSpeakers = speakers ?: return + // VideoPitch 回调, 用于同步各端音准 + if (singerRole != KTVSingRole.Audience) { + for (info in allSpeakers) { + if (info.uid == 0) { + pitch = + if (mediaPlayerState == MediaPlayerState.PLAYER_STATE_PLAYING && isOnMicOpen) { + info.voicePitch + } else { + 0.0 + } + } + } + } + } + + // 用于合唱校准 + override fun onLocalAudioStats(stats: LocalAudioStats?) { + if (KTVApi.useCustomAudioSource) return + val audioState = stats ?: return + audioPlayoutDelay = audioState.audioPlayoutDelay + } + + // 用于检测耳机状态 + override fun onAudioRouteChanged(routing: Int) { // 0\2\5 earPhone + audioRouting = routing + processAudioProfessionalProfile() + } + + // 用于检测收发流状态 + override fun onAudioPublishStateChanged( + channel: String?, + oldState: Int, + newState: Int, + elapseSinceLastState: Int + ) { + ktvApiLog("onAudioPublishStateChanged: oldState: $oldState, newState: $newState") + if (newState == 3) { + isPublishAudio = true + processAudioProfessionalProfile() + } else if (newState == 1) { + isPublishAudio = false + } + } + + // 延迟选路策略 + override fun onUserJoined(uid: Int, elapsed: Int) { + super.onUserJoined(uid, elapsed) + if (uid != giantChorusApiConfig.musicStreamUid && subScribeSingerMap.size < 8) { + mRtcEngine.muteRemoteAudioStreamEx(uid, false, singChannelRtcConnection) + if (uid != mainSingerUid) { + subScribeSingerMap[uid] = 0 + } + } else if (uid != giantChorusApiConfig.musicStreamUid && subScribeSingerMap.size == 8) { + mRtcEngine.muteRemoteAudioStreamEx(uid, true, singChannelRtcConnection) + } + if (uid != giantChorusApiConfig.musicStreamUid && uid != mainSingerUid) { + singerList.add(uid) + } + } + + override fun onUserOffline(uid: Int, reason: Int) { + super.onUserOffline(uid, reason) + subScribeSingerMap.remove(uid) + singerList.remove(uid) + } + + override fun onLeaveChannel(stats: RtcStats?) { + super.onLeaveChannel(stats) + subScribeSingerMap.clear() + singerList.clear() + } + + override fun onRemoteAudioStats(stats: RemoteAudioStats?) { + super.onRemoteAudioStats(stats) + stats ?: return + if (KTVApi.routeSelectionConfig.type == GiantChorusRouteSelectionType.RANDOM || KTVApi.routeSelectionConfig.type == GiantChorusRouteSelectionType.TOP_N) return + val uid = stats.uid + if (uid == mainSingerUid) { + mainSingerDelay = stats.e2eDelay + } +// if (uid == mainSingerUid && stats.e2eDelay > 300) { +// //ToastUtils.showToast("主唱 $mainSingerUid 延迟超过300ms,目前延迟:${stats.ntpE2eDelay}") +// } +// if (subScribeSingerMap.any { it.key == uid } && stats.e2eDelay > 300) { +// //ToastUtils.showToast("当前订阅用户 $uid 延迟超过300ms,目前延迟:${stats.ntpE2eDelay}") +// } + if (uid != mainSingerUid && uid != giantChorusApiConfig.musicStreamUid && subScribeSingerMap.containsKey(uid)) { + subScribeSingerMap[uid] = stats.e2eDelay + } + } + }) + + mRtcEngine.setParametersEx("{\"che.audio.max_mixed_participants\": 8}", singChannelRtcConnection) + mRtcEngine.setParametersEx("{\"rtc.use_audio4\": true}", singChannelRtcConnection) + + // 选路策略处理 + if (KTVApi.routeSelectionConfig.type == GiantChorusRouteSelectionType.TOP_N || KTVApi.routeSelectionConfig.type == GiantChorusRouteSelectionType.BY_DELAY_AND_TOP_N) { + if (newRole == KTVSingRole.LeadSinger) { + mRtcEngine.setParametersEx("{\"che.audio.filter_streams\":${KTVApi.routeSelectionConfig.streamNum}}", singChannelRtcConnection) + } else { + mRtcEngine.setParametersEx("{\"che.audio.filter_streams\":${KTVApi.routeSelectionConfig.streamNum - 1}}", singChannelRtcConnection) + } + } else { + mRtcEngine.setParametersEx("{\"che.audio.filter_streams\": 0}", singChannelRtcConnection) + } + mRtcEngine.enableAudioVolumeIndicationEx(50, 10, true, singChannelRtcConnection) + + when (newRole) { + KTVSingRole.LeadSinger -> { + // 更新音频配置 + mRtcEngine.setAudioScenario(AUDIO_SCENARIO_CHORUS) + mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":false}") + mRtcEngine.setParameters("{\"che.audio.neteq.enable_stable_playout\":false}") + mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 80000}") + + // mpk流加入频道 + val options = ChannelMediaOptions() + options.autoSubscribeAudio = false + options.autoSubscribeVideo = false + options.publishMicrophoneTrack = false + options.publishMediaPlayerAudioTrack = true + options.publishMediaPlayerId = mPlayer.mediaPlayerId + options.clientRoleType = CLIENT_ROLE_BROADCASTER + // 防止主唱和合唱听见mpk流的声音 + options.enableAudioRecordingOrPlayout = false + + val rtcConnection = RtcConnection() + rtcConnection.channelId = giantChorusApiConfig.chorusChannelName + rtcConnection.localUid = giantChorusApiConfig.musicStreamUid + mpkConnection = rtcConnection + + mRtcEngine.joinChannelEx( + giantChorusApiConfig.musicStreamToken, + mpkConnection, + options, + object : IRtcEngineEventHandler() { + override fun onJoinChannelSuccess(channel: String, uid: Int, elapsed: Int) { + ktvApiLog("onMPKJoinChannelSuccess, channel: $channel, uid: $uid") + } + + override fun onLeaveChannel(stats: RtcStats) { + ktvApiLog("onMPKLeaveChannel") + } + }) + mRtcEngine.setParametersEx("{\"rtc.use_audio4\": true}", mpkConnection) + } + KTVSingRole.CoSinger -> { + // 防止主唱和合唱听见mpk流的声音 + mRtcEngine.muteRemoteAudioStreamEx( + giantChorusApiConfig.musicStreamUid, + true, + singChannelRtcConnection + ) + + // 更新音频配置 + mRtcEngine.setAudioScenario(AUDIO_SCENARIO_CHORUS) + mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":false}") + mRtcEngine.setParameters("{\"che.audio.neteq.enable_stable_playout\":false}") + mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 48000}") + + // 预加载歌曲成功 + // 导唱 + mPlayer.setPlayerOption("enable_multi_audio_track", 1) + if (giantChorusApiConfig.musicType == KTVMusicType.SONG_CODE) { + val ret = (mPlayer as IAgoraMusicPlayer).open(songCode, 0) // TODO open failed + if (ret != 0) { + ktvApiLogError("mpk open failed: $ret") + } + } else { + val ret = mPlayer.open(songUrl, 0) // TODO open failed + if (ret != 0) { + ktvApiLogError("mpk open failed: $ret") + } + } + } + else -> { + ktvApiLogError("JoinChorus with Wrong role: $singerRole") + } + } + + mRtcEngine.muteRemoteAudioStreamEx(giantChorusApiConfig.musicStreamUid, true, singChannelRtcConnection) + // 加入演唱频道后,创建data stream + renewInnerDataStreamId() + } + + private fun leaveChorus2(role: KTVSingRole) { + ktvApiLog("leaveChorus: $singerRole") + when (role) { + KTVSingRole.LeadSinger -> { + mRtcEngine.leaveChannelEx(mpkConnection) + } + KTVSingRole.CoSinger -> { + mPlayer.stop() + + // 更新音频配置 + mRtcEngine.setAudioScenario(AUDIO_SCENARIO_GAME_STREAMING) + mRtcEngine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":true}") + mRtcEngine.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}") + mRtcEngine.setParameters("{\"che.audio.custom_bitrate\": 48000}") + } + else -> { + ktvApiLogError("JoinChorus with wrong role: $singerRole") + } + } + mRtcEngine.leaveChannelEx(singChannelRtcConnection) + } + + // ------------------ inner -------------------- + + private fun isChorusCoSinger(): Boolean { + return singerRole == KTVSingRole.CoSinger + } + + private fun sendStreamMessageWithJsonObject( + obj: JSONObject, + success: (isSendSuccess: Boolean) -> Unit + ) { + val ret = mRtcEngine.sendStreamMessageEx(innerDataStreamId, obj.toString().toByteArray(), singChannelRtcConnection) + if (ret == 0) { + success.invoke(true) + } else { + ktvApiLogError("sendStreamMessageWithJsonObject failed: $ret, innerDataStreamId:$innerDataStreamId") + } + } + + private fun syncPlayState( + state: Constants.MediaPlayerState, + error: Constants.MediaPlayerReason + ) { + val msg: MutableMap = HashMap() + msg["cmd"] = "PlayerState" + msg["state"] = Constants.MediaPlayerState.getValue(state) + msg["error"] = Constants.MediaPlayerReason.getValue(error) + val jsonMsg = JSONObject(msg) + sendStreamMessageWithJsonObject(jsonMsg) {} + } + + private fun syncPlayProgress(time: Long) { + val msg: MutableMap = HashMap() + msg["cmd"] = "Seek" + msg["position"] = time + val jsonMsg = JSONObject(msg) + sendStreamMessageWithJsonObject(jsonMsg) {} + } + + // ------------------ 歌词播放、同步 ------------------ + private fun startDisplayLrc() { + ktvApiLog("startDisplayLrc called") + mStopDisplayLrc = false + displayLrcFuture = scheduledThreadPool.scheduleAtFixedRate(displayLrcTask, 0,20, TimeUnit.MILLISECONDS) + } + + // 停止播放歌词 + private fun stopDisplayLrc() { + ktvApiLog("stopDisplayLrc called") + mStopDisplayLrc = true + displayLrcFuture?.cancel(true) + displayLrcFuture = null + if (scheduledThreadPool is ScheduledThreadPoolExecutor) { + scheduledThreadPool.remove(displayLrcTask) + } + } + + // ------------------ 评分驱动混音同步 ------------------ + private fun sendSyncScore() { + val jsonObject = JSONObject() + jsonObject.put("service", "audio_smart_mixer") // data message的目标消费者(服务)名 + jsonObject.put("version", "V1") //协议版本号(而非服务版本号) + val payloadJson = JSONObject() + payloadJson.put("cname", giantChorusApiConfig.chorusChannelName) // 频道名,演唱频道 + payloadJson.put("uid", giantChorusApiConfig.localUid.toString()) // 自己的uid + payloadJson.put("uLv", -1) //user-leve1(用户级别,若无则为 -1,Level 越高,越重要) + payloadJson.put("specialLabel", 0) //0: default-mode ,1:这个用户需要被排除出智能混音 + payloadJson.put("audioRoute", audioRouting) //音频路由:监听 onAudioRouteChanged + payloadJson.put("vocalScore", singingScore) //单句打分 + jsonObject.put("payload", payloadJson) + ktvApiLog("sendSyncScore: $jsonObject") + sendStreamMessageWithJsonObject(jsonObject) {} + } + + // 开始发送分数 3s/次 + private fun startSyncScore() { + mStopSyncScore = false + mSyncScoreFuture = scheduledThreadPool.scheduleAtFixedRate(mSyncScoreTask, 0, 3000, TimeUnit.MILLISECONDS) + } + + // 停止发送分数 + private fun stopSyncScore() { + mStopSyncScore = true + singingScore = 0 + + mSyncScoreFuture?.cancel(true) + mSyncScoreFuture = null + if (scheduledThreadPool is ScheduledThreadPoolExecutor) { + scheduledThreadPool.remove(mSyncScoreTask) + } + } + + // ------------------ 云端合流信息同步 ------------------ + private fun sendSyncCloudConvergenceStatus() { + val jsonObject = JSONObject() + jsonObject.put("service", "audio_smart_mixer_status") // data message的目标消费者(服务)名 + jsonObject.put("version", "V1") //协议版本号(而非服务版本号) + val payloadJson = JSONObject() + payloadJson.put("Ts", getNtpTimeInMs()) // NTP 时间 + payloadJson.put("cname", giantChorusApiConfig.chorusChannelName) // 频道名 + payloadJson.put("status", getCloudConvergenceStatus()) //(-1: unknown,0:非K歌状态,1:K歌播放状态,2:K歌暂停状态) + payloadJson.put("bgmUID", mpkConnection?.localUid.toString()) // mpk流的uid + payloadJson.put("leadsingerUID", mainSingerUid.toString()) //("-1" = unknown) //主唱Uid + jsonObject.put("payload", payloadJson) + ktvApiLog("sendSyncCloudConvergenceStatus: $jsonObject") + sendStreamMessageWithJsonObject(jsonObject) {} + } + + // -1: unknown,0:非K歌状态,1:K歌播放状态,2:K歌暂停状态) + private fun getCloudConvergenceStatus(): Int { + var status = -1 + when (this.mediaPlayerState) { + MediaPlayerState.PLAYER_STATE_PLAYING -> status = 1 + MediaPlayerState.PLAYER_STATE_PAUSED -> status = 2 + else -> {} + } + return status + } + + // 开始发送分数 200ms/次 + private fun startSyncCloudConvergenceStatus() { + mStopSyncCloudConvergenceStatus = false + mSyncCloudConvergenceStatusFuture = scheduledThreadPool.scheduleAtFixedRate(mSyncCloudConvergenceStatusTask, 0, 200,TimeUnit.MILLISECONDS) + } + + // 停止发送分数 + private fun stopSyncCloudConvergenceStatus() { + mStopSyncCloudConvergenceStatus = true + + mSyncCloudConvergenceStatusFuture?.cancel(true) + mSyncCloudConvergenceStatusFuture = null + if (scheduledThreadPool is ScheduledThreadPoolExecutor) { + scheduledThreadPool.remove(mSyncCloudConvergenceStatusTask) + } + } + + // ------------------ 延迟选路 ------------------ + private var mStopProcessDelay = true + + private val mProcessDelayTask = Runnable { + if (!mStopProcessDelay && singerRole != KTVSingRole.Audience) { + val n = if (singerRole == KTVSingRole.LeadSinger) KTVApi.routeSelectionConfig.streamNum else KTVApi.routeSelectionConfig.streamNum -1 + val sortedEntries = subScribeSingerMap.entries.sortedBy { it.value } + val other = sortedEntries.drop(3) + val drop = mutableListOf() + if (n > 3) { + other.drop(n - 3).forEach { (uid, _) -> + drop.add(uid) + mRtcEngine.muteRemoteAudioStreamEx(uid, true, singChannelRtcConnection) + subScribeSingerMap.remove(uid) + } + } + ktvApiLog("选路重新订阅, drop:$drop") + + val filteredList = singerList.filter { !subScribeSingerMap.containsKey(it) } + val filteredList2 = filteredList.filter { !drop.contains(it) } + val shuffledList = filteredList2.shuffled() + if (subScribeSingerMap.size < 8) { + val randomSingers = shuffledList.take(8 - subScribeSingerMap.size) + ktvApiLog("选路重新订阅, newSingers:$randomSingers") + for (singer in randomSingers) { + subScribeSingerMap[singer] = 0 + mRtcEngine.muteRemoteAudioStreamEx(singer, false, singChannelRtcConnection) + } + } + ktvApiLog("选路重新订阅, newSubScribeSingerMap:$subScribeSingerMap") + } + } + + private val mProcessSubscribeTask = Runnable { + if (!mStopProcessDelay && singerRole != KTVSingRole.Audience) { + val n = if (singerRole == KTVSingRole.LeadSinger) KTVApi.routeSelectionConfig.streamNum else KTVApi.routeSelectionConfig.streamNum -1 + val sortedEntries = subScribeSingerMap.entries.sortedBy { it.value } + val mustToHave = sortedEntries.take(3) + mustToHave.forEach { (uid, _) -> + mRtcEngine.adjustUserPlaybackSignalVolumeEx(uid, 100, singChannelRtcConnection) + } + val other = sortedEntries.drop(3) + if (n > 3) { + other.take(n - 3).forEach { (uid, delay) -> + if (delay > 300) { + mRtcEngine.adjustUserPlaybackSignalVolumeEx(uid, 0, singChannelRtcConnection) + } else { + mRtcEngine.adjustUserPlaybackSignalVolumeEx(uid, 100, singChannelRtcConnection) + } + } + other.drop(n - 3).forEach { (uid, _) -> + mRtcEngine.adjustUserPlaybackSignalVolumeEx(uid, 0, singChannelRtcConnection) + } + } + + ktvApiLog("选路排序+调整播放音量, mustToHave:$mustToHave, other:$other") + } + } + + private var mProcessDelayFuture :ScheduledFuture<*>? = null + private var mProcessSubscribeFuture :ScheduledFuture<*>? = null + private fun startProcessDelay() { + if (KTVApi.routeSelectionConfig.type == GiantChorusRouteSelectionType.TOP_N || KTVApi.routeSelectionConfig.type == GiantChorusRouteSelectionType.RANDOM) return + mStopProcessDelay = false + mProcessDelayFuture = scheduledThreadPool.scheduleAtFixedRate(mProcessDelayTask, 10000, 20000, TimeUnit.MILLISECONDS) + mProcessSubscribeFuture = scheduledThreadPool.scheduleAtFixedRate(mProcessSubscribeTask,15000,20000, TimeUnit.MILLISECONDS) + } + + private fun stopProcessDelay() { + mStopProcessDelay = true + + mProcessDelayFuture?.cancel(true) + mProcessSubscribeFuture?.cancel(true) + mProcessDelayFuture = null + if (scheduledThreadPool is ScheduledThreadPoolExecutor) { + scheduledThreadPool.remove(mProcessDelayTask) + scheduledThreadPool.remove(mProcessSubscribeTask) + } + } + + private fun loadLyric(songNo: Long, onLoadLyricCallback: (songNo: Long, lyricUrl: String?) -> Unit) { + ktvApiLog("loadLyric: $songNo") + val requestId = mMusicCenter.getLyric(songNo, 0) + if (requestId.isEmpty()) { + onLoadLyricCallback.invoke(songNo, null) + return + } + lyricSongCodeMap[requestId] = songNo + lyricCallbackMap[requestId] = onLoadLyricCallback + } + + private fun preLoadMusic(songNo: Long, onLoadMusicCallback: (songCode: Long, + percent: Int, + status: Int, + msg: String?, + lyricUrl: String?) -> Unit) { + ktvApiLog("loadMusic: $songNo") + val ret = mMusicCenter.isPreloaded(songNo) + if (ret == 0) { + loadMusicCallbackMap.remove(songNo.toString()) + onLoadMusicCallback(songNo, 100, 0, null, null) + return + } + + val retPreload = mMusicCenter.preload(songNo, null) + if (retPreload != 0) { + ktvApiLogError("preLoadMusic failed: $retPreload") + loadMusicCallbackMap.remove(songNo.toString()) + onLoadMusicCallback(songNo, 100, 1, null, null) + return + } + loadMusicCallbackMap[songNo.toString()] = onLoadMusicCallback + } + + private fun getNtpTimeInMs(): Long { + val currentNtpTime = mRtcEngine.ntpWallTimeInMs + return if (currentNtpTime != 0L) { + currentNtpTime + 2208988800L * 1000 + } else { + ktvApiLogError("getNtpTimeInMs DeviceDelay is zero!!!") + System.currentTimeMillis() + } + } + + private fun runOnMainThread(r: Runnable) { + if (Thread.currentThread() == mainHandler.looper.thread) { + r.run() + } else { + mainHandler.post(r) + } + } + + // ------------------------ AgoraRtcEvent ------------------------ + private fun dealWithStreamMessage(uid: Int, streamId: Int, data: ByteArray?) { + val jsonMsg: JSONObject + val messageData = data ?: return + try { + val strMsg = String(messageData) + jsonMsg = JSONObject(strMsg) + if (!jsonMsg.has("cmd")) return + if (jsonMsg.getString("cmd") == "setLrcTime") { //同步歌词 + val position = jsonMsg.getLong("time") + val realPosition = jsonMsg.getLong("realTime") + val duration = jsonMsg.getLong("duration") + val remoteNtp = jsonMsg.getLong("ntp") + val songId = jsonMsg.getString("songIdentifier") + val mpkState = jsonMsg.getInt("playerState") + + if (isChorusCoSinger()) { + // 本地BGM校准逻辑 + if (this.mediaPlayerState == MediaPlayerState.PLAYER_STATE_OPEN_COMPLETED) { + // 合唱者开始播放音乐前调小远端人声 + mRtcEngine.adjustPlaybackSignalVolume(KTVApi.remoteVolume) + // 收到leadSinger第一次播放位置消息时开启本地播放(先通过seek校准) + val delta = getNtpTimeInMs() - remoteNtp + val expectPosition = position + delta + audioPlayoutDelay + if (expectPosition in 1 until duration) { + mPlayer.seek(expectPosition) + } + mPlayer.play() + } else if (this.mediaPlayerState == MediaPlayerState.PLAYER_STATE_PLAYING) { + val localNtpTime = getNtpTimeInMs() + val localPosition = + localNtpTime - this.localPlayerSystemTime + this.localPlayerPosition // 当前副唱的播放时间 + val expectPosition = + localNtpTime - remoteNtp + position + audioPlayoutDelay // 实际主唱的播放时间 + val diff = expectPosition - localPosition + if (KTVApi.debugMode) { + ktvApiLog("play_status_seek: " + diff + " audioPlayoutDelay:" + audioPlayoutDelay + " localNtpTime: " + localNtpTime + " expectPosition: " + expectPosition + + " localPosition: " + localPosition + " ntp diff: " + (localNtpTime - remoteNtp)) + } + if ((diff > 50 || diff < -50) && expectPosition < duration) { //设置阈值为50ms,避免频繁seek + mPlayer.seek(expectPosition) + } + } else { + mLastReceivedPlayPosTime = System.currentTimeMillis() + mReceivedPlayPosition = realPosition + } + + if (MediaPlayerState.getStateByValue(mpkState) != this.mediaPlayerState) { + when (MediaPlayerState.getStateByValue(mpkState)) { + MediaPlayerState.PLAYER_STATE_PAUSED -> { + mPlayer.pause() + } + MediaPlayerState.PLAYER_STATE_PLAYING -> { + mPlayer.resume() + } + else -> {} + } + } + } else { + // 独唱观众 + if (jsonMsg.has("ver")) { + recvFromDataStream = false + } else { + recvFromDataStream = true + if (this.songIdentifier == songId) { + mLastReceivedPlayPosTime = System.currentTimeMillis() + mReceivedPlayPosition = realPosition + } else { + mLastReceivedPlayPosTime = null + mReceivedPlayPosition = 0 + } + } + } + } else if (jsonMsg.getString("cmd") == "Seek") { + // 伴唱收到原唱seek指令 + if (isChorusCoSinger()) { + val position = jsonMsg.getLong("position") + mPlayer.seek(position) + } + } else if (jsonMsg.getString("cmd") == "PlayerState") { + // 其他端收到原唱seek指令 + val state = jsonMsg.getInt("state") + val error = jsonMsg.getInt("error") + ktvApiLog("onStreamMessage PlayerState: $state") + if (isChorusCoSinger()) { + when (MediaPlayerState.getStateByValue(state)) { + MediaPlayerState.PLAYER_STATE_PAUSED -> { + mPlayer.pause() + } + MediaPlayerState.PLAYER_STATE_PLAYING -> { + mPlayer.resume() + } + else -> {} + } + } else if (this.singerRole == KTVSingRole.Audience) { + this.mediaPlayerState = MediaPlayerState.getStateByValue(state) + } + ktvApiEventHandlerList.forEach { it.onMusicPlayerStateChanged( + MediaPlayerState.getStateByValue(state), + Constants.MediaPlayerReason.getErrorByValue(error), + false + ) } + } else if (jsonMsg.getString("cmd") == "setVoicePitch") { + val pitch = jsonMsg.getDouble("pitch") + if (this.singerRole == KTVSingRole.Audience) { + this.pitch = pitch + } + } + } catch (exp: JSONException) { + ktvApiLogError("onStreamMessage:$exp") + } + } + + private fun dealWithAudioMetadata(uid: Int, data: ByteArray?) { + val messageData = data ?: return + val lrcTime = LrcTimeOuterClass.LrcTime.parseFrom(messageData) + if (lrcTime.type == LrcTimeOuterClass.MsgType.LRC_TIME) { //同步歌词 + val realPosition = lrcTime.ts + val songId = lrcTime.songId + val curTs = if (this.songIdentifier == songId) realPosition else 0 + runOnMainThread { + lrcView?.onUpdatePitch(pitch.toFloat()) + // (fix ENT-489)Make lyrics delay for 200ms + // Per suggestion from Bob, it has a intrinsic buffer/delay between sound and `onPositionChanged(Player)`, + // such as AEC/Player/Device buffer. + // We choose the estimated 200ms. + lrcView?.onUpdateProgress(if (curTs > 200) (curTs - 200) else curTs) // The delay here will impact both singer and audience side + } + } + } + + // ------------------------ AgoraMusicContentCenterEventDelegate ------------------------ + override fun onPreLoadEvent( + requestId: String?, + songCode: Long, + percent: Int, + lyricUrl: String?, + status: Int, + errorCode: Int + ) { + val callback = loadMusicCallbackMap[songCode.toString()] ?: return + if (status == 0 || status == 1) { + loadMusicCallbackMap.remove(songCode.toString()) + } + if (errorCode == 2) { + // Token过期 + ktvApiEventHandlerList.forEach { it.onTokenPrivilegeWillExpire() } + } + callback.invoke(songCode, percent, status, RtcEngine.getErrorDescription(errorCode), lyricUrl) + } + + override fun onMusicCollectionResult( + requestId: String?, + page: Int, + pageSize: Int, + total: Int, + list: Array?, + errorCode: Int + ) { + ktvApiLog("onMusicCollectionResult, requestId: $requestId, list: $list, errorCode: $errorCode") + val id = requestId ?: return + val callback = musicCollectionCallbackMap[id] ?: return + musicCollectionCallbackMap.remove(id) + if (errorCode == 2) { + // Token过期 + ktvApiEventHandlerList.forEach { it.onTokenPrivilegeWillExpire() } + } + callback.invoke(requestId, errorCode, page, pageSize, total, list) + } + + override fun onMusicChartsResult(requestId: String?, list: Array?, errorCode: Int) { + val id = requestId ?: return + val callback = musicChartsCallbackMap[id] ?: return + musicChartsCallbackMap.remove(id) + if (errorCode == 2) { + // Token过期 + ktvApiEventHandlerList.forEach { it.onTokenPrivilegeWillExpire() } + } + callback.invoke(requestId, errorCode, list) + } + + override fun onLyricResult( + requestId: String?, + songCode: Long, + lyricUrl: String?, + errorCode: Int + ) { + val callback = lyricCallbackMap[requestId] ?: return + val songCode = lyricSongCodeMap[requestId] ?: return + lyricCallbackMap.remove(lyricUrl) + if (errorCode == 2) { + // Token过期 + ktvApiEventHandlerList.forEach { it.onTokenPrivilegeWillExpire() } + } + if (lyricUrl == null || lyricUrl.isEmpty()) { + callback(songCode, null) + return + } + callback(songCode, lyricUrl) + } + + + override fun onSongSimpleInfoResult( + requestId: String?, + songCode: Long, + simpleInfo: String, + errorCode: Int + ) {} + + // ------------------------ AgoraRtcMediaPlayerDelegate ------------------------ + private var duration: Long = 0 + override fun onPlayerStateChanged( + state: Constants.MediaPlayerState?, + reason: Constants.MediaPlayerReason? + ) { + val mediaPlayerState = state ?: return + val mediaPlayerError = reason ?: return + ktvApiLog("onPlayerStateChanged called, state: $mediaPlayerState, error: $mediaPlayerError") + this.mediaPlayerState = mediaPlayerState + when (mediaPlayerState) { + MediaPlayerState.PLAYER_STATE_OPEN_COMPLETED -> { + duration = mPlayer.duration + this.localPlayerPosition = 0 + // 伴奏 + mPlayer.selectMultiAudioTrack(1, 1) + if (this.singerRole == KTVSingRole.SoloSinger || + this.singerRole == KTVSingRole.LeadSinger + ) { + mPlayer.play() + } + startProcessDelay() + } + MediaPlayerState.PLAYER_STATE_PLAYING -> { + mRtcEngine.adjustPlaybackSignalVolume(KTVApi.remoteVolume) + } + MediaPlayerState.PLAYER_STATE_PAUSED -> { + mRtcEngine.adjustPlaybackSignalVolume(100) + } + MediaPlayerState.PLAYER_STATE_STOPPED -> { + mRtcEngine.adjustPlaybackSignalVolume(100) + duration = 0 + stopProcessDelay() + } + else -> {} + } + + if (this.singerRole == KTVSingRole.SoloSinger || this.singerRole == KTVSingRole.LeadSinger) { + syncPlayState(mediaPlayerState, mediaPlayerError) + } + ktvApiEventHandlerList.forEach { it.onMusicPlayerStateChanged(mediaPlayerState, mediaPlayerError, true) } + } + + // 同步播放进度 + override fun onPositionChanged(position_ms: Long, timestamp_ms: Long) { + localPlayerPosition = position_ms + localPlayerSystemTime = timestamp_ms + + if ((this.singerRole == KTVSingRole.SoloSinger || this.singerRole == KTVSingRole.LeadSinger) && position_ms > audioPlayoutDelay) { + val msg: MutableMap = HashMap() + msg["cmd"] = "setLrcTime" + msg["ntp"] = timestamp_ms + msg["duration"] = duration + msg["time"] = + position_ms - audioPlayoutDelay // "position-audioDeviceDelay" 是计算出当前播放的真实进度 + msg["realTime"] = position_ms + msg["playerState"] = MediaPlayerState.getValue(this.mediaPlayerState) + msg["pitch"] = pitch + msg["songIdentifier"] = songIdentifier + msg["forward"] = true + msg["ver"] = lyricSyncVersion + val jsonMsg = JSONObject(msg) + sendStreamMessageWithJsonObject(jsonMsg) {} + } + + if (this.singerRole != KTVSingRole.Audience) { + mLastReceivedPlayPosTime = System.currentTimeMillis() + mReceivedPlayPosition = position_ms + } else { + mLastReceivedPlayPosTime = null + mReceivedPlayPosition = 0 + } + } + + override fun onPlayerEvent( + eventCode: Constants.MediaPlayerEvent?, + elapsedTime: Long, + message: String? + ) { + } + + override fun onMetaData(type: Constants.MediaPlayerMetadataType?, data: ByteArray?) {} + + override fun onPlayBufferUpdated(playCachedBuffer: Long) {} + + override fun onPreloadEvent(src: String?, event: Constants.MediaPlayerPreloadEvent?) {} + + override fun onAgoraCDNTokenWillExpire() {} + + override fun onPlayerSrcInfoChanged(from: SrcInfo?, to: SrcInfo?) {} + + override fun onPlayerInfoUpdated(info: PlayerUpdatedInfo?) {} + + override fun onPlayerCacheStats(stats: CacheStatistics?) {} + + override fun onPlayerPlaybackStats(stats: PlayerPlaybackStats?) {} + + override fun onAudioVolumeIndication(volume: Int) {} +} \ No newline at end of file diff --git a/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/LrcTimeOuterClass.java b/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/LrcTimeOuterClass.java new file mode 100644 index 0000000..9e69709 --- /dev/null +++ b/KTVAPI/Android/lib_ktvapi/src/main/java/io/agora/ktvapi/LrcTimeOuterClass.java @@ -0,0 +1,1042 @@ +package io.agora.ktvapi;// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: LrcTime.proto + +public final class LrcTimeOuterClass { + private LrcTimeOuterClass() {} + public static void registerAllExtensions( + com.google.protobuf.ExtensionRegistryLite registry) { + } + + public static void registerAllExtensions( + com.google.protobuf.ExtensionRegistry registry) { + registerAllExtensions( + (com.google.protobuf.ExtensionRegistryLite) registry); + } + /** + * Protobuf enum {@code MsgType} + */ + public enum MsgType + implements com.google.protobuf.ProtocolMessageEnum { + /** + * UNKNOWN_TYPE = 0; + */ + UNKNOWN_TYPE(0), + /** + * LRC_TIME = 1001; + */ + LRC_TIME(1001), + UNRECOGNIZED(-1), + ; + + /** + * UNKNOWN_TYPE = 0; + */ + public static final int UNKNOWN_TYPE_VALUE = 0; + /** + * LRC_TIME = 1001; + */ + public static final int LRC_TIME_VALUE = 1001; + + + public final int getNumber() { + if (this == UNRECOGNIZED) { + throw new IllegalArgumentException( + "Can't get the number of an unknown enum value."); + } + return value; + } + + /** + * @param value The numeric wire value of the corresponding enum entry. + * @return The enum associated with the given numeric wire value. + * @deprecated Use {@link #forNumber(int)} instead. + */ + @Deprecated + public static MsgType valueOf(int value) { + return forNumber(value); + } + + /** + * @param value The numeric wire value of the corresponding enum entry. + * @return The enum associated with the given numeric wire value. + */ + public static MsgType forNumber(int value) { + switch (value) { + case 0: return UNKNOWN_TYPE; + case 1001: return LRC_TIME; + default: return null; + } + } + + public static com.google.protobuf.Internal.EnumLiteMap + internalGetValueMap() { + return internalValueMap; + } + private static final com.google.protobuf.Internal.EnumLiteMap< + MsgType> internalValueMap = + new com.google.protobuf.Internal.EnumLiteMap() { + public MsgType findValueByNumber(int number) { + return MsgType.forNumber(number); + } + }; + + public final com.google.protobuf.Descriptors.EnumValueDescriptor + getValueDescriptor() { + if (this == UNRECOGNIZED) { + throw new IllegalStateException( + "Can't get the descriptor of an unrecognized enum value."); + } + return getDescriptor().getValues().get(ordinal()); + } + public final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptorForType() { + return getDescriptor(); + } + public static final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptor() { + return LrcTimeOuterClass.getDescriptor().getEnumTypes().get(0); + } + + private static final MsgType[] VALUES = values(); + + public static MsgType valueOf( + com.google.protobuf.Descriptors.EnumValueDescriptor desc) { + if (desc.getType() != getDescriptor()) { + throw new IllegalArgumentException( + "EnumValueDescriptor is not for this type."); + } + if (desc.getIndex() == -1) { + return UNRECOGNIZED; + } + return VALUES[desc.getIndex()]; + } + + private final int value; + + private MsgType(int value) { + this.value = value; + } + + // @@protoc_insertion_point(enum_scope:MsgType) + } + + public interface LrcTimeOrBuilder extends + // @@protoc_insertion_point(interface_extends:LrcTime) + com.google.protobuf.MessageOrBuilder { + + /** + * .MsgType type = 1; + * @return The enum numeric value on the wire for type. + */ + int getTypeValue(); + /** + * .MsgType type = 1; + * @return The type. + */ + MsgType getType(); + + /** + * bool forward = 2; + * @return The forward. + */ + boolean getForward(); + + /** + * int64 ts = 3; + * @return The ts. + */ + long getTs(); + + /** + * string songId = 4; + * @return The songId. + */ + String getSongId(); + /** + * string songId = 4; + * @return The bytes for songId. + */ + com.google.protobuf.ByteString + getSongIdBytes(); + + /** + * int32 uid = 5; + * @return The uid. + */ + int getUid(); + } + /** + * Protobuf type {@code LrcTime} + */ + public static final class LrcTime extends + com.google.protobuf.GeneratedMessageV3 implements + // @@protoc_insertion_point(message_implements:LrcTime) + LrcTimeOrBuilder { + private static final long serialVersionUID = 0L; + // Use LrcTime.newBuilder() to construct. + private LrcTime(com.google.protobuf.GeneratedMessageV3.Builder builder) { + super(builder); + } + private LrcTime() { + type_ = 0; + songId_ = ""; + } + + @Override + @SuppressWarnings({"unused"}) + protected Object newInstance( + UnusedPrivateParameter unused) { + return new LrcTime(); + } + + @Override + public final com.google.protobuf.UnknownFieldSet + getUnknownFields() { + return this.unknownFields; + } + private LrcTime( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + this(); + if (extensionRegistry == null) { + throw new NullPointerException(); + } + com.google.protobuf.UnknownFieldSet.Builder unknownFields = + com.google.protobuf.UnknownFieldSet.newBuilder(); + try { + boolean done = false; + while (!done) { + int tag = input.readTag(); + switch (tag) { + case 0: + done = true; + break; + case 8: { + int rawValue = input.readEnum(); + + type_ = rawValue; + break; + } + case 16: { + + forward_ = input.readBool(); + break; + } + case 24: { + + ts_ = input.readInt64(); + break; + } + case 34: { + String s = input.readStringRequireUtf8(); + + songId_ = s; + break; + } + case 40: { + + uid_ = input.readInt32(); + break; + } + default: { + if (!parseUnknownField( + input, unknownFields, extensionRegistry, tag)) { + done = true; + } + break; + } + } + } + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.setUnfinishedMessage(this); + } catch (java.io.IOException e) { + throw new com.google.protobuf.InvalidProtocolBufferException( + e).setUnfinishedMessage(this); + } finally { + this.unknownFields = unknownFields.build(); + makeExtensionsImmutable(); + } + } + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return LrcTimeOuterClass.internal_static_LrcTime_descriptor; + } + + @Override + protected FieldAccessorTable + internalGetFieldAccessorTable() { + return LrcTimeOuterClass.internal_static_LrcTime_fieldAccessorTable + .ensureFieldAccessorsInitialized( + LrcTime.class, Builder.class); + } + + public static final int TYPE_FIELD_NUMBER = 1; + private int type_; + /** + * .MsgType type = 1; + * @return The enum numeric value on the wire for type. + */ + @Override public int getTypeValue() { + return type_; + } + /** + * .MsgType type = 1; + * @return The type. + */ + @Override public MsgType getType() { + @SuppressWarnings("deprecation") + MsgType result = MsgType.valueOf(type_); + return result == null ? MsgType.UNRECOGNIZED : result; + } + + public static final int FORWARD_FIELD_NUMBER = 2; + private boolean forward_; + /** + * bool forward = 2; + * @return The forward. + */ + @Override + public boolean getForward() { + return forward_; + } + + public static final int TS_FIELD_NUMBER = 3; + private long ts_; + /** + * int64 ts = 3; + * @return The ts. + */ + @Override + public long getTs() { + return ts_; + } + + public static final int SONGID_FIELD_NUMBER = 4; + private volatile Object songId_; + /** + * string songId = 4; + * @return The songId. + */ + @Override + public String getSongId() { + Object ref = songId_; + if (ref instanceof String) { + return (String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + String s = bs.toStringUtf8(); + songId_ = s; + return s; + } + } + /** + * string songId = 4; + * @return The bytes for songId. + */ + @Override + public com.google.protobuf.ByteString + getSongIdBytes() { + Object ref = songId_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (String) ref); + songId_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int UID_FIELD_NUMBER = 5; + private int uid_; + /** + * int32 uid = 5; + * @return The uid. + */ + @Override + public int getUid() { + return uid_; + } + + private byte memoizedIsInitialized = -1; + @Override + public final boolean isInitialized() { + byte isInitialized = memoizedIsInitialized; + if (isInitialized == 1) return true; + if (isInitialized == 0) return false; + + memoizedIsInitialized = 1; + return true; + } + + @Override + public void writeTo(com.google.protobuf.CodedOutputStream output) + throws java.io.IOException { + if (type_ != MsgType.UNKNOWN_TYPE.getNumber()) { + output.writeEnum(1, type_); + } + if (forward_ != false) { + output.writeBool(2, forward_); + } + if (ts_ != 0L) { + output.writeInt64(3, ts_); + } + if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(songId_)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 4, songId_); + } + if (uid_ != 0) { + output.writeInt32(5, uid_); + } + unknownFields.writeTo(output); + } + + @Override + public int getSerializedSize() { + int size = memoizedSize; + if (size != -1) return size; + + size = 0; + if (type_ != MsgType.UNKNOWN_TYPE.getNumber()) { + size += com.google.protobuf.CodedOutputStream + .computeEnumSize(1, type_); + } + if (forward_ != false) { + size += com.google.protobuf.CodedOutputStream + .computeBoolSize(2, forward_); + } + if (ts_ != 0L) { + size += com.google.protobuf.CodedOutputStream + .computeInt64Size(3, ts_); + } + if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(songId_)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(4, songId_); + } + if (uid_ != 0) { + size += com.google.protobuf.CodedOutputStream + .computeInt32Size(5, uid_); + } + size += unknownFields.getSerializedSize(); + memoizedSize = size; + return size; + } + + @Override + public boolean equals(final Object obj) { + if (obj == this) { + return true; + } + if (!(obj instanceof LrcTime)) { + return super.equals(obj); + } + LrcTime other = (LrcTime) obj; + + if (type_ != other.type_) return false; + if (getForward() + != other.getForward()) return false; + if (getTs() + != other.getTs()) return false; + if (!getSongId() + .equals(other.getSongId())) return false; + if (getUid() + != other.getUid()) return false; + if (!unknownFields.equals(other.unknownFields)) return false; + return true; + } + + @Override + public int hashCode() { + if (memoizedHashCode != 0) { + return memoizedHashCode; + } + int hash = 41; + hash = (19 * hash) + getDescriptor().hashCode(); + hash = (37 * hash) + TYPE_FIELD_NUMBER; + hash = (53 * hash) + type_; + hash = (37 * hash) + FORWARD_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean( + getForward()); + hash = (37 * hash) + TS_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashLong( + getTs()); + hash = (37 * hash) + SONGID_FIELD_NUMBER; + hash = (53 * hash) + getSongId().hashCode(); + hash = (37 * hash) + UID_FIELD_NUMBER; + hash = (53 * hash) + getUid(); + hash = (29 * hash) + unknownFields.hashCode(); + memoizedHashCode = hash; + return hash; + } + + public static LrcTime parseFrom( + java.nio.ByteBuffer data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static LrcTime parseFrom( + java.nio.ByteBuffer data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static LrcTime parseFrom( + com.google.protobuf.ByteString data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static LrcTime parseFrom( + com.google.protobuf.ByteString data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static LrcTime parseFrom(byte[] data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static LrcTime parseFrom( + byte[] data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static LrcTime parseFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input); + } + public static LrcTime parseFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input, extensionRegistry); + } + public static LrcTime parseDelimitedFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseDelimitedWithIOException(PARSER, input); + } + public static LrcTime parseDelimitedFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseDelimitedWithIOException(PARSER, input, extensionRegistry); + } + public static LrcTime parseFrom( + com.google.protobuf.CodedInputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input); + } + public static LrcTime parseFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input, extensionRegistry); + } + + @Override + public Builder newBuilderForType() { return newBuilder(); } + public static Builder newBuilder() { + return DEFAULT_INSTANCE.toBuilder(); + } + public static Builder newBuilder(LrcTime prototype) { + return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); + } + @Override + public Builder toBuilder() { + return this == DEFAULT_INSTANCE + ? new Builder() : new Builder().mergeFrom(this); + } + + @Override + protected Builder newBuilderForType( + BuilderParent parent) { + Builder builder = new Builder(parent); + return builder; + } + /** + * Protobuf type {@code LrcTime} + */ + public static final class Builder extends + com.google.protobuf.GeneratedMessageV3.Builder implements + // @@protoc_insertion_point(builder_implements:LrcTime) + LrcTimeOrBuilder { + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return LrcTimeOuterClass.internal_static_LrcTime_descriptor; + } + + @Override + protected FieldAccessorTable + internalGetFieldAccessorTable() { + return LrcTimeOuterClass.internal_static_LrcTime_fieldAccessorTable + .ensureFieldAccessorsInitialized( + LrcTime.class, Builder.class); + } + + // Construct using LrcTimeOuterClass.LrcTime.newBuilder() + private Builder() { + maybeForceBuilderInitialization(); + } + + private Builder( + BuilderParent parent) { + super(parent); + maybeForceBuilderInitialization(); + } + private void maybeForceBuilderInitialization() { + if (com.google.protobuf.GeneratedMessageV3 + .alwaysUseFieldBuilders) { + } + } + @Override + public Builder clear() { + super.clear(); + type_ = 0; + + forward_ = false; + + ts_ = 0L; + + songId_ = ""; + + uid_ = 0; + + return this; + } + + @Override + public com.google.protobuf.Descriptors.Descriptor + getDescriptorForType() { + return LrcTimeOuterClass.internal_static_LrcTime_descriptor; + } + + @Override + public LrcTime getDefaultInstanceForType() { + return LrcTime.getDefaultInstance(); + } + + @Override + public LrcTime build() { + LrcTime result = buildPartial(); + if (!result.isInitialized()) { + throw newUninitializedMessageException(result); + } + return result; + } + + @Override + public LrcTime buildPartial() { + LrcTime result = new LrcTime(this); + result.type_ = type_; + result.forward_ = forward_; + result.ts_ = ts_; + result.songId_ = songId_; + result.uid_ = uid_; + onBuilt(); + return result; + } + + @Override + public Builder clone() { + return super.clone(); + } + @Override + public Builder setField( + com.google.protobuf.Descriptors.FieldDescriptor field, + Object value) { + return super.setField(field, value); + } + @Override + public Builder clearField( + com.google.protobuf.Descriptors.FieldDescriptor field) { + return super.clearField(field); + } + @Override + public Builder clearOneof( + com.google.protobuf.Descriptors.OneofDescriptor oneof) { + return super.clearOneof(oneof); + } + @Override + public Builder setRepeatedField( + com.google.protobuf.Descriptors.FieldDescriptor field, + int index, Object value) { + return super.setRepeatedField(field, index, value); + } + @Override + public Builder addRepeatedField( + com.google.protobuf.Descriptors.FieldDescriptor field, + Object value) { + return super.addRepeatedField(field, value); + } + @Override + public Builder mergeFrom(com.google.protobuf.Message other) { + if (other instanceof LrcTime) { + return mergeFrom((LrcTime)other); + } else { + super.mergeFrom(other); + return this; + } + } + + public Builder mergeFrom(LrcTime other) { + if (other == LrcTime.getDefaultInstance()) return this; + if (other.type_ != 0) { + setTypeValue(other.getTypeValue()); + } + if (other.getForward() != false) { + setForward(other.getForward()); + } + if (other.getTs() != 0L) { + setTs(other.getTs()); + } + if (!other.getSongId().isEmpty()) { + songId_ = other.songId_; + onChanged(); + } + if (other.getUid() != 0) { + setUid(other.getUid()); + } + this.mergeUnknownFields(other.unknownFields); + onChanged(); + return this; + } + + @Override + public final boolean isInitialized() { + return true; + } + + @Override + public Builder mergeFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + LrcTime parsedMessage = null; + try { + parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + parsedMessage = (LrcTime) e.getUnfinishedMessage(); + throw e.unwrapIOException(); + } finally { + if (parsedMessage != null) { + mergeFrom(parsedMessage); + } + } + return this; + } + + private int type_ = 0; + /** + * .MsgType type = 1; + * @return The enum numeric value on the wire for type. + */ + @Override public int getTypeValue() { + return type_; + } + /** + * .MsgType type = 1; + * @param value The enum numeric value on the wire for type to set. + * @return This builder for chaining. + */ + public Builder setTypeValue(int value) { + + type_ = value; + onChanged(); + return this; + } + /** + * .MsgType type = 1; + * @return The type. + */ + @Override + public MsgType getType() { + @SuppressWarnings("deprecation") + MsgType result = MsgType.valueOf(type_); + return result == null ? MsgType.UNRECOGNIZED : result; + } + /** + * .MsgType type = 1; + * @param value The type to set. + * @return This builder for chaining. + */ + public Builder setType(MsgType value) { + if (value == null) { + throw new NullPointerException(); + } + + type_ = value.getNumber(); + onChanged(); + return this; + } + /** + * .MsgType type = 1; + * @return This builder for chaining. + */ + public Builder clearType() { + + type_ = 0; + onChanged(); + return this; + } + + private boolean forward_ ; + /** + * bool forward = 2; + * @return The forward. + */ + @Override + public boolean getForward() { + return forward_; + } + /** + * bool forward = 2; + * @param value The forward to set. + * @return This builder for chaining. + */ + public Builder setForward(boolean value) { + + forward_ = value; + onChanged(); + return this; + } + /** + * bool forward = 2; + * @return This builder for chaining. + */ + public Builder clearForward() { + + forward_ = false; + onChanged(); + return this; + } + + private long ts_ ; + /** + * int64 ts = 3; + * @return The ts. + */ + @Override + public long getTs() { + return ts_; + } + /** + * int64 ts = 3; + * @param value The ts to set. + * @return This builder for chaining. + */ + public Builder setTs(long value) { + + ts_ = value; + onChanged(); + return this; + } + /** + * int64 ts = 3; + * @return This builder for chaining. + */ + public Builder clearTs() { + + ts_ = 0L; + onChanged(); + return this; + } + + private Object songId_ = ""; + /** + * string songId = 4; + * @return The songId. + */ + public String getSongId() { + Object ref = songId_; + if (!(ref instanceof String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + String s = bs.toStringUtf8(); + songId_ = s; + return s; + } else { + return (String) ref; + } + } + /** + * string songId = 4; + * @return The bytes for songId. + */ + public com.google.protobuf.ByteString + getSongIdBytes() { + Object ref = songId_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (String) ref); + songId_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * string songId = 4; + * @param value The songId to set. + * @return This builder for chaining. + */ + public Builder setSongId( + String value) { + if (value == null) { + throw new NullPointerException(); + } + + songId_ = value; + onChanged(); + return this; + } + /** + * string songId = 4; + * @return This builder for chaining. + */ + public Builder clearSongId() { + + songId_ = getDefaultInstance().getSongId(); + onChanged(); + return this; + } + /** + * string songId = 4; + * @param value The bytes for songId to set. + * @return This builder for chaining. + */ + public Builder setSongIdBytes( + com.google.protobuf.ByteString value) { + if (value == null) { + throw new NullPointerException(); + } + checkByteStringIsUtf8(value); + + songId_ = value; + onChanged(); + return this; + } + + private int uid_ ; + /** + * int32 uid = 5; + * @return The uid. + */ + @Override + public int getUid() { + return uid_; + } + /** + * int32 uid = 5; + * @param value The uid to set. + * @return This builder for chaining. + */ + public Builder setUid(int value) { + + uid_ = value; + onChanged(); + return this; + } + /** + * int32 uid = 5; + * @return This builder for chaining. + */ + public Builder clearUid() { + + uid_ = 0; + onChanged(); + return this; + } + @Override + public final Builder setUnknownFields( + final com.google.protobuf.UnknownFieldSet unknownFields) { + return super.setUnknownFields(unknownFields); + } + + @Override + public final Builder mergeUnknownFields( + final com.google.protobuf.UnknownFieldSet unknownFields) { + return super.mergeUnknownFields(unknownFields); + } + + + // @@protoc_insertion_point(builder_scope:LrcTime) + } + + // @@protoc_insertion_point(class_scope:LrcTime) + private static final LrcTime DEFAULT_INSTANCE; + static { + DEFAULT_INSTANCE = new LrcTime(); + } + + public static LrcTime getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static final com.google.protobuf.Parser + PARSER = new com.google.protobuf.AbstractParser() { + @Override + public LrcTime parsePartialFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return new LrcTime(input, extensionRegistry); + } + }; + + public static com.google.protobuf.Parser parser() { + return PARSER; + } + + @Override + public com.google.protobuf.Parser getParserForType() { + return PARSER; + } + + @Override + public LrcTime getDefaultInstanceForType() { + return DEFAULT_INSTANCE; + } + + } + + private static final com.google.protobuf.Descriptors.Descriptor + internal_static_LrcTime_descriptor; + private static final + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internal_static_LrcTime_fieldAccessorTable; + + public static com.google.protobuf.Descriptors.FileDescriptor + getDescriptor() { + return descriptor; + } + private static com.google.protobuf.Descriptors.FileDescriptor + descriptor; + static { + String[] descriptorData = { + "\n\rLrcTime.proto\"[\n\007LrcTime\022\026\n\004type\030\001 \001(\016" + + "2\010.MsgType\022\017\n\007forward\030\002 \001(\010\022\n\n\002ts\030\003 \001(\003\022" + + "\016\n\006songId\030\004 \001(\t\022\013\n\003uid\030\005 \001(\005**\n\007MsgType\022" + + "\020\n\014UNKNOWN_TYPE\020\000\022\r\n\010LRC_TIME\020\351\007b\006proto3" + }; + descriptor = com.google.protobuf.Descriptors.FileDescriptor + .internalBuildGeneratedFileFrom(descriptorData, + new com.google.protobuf.Descriptors.FileDescriptor[] { + }); + internal_static_LrcTime_descriptor = + getDescriptor().getMessageTypes().get(0); + internal_static_LrcTime_fieldAccessorTable = new + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( + internal_static_LrcTime_descriptor, + new String[] { "Type", "Forward", "Ts", "SongId", "Uid", }); + } + + // @@protoc_insertion_point(outer_class_scope) +} diff --git a/KTVAPI/iOS/Classes/APIReporter.swift b/KTVAPI/iOS/Classes/APIReporter.swift new file mode 100644 index 0000000..936e59b --- /dev/null +++ b/KTVAPI/iOS/Classes/APIReporter.swift @@ -0,0 +1,159 @@ +// +// APIReporter.swift +// CallAPI +// +// Created by wushengtao on 2024/4/8. +// + +import AgoraRtcKit + + +/// 场景化类型 +public enum APIType: Int { + case ktv = 1 //K歌 + case call = 2 //呼叫 + case beauty = 3 //美颜 + case videoLoader = 4 //秒开/秒切 + case pk = 5 //团战 + case vitualSpace = 6 // + case screenSpace = 7 //屏幕共享 + case audioScenario = 8 //音频scenario +} + +enum APIEventType: Int { + case api = 0 //api事件 + case cost //耗时事件 + case custom //自定义事件 +} + +struct ApiEventKey { + static let type = "type" + static let desc = "desc" + static let apiValue = "apiValue" + static let ts = "ts" + static let ext = "ext" +} + +struct APICostEvent { + static let channelUsage = "channelUsage" //频道使用耗时 + static let firstFrameActual = "firstFrameActual" //首帧实际耗时 + static let firstFramePerceived = "firstFramePerceived" //首帧感官耗时 +} + +let formatter = DateFormatter() +func debugApiPrint(_ message: String) { +#if DEBUG + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + let timeString = formatter.string(from: Date()) + print("\(timeString) \(message)") +#endif +} + +@objcMembers +public class APIReporter: NSObject { + private var engine: AgoraRtcEngineKit + private let messsageId: String = "agora:scenarioAPI" + private var category: String + private var durationEventStartMap: [String: Int64] = [:] + + //MARK: public + public init(type: APIType, version: String, engine: AgoraRtcEngineKit) { + self.category = "\(type.rawValue)_iOS_\(version)" + self.engine = engine + super.init() + + configParameters() + } + + public func reportFuncEvent(name: String, value: [String: Any], ext: [String: Any]) { + let content = "[APIReporter]reportFuncEvent: \(name) value: \(value) ext: \(ext)" + debugApiPrint(content) + let eventMap: [String: Any] = [ApiEventKey.type: APIEventType.api.rawValue, ApiEventKey.desc: name] + let labelMap: [String: Any] = [ApiEventKey.apiValue: value, ApiEventKey.ts: getCurrentTs(), ApiEventKey.ext: ext] + let event = convertToJSONString(eventMap) ?? "" + let label = convertToJSONString(labelMap) ?? "" + engine.sendCustomReportMessage(messsageId, + category: category, + event: event, + label: label, + value: 0) + } + + public func startDurationEvent(name: String) { + durationEventStartMap[name] = getCurrentTs() + } + + public func endDurationEvent(name: String, ext: [String: Any]) { + guard let beginTs = durationEventStartMap[name] else {return} + durationEventStartMap.removeValue(forKey: name) + let ts = getCurrentTs() + let cost = Int(ts - beginTs) + + reportCostEvent(ts: ts, name: name, cost: cost, ext: ext) + } + + public func reportCostEvent(name: String, cost: Int, ext: [String: Any]) { + durationEventStartMap.removeValue(forKey: name) + reportCostEvent(ts: getCurrentTs(), name: name, cost: cost, ext: ext) + } + + public func reportCustomEvent(name: String, ext: [String: Any]) { + let content = "[APIReporter]reportCustomEvent: \(name) ext: \(ext)" + debugApiPrint(content) + let eventMap: [String: Any] = [ApiEventKey.type: APIEventType.custom.rawValue, ApiEventKey.desc: name] + let labelMap: [String: Any] = [ApiEventKey.ts: getCurrentTs(), ApiEventKey.ext: ext] + let event = convertToJSONString(eventMap) ?? "" + let label = convertToJSONString(labelMap) ?? "" + engine.sendCustomReportMessage(messsageId, + category: category, + event: event, + label: label, + value: 0) + } + + public func writeLog(content: String, level: AgoraLogLevel) { + engine.writeLog(level, content: content) + } + + public func cleanCache() { + durationEventStartMap.removeAll() + } + + //MARK: private + private func reportCostEvent(ts: Int64, name: String, cost: Int, ext: [String: Any]) { + let content = "[APIReporter]reportCostEvent: \(name) cost: \(cost) ms ext: \(ext)" + debugApiPrint(content) + writeLog(content: content, level: .info) + let eventMap: [String: Any] = [ApiEventKey.type: APIEventType.cost.rawValue, ApiEventKey.desc: name] + let labelMap: [String: Any] = [ApiEventKey.ts: ts, ApiEventKey.ext: ext] + let event = convertToJSONString(eventMap) ?? "" + let label = convertToJSONString(labelMap) ?? "" + engine.sendCustomReportMessage(messsageId, + category: category, + event: event, + label: label, + value: cost) + } + + private func configParameters() { +// engine.setParameters("{\"rtc.qos_for_test_purpose\": true}") + engine.setParameters("{\"rtc.direct_send_custom_event\": true}") + engine.setParameters("{\"rtc.log_external_input\": true}") + } + + private func getCurrentTs() -> Int64 { + return Int64(round(Date().timeIntervalSince1970 * 1000.0)) + } + + private func convertToJSONString(_ dictionary: [String: Any]) -> String? { + do { + let jsonData = try JSONSerialization.data(withJSONObject: dictionary, options: []) + if let jsonString = String(data: jsonData, encoding: .utf8) { + return jsonString + } + } catch { + writeLog(content: "[APIReporter]convert to json fail: \(error) dictionary: \(dictionary)", level: .warn) + } + return nil + } +} diff --git a/KTVAPI/iOS/Classes/KTVApi.swift b/KTVAPI/iOS/Classes/KTVApi.swift index d1fca98..16395b8 100644 --- a/KTVAPI/iOS/Classes/KTVApi.swift +++ b/KTVAPI/iOS/Classes/KTVApi.swift @@ -65,16 +65,13 @@ import AgoraRtcKit /// 加入合唱失败原因 @objc public enum KTVJoinChorusFailReason: Int { - case musicPreloadFail //歌曲预加载失败 case musicOpenFail //歌曲打开失败 case joinChannelFail //加入ex频道失败 - case musicPreloadFailAndJoinChannelFail } @objc public enum KTVType: Int { case normal case singbattle - case cantata case singRelay } @@ -88,7 +85,7 @@ import AgoraRtcKit /// - status: <#status description#> /// - msg: <#msg description#> /// - lyricUrl: <#lyricUrl description#> - func onMusicLoadProgress(songCode: Int, percent: Int, status: AgoraMusicContentCenterPreloadStatus, msg: String?, lyricUrl: String?) + func onMusicLoadProgress(songCode: Int, percent: Int, state: AgoraMusicContentCenterPreloadState, msg: String?, lyricUrl: String?) /// 歌曲加载成功 /// - Parameters: @@ -105,17 +102,6 @@ import AgoraRtcKit func onMusicLoadFail(songCode: Int, reason: KTVLoadSongFailReason) } - -//public protocol KTVJoinChorusStateListener: NSObjectProtocol { -// -// /// 加入合唱成功 -// func onJoinChorusSuccess() -// -// /// 加入合唱失败 -// /// - Parameter reason: 失败原因 -// func onJoinChorusFail(reason: KTVJoinChorusFailReason) -//} - @objc public protocol KTVLrcViewDelegate: NSObjectProtocol { func onUpdatePitch(pitch: Float) func onUpdateProgress(progress: Int) @@ -131,7 +117,7 @@ import AgoraRtcKit /// - error: <#error description#> /// - isLocal: <#isLocal description#> func onMusicPlayerStateChanged(state: AgoraMediaPlayerState, - error: AgoraMediaPlayerError, + reason: AgoraMediaPlayerReason, isLocal: Bool) @@ -160,6 +146,73 @@ import AgoraRtcKit func onMusicPlayerProgressChanged(with progress: Int) } +// 大合唱中演唱者互相收听对方音频流的选路策略 +enum GiantChorusRouteSelectionType: Int { + case random = 0 // 随机选取几条流 + case byDelay = 1 // 根据延迟选择最低的几条流 + case topN = 2 // 根据音强选流 + case byDelayAndTopN = 3 // 同时开始延迟选路和音强选流 +} + +// 大合唱中演唱者互相收听对方音频流的选路配置 +@objc public class GiantChorusRouteSelectionConfig: NSObject { + let type: GiantChorusRouteSelectionType // 选路策略 + let streamNum: Int // 最大选取的流个数(推荐6) + + init(type: GiantChorusRouteSelectionType, streamNum: Int) { + self.type = type + self.streamNum = streamNum + } +} + +@objc open class GiantChorusConfiguration: NSObject { + var appId: String + var rtmToken: String + weak var engine: AgoraRtcEngineKit? + var channelName: String + var localUid: Int = 0 + var chorusChannelName: String + var chorusChannelToken: String + var maxCacheSize: Int = 10 + var musicType: loadMusicType = .mcc + var audienceChannelToken: String = "" + var musicStreamUid: Int = 0 + var musicChannelToken: String = "" + var routeSelectionConfig: GiantChorusRouteSelectionConfig = GiantChorusRouteSelectionConfig(type: .byDelay, streamNum: 6) + var mccDomain: String? + @objc public + init(appId: String, + rtmToken: String, + engine: AgoraRtcEngineKit, + localUid: Int, + audienceChannelName: String, + audienceChannelToken: String, + chorusChannelName: String, + chorusChannelToken: String, + musicStreamUid: Int, + musicChannelToken: String, + maxCacheSize: Int, + musicType: loadMusicType, + routeSelectionConfig: GiantChorusRouteSelectionConfig, + mccDomain: String? + ) { + self.appId = appId + self.rtmToken = rtmToken + self.engine = engine + self.channelName = audienceChannelName + self.localUid = localUid + self.chorusChannelName = chorusChannelName + self.chorusChannelToken = chorusChannelToken + self.maxCacheSize = maxCacheSize + self.musicType = musicType + self.audienceChannelToken = audienceChannelToken + self.musicStreamUid = musicStreamUid + self.musicChannelToken = musicChannelToken + self.routeSelectionConfig = routeSelectionConfig + self.mccDomain = mccDomain + } +} + @objc open class KTVApiConfig: NSObject{ var appId: String var rtmToken: String @@ -171,7 +224,7 @@ import AgoraRtcKit var type: KTVType = .normal var maxCacheSize: Int = 10 var musicType: loadMusicType = .mcc - var isDebugMode: Bool = false + var mccDomain: String? @objc public init(appId: String, rtmToken: String, @@ -181,9 +234,9 @@ import AgoraRtcKit chorusChannelName: String, chorusChannelToken: String, type: KTVType, - maxCacheSize: Int, musicType: loadMusicType, - isDebugMode: Bool + maxCacheSize: Int, + mccDomain: String? ) { self.appId = appId self.rtmToken = rtmToken @@ -195,49 +248,49 @@ import AgoraRtcKit self.type = type self.maxCacheSize = maxCacheSize self.musicType = musicType - self.isDebugMode = isDebugMode + self.mccDomain = mccDomain } + + } /// 歌曲加载配置信息 @objcMembers open class KTVSongConfiguration: NSObject { public var songIdentifier: String = "" - public var autoPlay: Bool = false //是否加载完成自动播放 public var mainSingerUid: Int = 0 //主唱uid public var mode: KTVLoadMusicMode = .loadMusicAndLrc - - func printObjectContent() -> String { - var content = "" - - let mirror = Mirror(reflecting: self) - for child in mirror.children { - if let propertyName = child.label { - if let propertyValue = child.value as? CustomStringConvertible { - content += "\(propertyName): \(propertyValue)\n" - } else { - content += "\(propertyName): \(child.value)\n" - } - } - } - - return content - } + public var songCutter: Bool = false +// func printObjectContent() -> String { +// var content = "" +// +// let mirror = Mirror(reflecting: self) +// for child in mirror.children { +// if let propertyName = child.label { +// if let propertyValue = child.value as? CustomStringConvertible { +// content += "\(propertyName): \(propertyValue)\n" +// } else { +// content += "\(propertyName): \(child.value)\n" +// } +// } +// } +// +// return content +// } } public typealias LyricCallback = ((String?) -> Void) -public typealias LoadMusicCallback = ((AgoraMusicContentCenterPreloadStatus, NSInteger) -> Void) +public typealias LoadMusicCallback = ((AgoraMusicContentCenterPreloadState, NSInteger) -> Void) public typealias ISwitchRoleStateListener = (KTVSwitchRoleState, KTVSwitchRoleFailReason) -> Void -public typealias MusicChartCallBacks = (String, AgoraMusicContentCenterStatusCode, [AgoraMusicChartInfo]?) -> Void -public typealias MusicResultCallBacks = (String, AgoraMusicContentCenterStatusCode, AgoraMusicCollection) -> Void +public typealias MusicChartCallBacks = (String, AgoraMusicContentCenterStateReason, [AgoraMusicChartInfo]?) -> Void +public typealias MusicResultCallBacks = (String, AgoraMusicContentCenterStateReason, AgoraMusicCollection) -> Void public typealias JoinExChannelCallBack = ((Bool, KTVJoinChorusFailReason?)-> Void) @objc public protocol KTVApiDelegate: NSObjectProtocol { - /// 初始化 - /// - Parameter config: <#config description#> - init(config: KTVApiConfig) + @objc optional func createKtvApi(config: KTVApiConfig) //小合唱必选 + @objc optional func createKTVGiantChorusApi(config: GiantChorusConfiguration) //大合唱必选 /// 订阅KTVApi事件 /// - Parameter ktvApiEventHandler: <#ktvApiEventHandler description#> @@ -410,4 +463,6 @@ public typealias JoinExChannelCallBack = ((Bool, KTVJoinChorusFailReason?)-> Voi */ func removeMusic(songCode: Int) + + @objc func didAudioMetadataReceived( uid: UInt, metadata: Data) } diff --git a/KTVAPI/iOS/Classes/KTVApiImpl.swift b/KTVAPI/iOS/Classes/KTVApiImpl.swift index f76082f..f302e55 100644 --- a/KTVAPI/iOS/Classes/KTVApiImpl.swift +++ b/KTVAPI/iOS/Classes/KTVApiImpl.swift @@ -7,25 +7,21 @@ import Foundation import AgoraRtcKit - +import SwiftProtobuf /// 加载歌曲状态 -@objc public enum KTVLoadSongState: Int { +@objc fileprivate enum KTVLoadSongState: Int { case idle = -1 //空闲 case ok = 0 //成功 case failed //失败 case inProgress //加载中 } -enum KTVSongMode: Int { +fileprivate enum KTVSongMode: Int { case songCode case songUrl } -private func agoraPrint(_ message: String) { - print(message) -} - -@objc class KTVApiImpl: NSObject{ +@objc class KTVApiImpl: NSObject, KTVApiDelegate{ private var apiConfig: KTVApiConfig? @@ -55,6 +51,7 @@ private func agoraPrint(_ message: String) { private var startHighTime: Int = 0 private var isRelease: Bool = false private var songUrl2: String = "" + private var enableMultipathing = true private var playerState: AgoraMediaPlayerState = .idle { didSet { agoraPrint("playerState did changed: \(oldValue.rawValue)->\(playerState.rawValue)") @@ -83,6 +80,11 @@ private func agoraPrint(_ message: String) { private var songUrl: String = "" private var songCode: Int = 0 private var songIdentifier: String = "" + + private let tag = "KTV_API_LOG" + private let messageId = "agora:scenarioAPI" + private let version = "5.0.0" + private let lyricSyncVersion = 2 private var singerRole: KTVSingRole = .audience { didSet { @@ -93,24 +95,29 @@ private func agoraPrint(_ message: String) { private var timer: Timer? private var isPause: Bool = false - + private var recvFromDataStream = false public var remoteVolume: Int = 30 private var joinChorusNewRole: KTVSingRole = .audience private var oldPitch: Double = 0 private var isWearingHeadPhones: Bool = false private var enableProfessional: Bool = false private var isPublishAudio: Bool = false + private var preludeDuration: Int64 = 0 private lazy var apiDelegateHandler = KTVApiRTCDelegateHandler(with: self) + + private var totalSize: Int = 0 + + private var apiRepoter: APIReporter? + deinit { mcc?.register(nil) agoraPrint("deinit KTVApiImpl") } - - @objc required init(config: KTVApiConfig) { - super.init() - agoraPrint("init KTVApiImpl") + + @objc func createKtvApi(config: KTVApiConfig) { self.apiConfig = config + apiRepoter = APIReporter(type: .ktv, version: version, engine: apiConfig?.engine ?? AgoraRtcEngineKit()) setParams() if config.musicType == .mcc { @@ -121,11 +128,14 @@ private func agoraPrint(_ message: String) { contentCenterConfiguration.token = config.rtmToken contentCenterConfiguration.rtcEngine = config.engine contentCenterConfiguration.maxCacheSize = UInt(config.maxCacheSize) - if config.isDebugMode { - //如果这一块报错为contentCenterConfiguration没有mccDomain这个属性 说明该版本不支持这个 可以注释掉这行代码。完全不影响 - contentCenterConfiguration.mccDomain = "api-test.agora.io" + if let domain = config.mccDomain { + contentCenterConfiguration.mccDomain = domain } mcc = AgoraMusicContentCenter.sharedContentCenter(config: contentCenterConfiguration) + if mcc == nil { + agoraPrint("mcc create fail") +// assert(mcc != nil, "mcc == nil") + } mcc?.register(self) // ------------------ 初始化音乐播放器实例 ------------------ mediaPlayer = mcc?.createMusicPlayer(delegate: self) @@ -137,8 +147,11 @@ private func agoraPrint(_ message: String) { mediaPlayer?.adjustPlayoutVolume(50) mediaPlayer?.adjustPublishSignalVolume(50) } + apiConfig?.engine?.addDelegate(apiDelegateHandler) + mediaPlayer?.setPlayerOption("play_pos_change_callback", value: 100) initTimer() + agoraPrint("init KTVApiImpl") } private func setParams() { @@ -153,13 +166,24 @@ private func agoraPrint(_ message: String) { engine.setParameters("{\"che.audio.neteq.prebuffer_max_delay\": 600}") engine.setParameters("{\"che.audio.max_mixed_participants\": 8}") engine.setParameters("{\"che.audio.custom_bitrate\": 48000}") - engine.setParameters("{\"che.audio.direct.uplink_process\": false}") engine.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}") engine.setParameters("{\"che.audio.neteq.targetlevel_offset\": 20}") engine.setParameters("{\"che.audio.ans.noise_gate\": 20}") + engine.setParameters("{\"rtc.use_audio4\": true}") if apiConfig?.type == .singRelay { engine.setParameters("{\"che.audio.aiaec.working_mode\": 1}") } + + //4.3.0 add + enableMultipathing = true +// engine.setParameters("{\"rtc.enable_tds_request_on_join\": true}") +// engine.setParameters("{\"rtc.remote_path_scheduling_strategy\": 0}") + engine.setParameters("{\"rtc.path_scheduling_strategy\": 0}") + // engine.setParameters("{\"rtc.enableMultipath\": true}") + engine.setParameters("{\"rtc.log_external_input\":true}") + // 数据上报 + engine.setParameters("{\"rtc.direct_send_custom_event\": true}") + // engine.setParameters("{\"rtc.qos_for_test_purpose\": true}") } func renewInnerDataStreamId() { @@ -167,19 +191,58 @@ private func agoraPrint(_ message: String) { dataStreamConfig.ordered = false dataStreamConfig.syncWithAudio = true self.apiConfig?.engine?.createDataStream(&dataStreamId, config: dataStreamConfig) - sendCustomMessage(with: "renewInnerDataStreamId", label: "") + sendCustomMessage(with: "renewInnerDataStreamId", dict: [:]) + agoraPrint("renewInnerDataStreamId") } } //MARK: KTVApiDelegate -extension KTVApiImpl: KTVApiDelegate { +extension KTVApiImpl { + + func objectContent(of object: Any) -> [String: Any] { + var content = [String: Any]() + + let mirror = Mirror(reflecting: object) + for child in mirror.children { + if let propertyName = child.label { + if let convertibleValue = convertToJSONSerializable(child.value) { + content[propertyName] = convertibleValue + } + } + } + + return content + } + + func convertToJSONSerializable(_ value: Any) -> Any? { + switch value { + case let value as String: + return value + case let value as Int: + return value + case let value as Double: + return value + case let value as Bool: + return value + case let value as Int?: + return value + case let value as Double?: + return value + case let value as Bool?: + return value + case let value as String?: + return value + default: + return nil + } + } func getMusicContentCenter() -> AgoraMusicContentCenter? { return mcc } func setLrcView(view: KTVLrcViewDelegate) { - sendCustomMessage(with: "renewInnerDataStreamId", label: "view:\(view.description)") + sendCustomMessage(with: "setLrcView", dict: [:]) lrcControl = view } @@ -192,15 +255,14 @@ extension KTVApiImpl: KTVApiDelegate { self.songUrl = url1 self.songUrl2 = url2 - if config.autoPlay { - // 主唱自动播放歌曲 - if self.singerRole != .leadSinger { - switchSingerRole(newRole: .soloSinger) { state, failRes in - - } - } - startSing(url: url1, startPos: 0) - } +// if config.autoPlay { +// // 主唱自动播放歌曲 +// if self.singerRole != .leadSinger { +// switchSingerRole(newRole: .soloSinger) { state, failRes in +// } +// } +// startSing(url: url1, startPos: 0) + // } } //主要针对本地歌曲播放的主唱伴奏切换的 MCC直接忽视这个方法 @@ -218,8 +280,8 @@ extension KTVApiImpl: KTVApiDelegate { } func loadMusic(songCode: Int, config: KTVSongConfiguration, onMusicLoadStateListener: IMusicLoadStateListener) { - sendCustomMessage(with: "loadMusic", label: "config:\(config.printObjectContent())") - agoraPrint("loadMusic songCode:\(songCode) ") + sendCustomMessage(with: "loadMusic", dict: objectContent(of: config)) + agoraPrint("loadMusic songCode:\(songCode) mode:\(config.mode.rawValue)") self.songMode = .songCode self.songCode = songCode self.songIdentifier = config.songIdentifier @@ -227,28 +289,27 @@ extension KTVApiImpl: KTVApiDelegate { } func loadMusic(config: KTVSongConfiguration, url: String) { - sendCustomMessage(with: "loadMusic", label: "config:\(config.printObjectContent()), url:\(url)") + sendCustomMessage(with: "loadMusicWithUrl:\(url)", dict: objectContent(of: config)) self.songMode = .songUrl self.songUrl = url self.songIdentifier = config.songIdentifier - if config.autoPlay { - // 主唱自动播放歌曲 - if singerRole != .leadSinger { - switchSingerRole(newRole: .soloSinger) { _, _ in - - } - } - startSing(url: url, startPos: 0) - } +// if config.autoPlay { +// // 主唱自动播放歌曲 +// if singerRole != .leadSinger { +// switchSingerRole(newRole: .soloSinger) { _, _ in +// } +// } +// startSing(url: url, startPos: 0) +// } } func getMusicPlayer() -> AgoraRtcMediaPlayerProtocol? { - sendCustomMessage(with: "getMusicPlayer", label: "") + sendCustomMessage(with: "getMusicPlayer", dict: [:]) return mediaPlayer } func addEventHandler(ktvApiEventHandler: KTVApiEventHandlerDelegate) { - sendCustomMessage(with: "addEventHandler", label: "") + sendCustomMessage(with: "addEventHandler", dict: [:]) if eventHandlers.contains(ktvApiEventHandler) { return } @@ -256,12 +317,12 @@ extension KTVApiImpl: KTVApiDelegate { } func removeEventHandler(ktvApiEventHandler: KTVApiEventHandlerDelegate) { - sendCustomMessage(with: "removeEventHandler", label: "") + sendCustomMessage(with: "removeEventHandler", dict: [:]) eventHandlers.remove(ktvApiEventHandler) } func cleanCache() { - sendCustomMessage(with: "cleanCache", label: "") + sendCustomMessage(with: "cleanCache", dict: [:]) isRelease = true freeTimer() agoraPrint("cleanCache") @@ -282,7 +343,12 @@ extension KTVApiImpl: KTVApiDelegate { } func renewToken(rtmToken: String, chorusChannelRtcToken: String) { - sendCustomMessage(with: "renewToken", label: "rtmToken:\(rtmToken), chorusChannelRtcToken:\(chorusChannelRtcToken)") + + let dict: [String: Any] = [ + "rtmToken":rtmToken, + "chorusChannelRtcToken":chorusChannelRtcToken + ] + sendCustomMessage(with: "renewToken", dict: dict) // 更新RtmToken mcc?.renewToken(rtmToken) // 更新合唱频道RtcToken @@ -294,7 +360,7 @@ extension KTVApiImpl: KTVApiDelegate { } func fetchMusicCharts(completion: @escaping MusicChartCallBacks) { - sendCustomMessage(with: "fetchMusicCharts", label: "") + sendCustomMessage(with: "fetchMusicCharts", dict: [:]) agoraPrint("fetchMusicCharts") let requestId = mcc!.getMusicCharts() musicChartDict[requestId] = completion @@ -304,9 +370,15 @@ extension KTVApiImpl: KTVApiDelegate { page: Int, pageSize: Int, jsonOption: String, - completion:@escaping (String, AgoraMusicContentCenterStatusCode, AgoraMusicCollection) -> Void) { + completion:@escaping (String, AgoraMusicContentCenterStateReason, AgoraMusicCollection) -> Void) { agoraPrint("searchMusic with musicChartId: \(musicChartId)") - sendCustomMessage(with: "searchMusic", label: "musicChartId:\(musicChartId), page:\(page), pageSize:\(pageSize), jsonOption:\(jsonOption)") + let dict: [String: Any] = [ + "musicChartId":musicChartId, + "page": page, + "pageSize": pageSize, + "jsonOption": jsonOption + ] + sendCustomMessage(with: "searchMusic", dict: dict) let requestId = mcc!.getMusicCollection(musicChartId: musicChartId, page: page, pageSize: pageSize, jsonOption: jsonOption) musicSearchDict[requestId] = completion } @@ -315,25 +387,38 @@ extension KTVApiImpl: KTVApiDelegate { page: Int, pageSize: Int, jsonOption: String, - completion: @escaping (String, AgoraMusicContentCenterStatusCode, AgoraMusicCollection) -> Void) { + completion: @escaping (String, AgoraMusicContentCenterStateReason, AgoraMusicCollection) -> Void) { agoraPrint("searchMusic with keyword: \(keyword)") - sendCustomMessage(with: "searchMusic", label: "keyword:\(keyword), page:\(page), pageSize:\(pageSize), jsonOption:\(jsonOption)") + let dict: [String: Any] = [ + "keyword": keyword, + "page": page, + "pageSize": pageSize, + "jsonOption": jsonOption + ] + sendCustomMessage(with: "searchMusic", dict: dict) let requestId = mcc!.searchMusic(keyWord: keyword, page: page, pageSize: pageSize, jsonOption: jsonOption) musicSearchDict[requestId] = completion } func switchSingerRole(newRole: KTVSingRole, onSwitchRoleState: @escaping (KTVSwitchRoleState, KTVSwitchRoleFailReason) -> Void) { let oldRole = singerRole - sendCustomMessage(with: "switchSingerRole", label: "oldRole:\(oldRole.rawValue), newRole: \(newRole.rawValue)") + + let dict: [String: Any] = [ + "oldRole": oldRole.rawValue, + "newRole": newRole.rawValue + ] + sendCustomMessage(with: "switchSingerRole", dict: dict) agoraPrint("switchSingerRole oldRole:\(oldRole.rawValue), newRole: \(newRole.rawValue)") - if ((oldRole == .leadSinger || oldRole == .soloSinger) && (newRole == .coSinger || newRole == .audience) && isNowMicMuted) { - apiConfig?.engine?.muteLocalAudioStream(true) - apiConfig?.engine?.adjustRecordingSignalVolume(100) - } else if ((oldRole == .audience || oldRole == .coSinger) && (newRole == .leadSinger || newRole == .soloSinger) && isNowMicMuted) { - apiConfig?.engine?.adjustRecordingSignalVolume(0) - apiConfig?.engine?.muteLocalAudioStream(false) - } +// if (apiConfig?.type != .singRelay) { +// if ((oldRole == .leadSinger || oldRole == .soloSinger) && (newRole == .coSinger || newRole == .audience) && isNowMicMuted) { +// apiConfig?.engine?.muteLocalAudioStream(true) +// apiConfig?.engine?.adjustRecordingSignalVolume(100) +// } else if ((oldRole == .audience || oldRole == .coSinger) && (newRole == .leadSinger || newRole == .soloSinger) && isNowMicMuted) { +// apiConfig?.engine?.adjustRecordingSignalVolume(0) +// apiConfig?.engine?.muteLocalAudioStream(false) +// } +// } self.switchSingerRole(oldRole: oldRole, newRole: newRole, token: apiConfig?.chorusChannelToken ?? "", stateCallBack: onSwitchRoleState) } @@ -342,7 +427,7 @@ extension KTVApiImpl: KTVApiDelegate { * 恢复播放 */ @objc public func resumeSing() { - sendCustomMessage(with: "resumeSing", label: "") + sendCustomMessage(with: "resumeSing", dict: [:]) agoraPrint("resumeSing") if mediaPlayer?.getPlayerState() == .paused { mediaPlayer?.resume() @@ -356,7 +441,7 @@ extension KTVApiImpl: KTVApiDelegate { * 暂停播放 */ @objc public func pauseSing() { - sendCustomMessage(with: "pauseSing", label: "") + sendCustomMessage(with: "pauseSing", dict: [:]) agoraPrint("pauseSing") mediaPlayer?.pause() } @@ -365,7 +450,7 @@ extension KTVApiImpl: KTVApiDelegate { * 调整进度 */ @objc public func seekSing(time: NSInteger) { - sendCustomMessage(with: "seekSing", label: "") + sendCustomMessage(with: "seekSing", dict: ["time":time]) agoraPrint("seekSing") mediaPlayer?.seek(toPosition: time) } @@ -381,25 +466,60 @@ extension KTVApiImpl: KTVApiDelegate { * 设置当前mic开关状态 */ @objc public func muteMic(muteStatus: Bool) { - sendCustomMessage(with: "setMicStatus", label: "\(muteStatus)") + sendCustomMessage(with: "setMicStatus", dict: ["muteStatus":muteStatus]) self.isNowMicMuted = muteStatus - if self.singerRole == .leadSinger || self.singerRole == .soloSinger { - apiConfig?.engine?.adjustRecordingSignalVolume(muteStatus ? 0 : 100) + if (apiConfig?.type != .singRelay) { + if self.singerRole == .leadSinger || self.singerRole == .soloSinger { + apiConfig?.engine?.adjustRecordingSignalVolume(muteStatus ? 0 : 100) + } else { +// let channelMediaOptions = AgoraRtcChannelMediaOptions() +// channelMediaOptions.publishMicrophoneTrack = !muteStatus +// channelMediaOptions.clientRoleType = .broadcaster +// apiConfig?.engine?.updateChannel(with: channelMediaOptions) +// apiConfig?.engine?.muteLocalAudioStream(muteStatus) + + apiConfig?.engine?.adjustRecordingSignalVolume(muteStatus ? 0 : 100) + } } else { - apiConfig?.engine?.muteLocalAudioStream(muteStatus) + apiConfig?.engine?.adjustRecordingSignalVolume(muteStatus ? 0 : 100) } } @objc public func removeMusic(songCode: Int) { - sendCustomMessage(with: "removeMusic", label: "songCode:\(songCode)") + sendCustomMessage(with: "removeMusic", dict: ["songCode": songCode]) let ret: Int = mcc?.removeCache(songCode: songCode) ?? 0 if ret < 0 { agoraPrint("removeMusic failed: ret:\(ret)") } } + + @objc public func enableMutipath(enable: Bool) { + sendCustomMessage(with: "enableMutipath", dict: ["enable":enable]) + agoraPrint("enableMutipath:\(enable)") + enableMultipathing = enable + if singerRole == .coSinger || singerRole == .leadSinger { + if let subChorusConnection = subChorusConnection { + apiConfig?.engine?.setParametersEx("{\"rtc.enableMultipath\": \(enable), \"rtc.path_scheduling_strategy\": 0, \"rtc.remote_path_scheduling_strategy\": 0}", connection: subChorusConnection) + } + } + } + private func agoraPrint(_ message: String) { + #if DEBUG + print("[KTVAPI]\(message)") + #endif + apiRepoter?.writeLog(content: "[KTVAPI]\(message)", level: .info) + } + + private func agoraPrintError(_ message: String) { + #if DEBUG + print("[KTVAPI][Error]\(message)") + #endif + apiRepoter?.writeLog(content: "[KTVAPI][Error]\(message)", level: .error) + } } + // 主要是角色切换,加入合唱,加入多频道,退出合唱,退出多频道 extension KTVApiImpl { private func switchSingerRole(oldRole: KTVSingRole, newRole: KTVSingRole, token: String, stateCallBack:@escaping ISwitchRoleStateListener) { @@ -606,10 +726,10 @@ extension KTVApiImpl { let rtcConnection = AgoraRtcConnection() rtcConnection.channelId = apiConfig?.chorusChannelName ?? "" rtcConnection.localUid = UInt(apiConfig?.localUid ?? 0) - subChorusConnection = rtcConnection + subChorusConnection = rtcConnection joinChorusNewRole = role - let ret = apiConfig?.engine?.joinChannelEx(byToken: token, connection: rtcConnection, delegate: self, mediaOptions: mediaOption, joinSuccess: nil) + let ret = apiConfig?.engine?.joinChannelEx(byToken: token, connection: rtcConnection, delegate: self, mediaOptions: mediaOption, joinSuccess: nil) agoraPrint("joinChannelEx ret: \(ret ?? -999)") if newRole == .coSinger { let uid = UInt(songConfig?.mainSingerUid ?? 0) @@ -617,6 +737,10 @@ extension KTVApiImpl { apiConfig?.engine?.muteRemoteAudioStream(uid, mute: true) agoraPrint("muteRemoteAudioStream: \(uid), ret: \(ret ?? -1)") } + if enableMultipathing { + apiConfig?.engine?.setParametersEx("{\"rtc.path_scheduling_strategy\":0, \"rtc.enableMultipath\": true, \"rtc.remote_path_scheduling_strategy\": 0}", connection: rtcConnection) + } + apiConfig?.engine?.setParameters("{\"rtc.use_audio4\": true}") } private func leaveChorus2ndChannel(_ role: KTVSingRole) { @@ -666,7 +790,6 @@ extension KTVApiImpl { } private func _loadMusic(config: KTVSongConfiguration, mode: KTVLoadMusicMode, onMusicLoadStateListener: IMusicLoadStateListener){ - songConfig = config lastReceivedPosition = 0 localPosition = 0 @@ -676,6 +799,7 @@ extension KTVApiImpl { } if (config.mode == .loadNone) { + agoraPrint("load music none") return } @@ -696,23 +820,23 @@ extension KTVApiImpl { onMusicLoadStateListener.onMusicLoadFail(songCode: self.songCode, reason: .noLyricUrl) } - if (config.autoPlay) { - // 主唱自动播放歌曲 - if self.singerRole != .leadSinger { - self.switchSingerRole(newRole: .soloSinger) { _, _ in - - } - } - self.startSing(songCode: self.songCode, startPos: 0) - } +// if (config.autoPlay) { +// // 主唱自动播放歌曲 +// if self.singerRole != .leadSinger { +// self.switchSingerRole(newRole: .soloSinger) { _, _ in +// } +// } +// self.startSing(songCode: self.songCode, startPos: 0) +// } } } else { loadMusicListeners.setObject(onMusicLoadStateListener, forKey: "\(self.songCode)" as NSString) - onMusicLoadStateListener.onMusicLoadProgress(songCode: self.songCode, percent: 0, status: .preloading, msg: "", lyricUrl: "") + // onMusicLoadStateListener.onMusicLoadProgress(songCode: self.songCode, percent: 0, status: .preloading, msg: "", lyricUrl: "") // TODO: 只有未缓存时才显示进度条 if mcc?.isPreloaded(songCode: songCode) != 0 { - onMusicLoadStateListener.onMusicLoadProgress(songCode: self.songCode, percent: 0, status: .preloading, msg: "", lyricUrl: "") + onMusicLoadStateListener.onMusicLoadProgress(songCode: self.songCode, percent: 0, state: .preloading, msg: "", lyricUrl: "") } + preloadMusic(with: songCode) { [weak self] status, songCode in guard let self = self else { return } if self.songCode != songCode { @@ -723,7 +847,6 @@ extension KTVApiImpl { if mode == .loadMusicAndLrc { // 需要加载歌词 self.loadLyric(with: songCode) { url in - agoraPrint("loadMusicAndLrc: songCode:\(songCode) status:\(status.rawValue) ulr:\(String(describing: url))") if self.songCode != songCode { onMusicLoadStateListener.onMusicLoadFail(songCode: songCode, reason: .cancled) return @@ -732,35 +855,35 @@ extension KTVApiImpl { self.lyricUrlMap[String(songCode)] = urlPath self.setLyric(with: urlPath) { lyricUrl in onMusicLoadStateListener.onMusicLoadSuccess(songCode: songCode, lyricUrl: urlPath) + self.agoraPrint("loadMusicAndLrc: songCode:\(songCode) status:\(status.rawValue) ulr:\(String(describing: url))") } } else { onMusicLoadStateListener.onMusicLoadFail(songCode: songCode, reason: .noLyricUrl) + self.agoraPrint("loadMusicAndLrc: songCode:\(songCode) status:\(status.rawValue) ulr:\(String(describing: url))") } - if config.autoPlay { - // 主唱自动播放歌曲 - if self.singerRole != .leadSinger { - self.switchSingerRole(newRole: .soloSinger) { _, _ in - - } - } - self.startSing(songCode: self.songCode, startPos: 0) - } +// if config.autoPlay { +// // 主唱自动播放歌曲 +// if self.singerRole != .leadSinger { +// self.switchSingerRole(newRole: .soloSinger) { _, _ in +// } +// } +// self.startSing(songCode: self.songCode, startPos: 0) +// } } } else if mode == .loadMusicOnly { agoraPrint("loadMusicOnly: songCode:\(songCode) load success") - if config.autoPlay { - // 主唱自动播放歌曲 - if self.singerRole != .leadSinger { - self.switchSingerRole(newRole: .soloSinger) { _, _ in - - } - } - self.startSing(songCode: self.songCode, startPos: 0) - } +// if config.autoPlay { +// // 主唱自动播放歌曲 +// if self.singerRole != .leadSinger { +// self.switchSingerRole(newRole: .soloSinger) { _, _ in +// } +// } +// self.startSing(songCode: self.songCode, startPos: 0) +// } onMusicLoadStateListener.onMusicLoadSuccess(songCode: songCode, lyricUrl: "") } } else { - agoraPrint("load music failed songCode:\(songCode)") + agoraPrintError("load music failed songCode:\(songCode)") onMusicLoadStateListener.onMusicLoadFail(songCode: songCode, reason: .musicPreloadFail) } } @@ -769,18 +892,28 @@ extension KTVApiImpl { private func loadLyric(with songCode: NSInteger, callBack:@escaping LyricCallback) { agoraPrint("loadLyric songCode: \(songCode)") - let requestId: String = self.mcc?.getLyric(songCode: songCode, lyricType: 0) ?? "" + guard let mcc = self.mcc else { + agoraPrint("loadLyric songCode: \(songCode) fail") + callBack(nil) + return + } + let requestId: String = mcc.getLyric(songCode: songCode, lyricType: 0) self.lyricCallbacks.updateValue(callBack, forKey: requestId) } private func preloadMusic(with songCode: Int, callback: @escaping LoadMusicCallback) { agoraPrint("preloadMusic songCode: \(songCode)") - if self.mcc?.isPreloaded(songCode: songCode) == 0 { + guard let mcc = self.mcc else { + agoraPrint("preloadMusic songCode: \(songCode) fail") + callback(.error, songCode) + return + } + if mcc.isPreloaded(songCode: songCode) == 0 { musicCallbacks.removeValue(forKey: String(songCode)) callback(.OK, songCode) return } - let err = self.mcc?.preload(songCode: songCode, jsonOption: nil) + let err = mcc.preload(songCode: songCode, jsonOption: nil) if err != 0 { musicCallbacks.removeValue(forKey: String(songCode)) callback(.error, songCode) @@ -796,11 +929,15 @@ extension KTVApiImpl { } func startSing(songCode: Int, startPos: Int) { - sendCustomMessage(with: "startSing", label: "songCode:\(songCode), startPos: \(startPos)") + let dict: [String: Any] = [ + "songCode": songCode, + "startPos": startPos + ] + sendCustomMessage(with: "startSing", dict: dict) let role = singerRole agoraPrint("startSing role: \(role.rawValue)") if self.songCode != songCode { - agoraPrint("startSing failed: canceled") + agoraPrintError("startSing failed: canceled") return } @@ -809,20 +946,24 @@ extension KTVApiImpl { } apiConfig?.engine?.adjustPlaybackSignalVolume(Int(remoteVolume)) let ret = (mediaPlayer as? AgoraMusicPlayerProtocol)?.openMedia(songCode: songCode, startPos: startPos) - agoraPrint("startSing->openMedia(\(songCode) fail: \(ret ?? -1)") + agoraPrintError("startSing->openMedia(\(songCode) fail: \(ret ?? -1)") } func startSing(url: String, startPos: Int) { - sendCustomMessage(with: "startSing", label: "url:\(url), startPos: \(startPos)") + let dict: [String: Any] = [ + "url": url, + "startPos": startPos + ] + sendCustomMessage(with: "startSing", dict: dict) let role = singerRole agoraPrint("startSing role: \(role.rawValue)") if self.songUrl != songUrl { - agoraPrint("startSing failed: canceled") + agoraPrintError("startSing failed: canceled") return } apiConfig?.engine?.adjustPlaybackSignalVolume(Int(remoteVolume)) let ret = mediaPlayer?.open(url, startPos: 0) - agoraPrint("startSing->openMedia(\(url) fail: \(ret ?? -1)") + agoraPrintError("startSing->openMedia(\(url) fail: \(ret ?? -1)") } /** @@ -830,7 +971,7 @@ extension KTVApiImpl { */ @objc public func stopSing() { agoraPrint("stopSing") - sendCustomMessage(with: "stopSing", label: "") + sendCustomMessage(with: "stopSing", dict: [:]) let mediaOption = AgoraRtcChannelMediaOptions() mediaOption.publishMediaPlayerAudioTrack = false apiConfig?.engine?.updateChannel(with: mediaOption) @@ -850,6 +991,7 @@ extension KTVApiImpl { @objc func enableProfessionalStreamerMode(_ enable: Bool) { if self.isPublishAudio == false {return} + agoraPrint("enableProfessionalStreamerMode enable:\(enable)") self.enableProfessional = enable //专业非专业还需要根据是否佩戴耳机来判断是否开启3A apiConfig?.engine?.setAudioProfile(enable ? .musicHighQualityStereo : .musicStandardStereo) @@ -868,6 +1010,7 @@ extension KTVApiImpl { } } + } // rtc的子频道代理回调 @@ -888,9 +1031,8 @@ extension KTVApiImpl: AgoraRtcEngineDelegate { } public func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) { - agoraPrint("didOccurError: \(errorCode.rawValue)") + agoraPrintError("didOccurError: \(errorCode.rawValue)") if errorCode != .joinChannelRejected {return} - agoraPrint("join ex channel failed") engine.setAudioScenario(.gameStreaming) if joinChorusNewRole == .leadSinger { mainSingerHasJoinChannelEx = false @@ -928,27 +1070,17 @@ extension KTVApiImpl { let ntpTime = dict["ntp"] as? Int, let songId = dict["songIdentifier"] as? String else { return } - agoraPrint("realTime:\(realPosition) position:\(position) lastNtpTime:\(lastNtpTime) ntpTime:\(ntpTime) ntpGap:\(ntpTime - self.lastNtpTime) ") - //如果接收到的歌曲和自己本地的歌曲不一致就不更新进度 -// guard songCode == self.songCode else { -// agoraPrint("local songCode[\(songCode)] is not equal to recv songCode[\(self.songCode)] role: \(singerRole.rawValue)") -// return -// } self.lastNtpTime = ntpTime self.remotePlayerDuration = TimeInterval(duration) let state = AgoraMediaPlayerState(rawValue: mainSingerState) ?? .stopped -// self.lastMainSingerUpdateTime = Date().milListamp -// self.remotePlayerPosition = TimeInterval(realPosition) if self.playerState != state { agoraPrint("[setLrcTime] recv state: \(self.playerState.rawValue)->\(state.rawValue) role: \(singerRole.rawValue) role: \(singerRole.rawValue)") if state == .playing, singerRole == .coSinger, playerState == .openCompleted { //如果是伴唱等待主唱开始播放,seek 到指定位置开始播放保证歌词显示位置准确 self.localPlayerPosition = self.lastMainSingerUpdateTime - Double(position) - print("localPlayerPosition:playerKit:handleSetLrcTimeCommand \(localPlayerPosition)") - agoraPrint("seek toPosition: \(position)") mediaPlayer?.seek(toPosition: Int(position)) } @@ -960,28 +1092,25 @@ extension KTVApiImpl { self.remotePlayerPosition = TimeInterval(realPosition) handleCoSingerRole(dict: dict) } else if role == .audience { - if self.songIdentifier == songId { - self.lastMainSingerUpdateTime = Date().milListamp - self.remotePlayerPosition = TimeInterval(realPosition) + if dict.keys.contains("ver") { + recvFromDataStream = false } else { - self.lastMainSingerUpdateTime = 0 - self.remotePlayerPosition = 0 + recvFromDataStream = true + if self.songIdentifier == songId { + self.lastMainSingerUpdateTime = Date().milListamp + self.remotePlayerPosition = TimeInterval(realPosition) + } else { + self.lastMainSingerUpdateTime = 0 + self.remotePlayerPosition = 0 + } + handleAudienceRole(dict: dict) } - handleAudienceRole(dict: dict) } } private func handlePlayerStateCommand(dict: [String: Any], role: KTVSingRole) { let mainSingerState: Int = dict["state"] as? Int ?? 0 let state = AgoraMediaPlayerState(rawValue: mainSingerState) ?? .idle -// -// if state == .playing, singerRole == .coSinger, playerState == .openCompleted { -// //如果是伴唱等待主唱开始播放,seek 到指定位置开始播放保证歌词显示位置准确 -// self.localPlayerPosition = getPlayerCurrentTime() -// print("localPlayerPosition:playerKit:handlePlayerStateCommand \(localPlayerPosition)") -// agoraPrint("seek toPosition: \(self.localPlayerPosition)") -// mediaPlayer?.seek(toPosition: Int(self.localPlayerPosition)) -// } agoraPrint("recv state with MainSinger: \(state.rawValue)") syncPlayStateFromRemote(state: state, needDisplay: true) @@ -1010,9 +1139,9 @@ extension KTVApiImpl { let threshold = expectPosition - Int(localPosition) let ntpTime = dict["ntp"] as? Int ?? 0 let time = dict["time"] as? Int64 ?? 0 - agoraPrint("checkNtp, diff:\(threshold), localNtp:\(getNtpTimeInMs()), localPosition:\(localPosition), audioPlayoutDelay:\(audioPlayoutDelay), remoteDiff:\(String(describing: ntpTime - Int(time)))") + // agoraPrint("checkNtp, diff:\(threshold), localNtp:\(getNtpTimeInMs()), localPosition:\(localPosition), audioPlayoutDelay:\(audioPlayoutDelay), remoteDiff:\(String(describing: ntpTime - Int(time)))") if abs(threshold) > 50 { - print("expectPosition:\(expectPosition)") + agoraPrint("expectPosition:\(expectPosition)") mediaPlayer?.seek(toPosition: expectPosition) } } @@ -1026,7 +1155,7 @@ extension KTVApiImpl { let mainSingerUid = dict["uid"] as? Int ?? 0 songConfig?.mainSingerUid = mainSingerUid let ret = apiConfig?.engine?.muteRemoteAudioStream(UInt(mainSingerUid), mute: true) - print("ret:\(ret)") + agoraPrint("handleCosingerToLeadSinger:ret:\(String(describing: ret))") } } } @@ -1073,7 +1202,31 @@ extension KTVApiImpl { if self.singerRole != .audience { current = Date().milListamp - self.lastReceivedPosition + Double(self.localPosition) } - self.setProgress(with: Int(current) + Int(self.startHighTime)) + + if self.singerRole == .audience && !recvFromDataStream { + + } else { + var curTime:Int64 = Int64(current) + Int64(self.startHighTime) + if songConfig?.songCutter == true { + curTime = curTime - preludeDuration > 0 ? curTime - preludeDuration : curTime + } + if self.singerRole != .audience { + current = Date().milListamp - self.lastReceivedPosition + Double(self.localPosition) + + if self.singerRole == .leadSinger || self.singerRole == .soloSinger { + var time: LrcTime = LrcTime() + time.forward = true + time.ts = curTime + time.songID = songIdentifier + time.type = .lrcTime + //大合唱的uid是musicuid + time.uid = Int32(apiConfig?.localUid ?? 0) + sendMetaMsg(with: time) + } + } + self.setProgress(with: Int(curTime)) + } + self.oldPitch = self.pitch }) } @@ -1163,13 +1316,13 @@ extension KTVApiImpl { resumeSing() } else if (state == .playBackAllLoopsCompleted && needDisplay == true) { getEventHander { delegate in - delegate.onMusicPlayerStateChanged(state: state, error: .none, isLocal: true) + delegate.onMusicPlayerStateChanged(state: state, reason: .none, isLocal: true) } } } else { self.playerState = state getEventHander { delegate in - delegate.onMusicPlayerStateChanged(state: self.playerState, error: .none, isLocal: false) + delegate.onMusicPlayerStateChanged(state: self.playerState, reason: .none, isLocal: false) } } } @@ -1208,22 +1361,25 @@ extension KTVApiImpl { return localNtpTime } - private func syncPlayState(state: AgoraMediaPlayerState, error: AgoraMediaPlayerError) { - let dict: [String: Any] = ["cmd": "PlayerState", "userId": apiConfig?.localUid as Any, "state": state.rawValue, "error": "\(error.rawValue)"] + private func syncPlayState(state: AgoraMediaPlayerState, reason: AgoraMediaPlayerReason) { + let dict: [String: Any] = ["cmd": "PlayerState", "userId": apiConfig?.localUid as Any, "state": state.rawValue, "error": "\(reason.rawValue)"] sendStreamMessageWithDict(dict, success: nil) } - private func sendCustomMessage(with event: String, label: String) { - apiConfig?.engine?.sendCustomReportMessage("scenarioAPI", category: "1_ios_4.0.0", event: event, label: label, value: 0) + private func sendCustomMessage(with event: String, dict: [String: Any]) { + apiRepoter?.reportFuncEvent(name: event, value: dict, ext: [:]) } private func sendStreamMessageWithDict(_ dict: [String: Any], success: ((_ success: Bool) -> Void)?) { let messageData = compactDictionaryToData(dict as [String: Any]) + let sizeInBits = (messageData ?? Data()).count * 8 + totalSize += sizeInBits let code = apiConfig?.engine?.sendStreamMessage(dataStreamId, data: messageData ?? Data()) if code == 0 && success != nil { success!(true) } if code != 0 { agoraPrint("sendStreamMessage fail: \(String(describing: code))") } +// print("totalSize:\(totalSize)") } private func syncPlayState(_ state: AgoraMediaPlayerState) { @@ -1235,6 +1391,14 @@ extension KTVApiImpl { lrcControl?.onUpdatePitch(pitch: Float(self.pitch)) lrcControl?.onUpdateProgress(progress: pos > 200 ? pos - 200 : pos) } + + private func sendMetaMsg(with time: LrcTime) { + let data: Data? = try? time.serializedData() + let code = apiConfig?.engine?.sendAudioMetadata(data ?? Data()) + if code != 0 { + agoraPrintError("sendStreamMessage fail: \(String(describing: code))") + } + } } //主要是MPK的回调 @@ -1253,11 +1417,10 @@ extension KTVApiImpl: AgoraRtcMediaPlayerDelegate { "realTime":position_ms, "ntp": timestamp_ms, "playerState": self.playerState.rawValue, - "songIdentifier": songIdentifier - // "songCode": self.songCode + "songIdentifier": songIdentifier, + "ver":2, ] - agoraPrint("position_ms:\(position_ms), ntp:\(getNtpTimeInMs()), delta:\(self.getNtpTimeInMs() - position_ms), autoPlayoutDelay:\(self.audioPlayoutDelay)") - print("autoPlayoutDelay:\(self.audioPlayoutDelay)") + // agoraPrint("position_ms:\(position_ms), ntp:\(getNtpTimeInMs()), delta:\(self.getNtpTimeInMs() - position_ms), autoPlayoutDelay:\(self.audioPlayoutDelay)") sendStreamMessageWithDict(dict) { _ in @@ -1275,12 +1438,12 @@ extension KTVApiImpl: AgoraRtcMediaPlayerDelegate { } - func AgoraRtcMediaPlayer(_ playerKit: AgoraRtcMediaPlayerProtocol, didChangedTo state: AgoraMediaPlayerState, error: AgoraMediaPlayerError) { + func AgoraRtcMediaPlayer(_ playerKit: AgoraRtcMediaPlayerProtocol, didChangedTo state: AgoraMediaPlayerState, reason: AgoraMediaPlayerReason) { agoraPrint("agoraRtcMediaPlayer didChangedToState: \(state.rawValue) \(self.songCode)") if isRelease {return} if state == .openCompleted { self.localPlayerPosition = Date().milListamp - print("localPlayerPosition:playerKit:openCompleted \(localPlayerPosition)") + agoraPrint("localPlayerPosition:playerKit:openCompleted \(localPlayerPosition)") self.playerDuration = TimeInterval(mediaPlayer?.getDuration() ?? 0) if isMainSinger() { //主唱播放,通过同步消息“setLrcTime”通知伴唱play playerKit.play() @@ -1298,11 +1461,11 @@ extension KTVApiImpl: AgoraRtcMediaPlayerDelegate { } else if state == .playing { apiConfig?.engine?.adjustPlaybackSignalVolume(Int(remoteVolume)) self.localPlayerPosition = Date().milListamp - Double(mediaPlayer?.getPosition() ?? 0) - print("localPlayerPosition:playerKit:playing \(localPlayerPosition)") + agoraPrint("localPlayerPosition:playerKit:playing \(localPlayerPosition)") } if isMainSinger() { - syncPlayState(state: state, error: error) + syncPlayState(state: state, reason: reason) } self.playerState = state agoraPrint("recv state with player callback : \(state.rawValue)") @@ -1310,10 +1473,10 @@ extension KTVApiImpl: AgoraRtcMediaPlayerDelegate { return } getEventHander { delegate in - delegate.onMusicPlayerStateChanged(state: state, error: .none, isLocal: true) + delegate.onMusicPlayerStateChanged(state: state, reason: .none, isLocal: true) } } - + private func isMainSinger() -> Bool { return singerRole == .soloSinger || singerRole == .leadSinger } @@ -1322,7 +1485,7 @@ extension KTVApiImpl: AgoraRtcMediaPlayerDelegate { //主要是MCC的回调 extension KTVApiImpl: AgoraMusicContentCenterEventDelegate { - func onSongSimpleInfoResult(_ requestId: String, songCode: Int, simpleInfo: String?, errorCode: AgoraMusicContentCenterStatusCode) { + func onSongSimpleInfoResult(_ requestId: String, songCode: Int, simpleInfo: String?, reason: AgoraMusicContentCenterStateReason) { if let jsonData = simpleInfo?.data(using: .utf8) { do { let jsonMsg = try JSONSerialization.jsonObject(with: jsonData, options: []) as! [String: Any] @@ -1330,74 +1493,80 @@ extension KTVApiImpl: AgoraMusicContentCenterEventDelegate { let highPart = format["highPart"] as! [[String: Any]] let highStartTime = highPart[0]["highStartTime"] as! Int let highEndTime = highPart[0]["highEndTime"] as! Int + if highPart[0].keys.contains("preludeDuration") { + self.preludeDuration = highPart[0]["preludeDuration"] as! Int64 + } let time = highStartTime startHighTime = time self.lrcControl?.onHighPartTime(highStartTime: highStartTime, highEndTime: highEndTime) } catch { - print("Error while parsing JSON: \(error.localizedDescription)") + agoraPrintError("Error while parsing JSON: \(error.localizedDescription)") } } - if (errorCode == .errorGateway) { + if (reason == .errorGateway) { getEventHander { delegate in delegate.onTokenPrivilegeWillExpire() } } } - - func onMusicChartsResult(_ requestId: String, result: [AgoraMusicChartInfo], errorCode: AgoraMusicContentCenterStatusCode) { + + func onMusicChartsResult(_ requestId: String, result: [AgoraMusicChartInfo], reason: AgoraMusicContentCenterStateReason) { guard let callback = musicChartDict[requestId] else {return} - callback(requestId, errorCode, result) + callback(requestId, reason, result) musicChartDict.removeValue(forKey: requestId) - if (errorCode == .errorGateway) { + if (reason == .errorGateway) { getEventHander { delegate in delegate.onTokenPrivilegeWillExpire() } } } - func onMusicCollectionResult(_ requestId: String, result: AgoraMusicCollection, errorCode: AgoraMusicContentCenterStatusCode) { + func onMusicCollectionResult(_ requestId: String, result: AgoraMusicCollection, reason: AgoraMusicContentCenterStateReason) { guard let callback = musicSearchDict[requestId] else {return} - callback(requestId, errorCode, result) + callback(requestId, reason, result) musicSearchDict.removeValue(forKey: requestId) - if (errorCode == .errorGateway) { + if (reason == .errorGateway) { getEventHander { delegate in delegate.onTokenPrivilegeWillExpire() } } } - func onLyricResult(_ requestId: String, songCode: Int, lyricUrl: String?, errorCode: AgoraMusicContentCenterStatusCode) { + func onLyricResult(_ requestId: String, songCode: Int, lyricUrl: String?, reason: AgoraMusicContentCenterStateReason) { + agoraPrint("onLyricResult requestId: \(requestId) songCode: \(songCode) lyricUrl: \(lyricUrl ?? "") reason: \(reason.rawValue)") guard let lrcUrl = lyricUrl else {return} let callback = self.lyricCallbacks[requestId] guard let lyricCallback = callback else { return } self.lyricCallbacks.removeValue(forKey: requestId) - if (errorCode == .errorGateway) { + if (reason == .errorGateway) { getEventHander { delegate in delegate.onTokenPrivilegeWillExpire() } } if lrcUrl.isEmpty { lyricCallback(nil) + agoraPrintError("onLyricResult: lrcUrl.isEmpty") return } lyricCallback(lrcUrl) + agoraPrint("onLyricResult: lrcUrl is \(lrcUrl)") } - func onPreLoadEvent(_ requestId: String, songCode: Int, percent: Int, lyricUrl: String?, status: AgoraMusicContentCenterPreloadStatus, errorCode: AgoraMusicContentCenterStatusCode) { + func onPreLoadEvent(_ requestId: String, songCode: Int, percent: Int, lyricUrl: String?, state: AgoraMusicContentCenterPreloadState, reason: AgoraMusicContentCenterStateReason) { if let listener = self.loadMusicListeners.object(forKey: "\(songCode)" as NSString) as? IMusicLoadStateListener { - listener.onMusicLoadProgress(songCode: songCode, percent: percent, status: status, msg: String(errorCode.rawValue), lyricUrl: lyricUrl) + listener.onMusicLoadProgress(songCode: songCode, percent: percent, state: state, msg: String(reason.rawValue), lyricUrl: lyricUrl) } - if (status == .preloading) { return } - agoraPrint("songCode:\(songCode), status:\(status.rawValue), code:\(errorCode.rawValue)") + if (state == .preloading) { return } + agoraPrint("songCode:\(songCode), status:\(state.rawValue), code:\(reason.rawValue)") let SongCode = "\(songCode)" guard let block = self.musicCallbacks[SongCode] else { return } self.musicCallbacks.removeValue(forKey: SongCode) - if (errorCode == .errorGateway) { + if (reason == .errorGateway) { getEventHander { delegate in delegate.onTokenPrivilegeWillExpire() } } - block(status, songCode) + block(state, songCode) } } @@ -1419,7 +1588,7 @@ extension Date { extension KTVApiImpl: KTVApiRTCDelegate { func didJoinChannel(channel: String, withUid uid: UInt, elapsed: Int) { - print("ktvapi加入主频道成功") + agoraPrint("ktvapi加入主频道成功") } func didJoinedOfUid(uid: UInt, elapsed: Int) { @@ -1454,11 +1623,12 @@ extension KTVApiImpl: KTVApiRTCDelegate { func didAudioPublishStateChange(channelId: String, oldState: AgoraStreamPublishState, newState: AgoraStreamPublishState, elapseSinceLastState: Int32) { self.isPublishAudio = newState == .published enableProfessionalStreamerMode(self.enableProfessional) - print("PublishStateChange:\(newState)") + agoraPrint("PublishStateChange:\(newState)") } func receiveStreamMessageFromUid(uid: UInt, streamId: Int, data: Data) { let role = singerRole + if isRelease {return} guard let dict = dataToDictionary(data: data), let cmd = dict["cmd"] as? String else { return } switch cmd { @@ -1481,8 +1651,8 @@ extension KTVApiImpl: KTVApiRTCDelegate { } func didRTCAudioRouteChanged(routing: AgoraAudioOutputRouting) { - print("Route changed:\(routing)") - let headPhones: [AgoraAudioOutputRouting] = [.headset, .headsetBluetooth, .headsetNoMic] + agoraPrint("Route changed:\(routing)") + let headPhones: [AgoraAudioOutputRouting] = [.headset, .bluetoothDeviceHfp, .bluetoothDeviceA2dp, .headsetNoMic] let wearHeadPhone: Bool = headPhones.contains(routing) if wearHeadPhone == self.isWearingHeadPhones { return @@ -1490,7 +1660,17 @@ extension KTVApiImpl: KTVApiRTCDelegate { self.isWearingHeadPhones = wearHeadPhone enableProfessionalStreamerMode(self.enableProfessional) } + + func audioMetadataReceived(uid: UInt, metadata: Data) { + guard let time: LrcTime = try? LrcTime(serializedData: metadata) else {return} + if time.type == .lrcTime && self.singerRole == .audience { + self.setProgress(with: Int(time.ts)) + } + } + @objc func didAudioMetadataReceived( uid: UInt, metadata: Data) { + + } } /*----这一块的代码主要是用来处理主频道的RTC代理事件,外部不再需要手动转代理,😁---*/ @@ -1502,6 +1682,7 @@ protocol KTVApiRTCDelegate: NSObjectProtocol { func didAudioPublishStateChange(channelId: String, oldState: AgoraStreamPublishState, newState: AgoraStreamPublishState, elapseSinceLastState: Int32) func receiveStreamMessageFromUid(uid: UInt, streamId: Int, data: Data) func localAudioStats(stats: AgoraRtcLocalAudioStats) + func audioMetadataReceived( uid: UInt, metadata: Data) } class KTVApiRTCDelegateHandler: NSObject, AgoraRtcEngineDelegate { @@ -1538,5 +1719,15 @@ class KTVApiRTCDelegateHandler: NSObject, AgoraRtcEngineDelegate { func rtcEngine(_ engine: AgoraRtcEngineKit, localAudioStats stats: AgoraRtcLocalAudioStats) { delegate.localAudioStats(stats: stats) } + + func rtcEngine(_ engine: AgoraRtcEngineKit, audioMetadataReceived uid: UInt, metadata: Data) { + delegate.audioMetadataReceived(uid: uid, metadata: metadata) + } } + +extension KTVApiImpl { + @objc public func isSongLoading(songCode: String) -> Bool { + return musicCallbacks[songCode] == nil ? false : true + } +} diff --git a/KTVAPI/iOS/Classes/KTVGiantChorusApiImpl.swift b/KTVAPI/iOS/Classes/KTVGiantChorusApiImpl.swift new file mode 100644 index 0000000..cdc5458 --- /dev/null +++ b/KTVAPI/iOS/Classes/KTVGiantChorusApiImpl.swift @@ -0,0 +1,2059 @@ +// +// KTVApiImpl.swift +// AgoraEntScenarios +// +// Created by wushengtao on 2023/3/14. +// + +import Foundation +import AgoraRtcKit + +/// 加载歌曲状态 +@objc fileprivate enum KTVLoadSongState: Int { + case idle = -1 //空闲 + case ok = 0 //成功 + case failed //失败 + case inProgress //加载中 +} + +fileprivate enum KTVSongMode: Int { + case songCode + case songUrl +} + +@objc class KTVGiantChorusApiImpl: NSObject, KTVApiDelegate{ + + private var apiConfig: GiantChorusConfiguration? + + private var songConfig: KTVSongConfiguration? + private var subChorusConnection: AgoraRtcConnection? + private var singChannelConnection: AgoraRtcConnection? + private var mpkConnection: AgoraRtcConnection? + + private var eventHandlers: NSHashTable = NSHashTable.weakObjects() + private var loadMusicListeners: NSMapTable = NSMapTable(keyOptions: .copyIn, valueOptions: .weakMemory) + + // private var musicPlayer: AgoraRtcMediaPlayerProtocol? //mcc + private var mediaPlayer: AgoraRtcMediaPlayerProtocol? //local + private var mcc: AgoraMusicContentCenter? + + private var loadSongMap = Dictionary() + private var lyricUrlMap = Dictionary() + private var loadDict = Dictionary() + private var lyricCallbacks = Dictionary() + private var musicCallbacks = Dictionary() + + private var hasSendPreludeEndPosition: Bool = false + private var hasSendEndPosition: Bool = false + + //multipath + private var enableMultipathing: Bool = true + + private var audioPlayoutDelay: NSInteger = 0 + private var isNowMicMuted: Bool = false + private var loadSongState: KTVLoadSongState = .idle + private var lastNtpTime: Int = 0 + private var startHighTime: Int = 0 + private var isRelease: Bool = false + private var songUrl2: String = "" + private var playerState: AgoraMediaPlayerState = .idle { + didSet { + agoraPrint("playerState did changed: \(oldValue.rawValue)->\(playerState.rawValue)") + updateRemotePlayBackVolumeIfNeed() + updateTimer(with: playerState) + } + } + private var pitch: Double = 0 + private var localPlayerPosition: TimeInterval = 0 + private var remotePlayerPosition: TimeInterval = 0 + private var remotePlayerDuration: TimeInterval = 0 + private var localPlayerSystemTime: TimeInterval = 0 + private var lastMainSingerUpdateTime: TimeInterval = 0 + private var playerDuration: TimeInterval = 0 + // private lazy var apiDelegateHandler = KTVApiRTCDelegateHandler(with: self) + + private var musicChartDict: [String: MusicChartCallBacks] = [:] + private var musicSearchDict: Dictionary = Dictionary() + private var onJoinExChannelCallBack : JoinExChannelCallBack? + private var mainSingerHasJoinChannelEx: Bool = false + private var dataStreamId: Int = 0 + private var lastReceivedPosition: TimeInterval = 0 + private var localPosition: Int = 0 + + private var songMode: KTVSongMode = .songCode + private var useCustomAudioSource:Bool = false + private var songUrl: String = "" + private var songCode: Int = 0 + private var songIdentifier: String = "" + + private var singerRole: KTVSingRole = .audience { + didSet { + agoraPrint("singerRole changed: \(oldValue.rawValue)->\(singerRole.rawValue)") + } + } + private var lrcControl: KTVLrcViewDelegate? + + private var timer: Timer? + private var isPause: Bool = false + + private var singingScore: Int = 0 + + public var remoteVolume: Int = 30 + private var joinChorusNewRole: KTVSingRole = .audience + private var oldPitch: Double = 0 + private var isWearingHeadPhones: Bool = false + private var enableProfessional: Bool = false + private var isPublishAudio: Bool = false + private var audioRouting: Int = -1 + private var recvFromDataStream = false + //大合唱独有 + private var mStopSyncPitch = true + private var mSyncPitchTimer: DispatchSourceTimer? + private var mStopSyncScore = true + private var mSyncScoreTimer: DispatchSourceTimer? + private var mStopSyncCloudConvergenceStatus = true + private var mSyncCloudConvergenceStatusTimer: DispatchSourceTimer? + private var mStopProcessDelay = true + private var processDelayFuture: DispatchSourceTimer? + private var processSubscribeFuture: DispatchSourceTimer? + private var subScribeSingerMap = [Int: Int]() // + private var singerList = [Int]() // + private var mainSingerDelay = 0 + + private let tag = "KTV_API_LOG" + private let messageId = "agora:scenarioAPI" + private let version = "5.0.0" + private let lyricSyncVersion = 2 + + private var apiRepoter: APIReporter? + + deinit { + mcc?.register(nil) + agoraPrint("deinit KTVApiImpl") + } + + func createKTVGiantChorusApi(config: GiantChorusConfiguration) { + self.apiConfig = config + agoraPrint("createKTVGiantChorusApi") + self.singChannelConnection = AgoraRtcConnection(channelId: config.chorusChannelName, localUid: config.localUid) + + setParams() + + if config.musicType == .mcc { + // ------------------ 初始化内容中心 ------------------ + let contentCenterConfiguration = AgoraMusicContentCenterConfig() + contentCenterConfiguration.appId = config.appId + contentCenterConfiguration.mccUid = config.localUid + contentCenterConfiguration.token = config.rtmToken + contentCenterConfiguration.rtcEngine = config.engine + contentCenterConfiguration.maxCacheSize = UInt(config.maxCacheSize) + if let domain = config.mccDomain { + contentCenterConfiguration.mccDomain = domain + } + mcc = AgoraMusicContentCenter.sharedContentCenter(config: contentCenterConfiguration) + mcc?.register(self) + // ------------------ 初始化音乐播放器实例 ------------------ + mediaPlayer = mcc?.createMusicPlayer(delegate: self) + mediaPlayer?.adjustPlayoutVolume(50) + mediaPlayer?.adjustPublishSignalVolume(50) + } else { + mediaPlayer = apiConfig?.engine?.createMediaPlayer(with: self) + // 音量最佳实践调整 + mediaPlayer?.adjustPlayoutVolume(50) + mediaPlayer?.adjustPublishSignalVolume(50) + } + + apiRepoter = APIReporter(type: .ktv, version: version, engine: apiConfig?.engine ?? AgoraRtcEngineKit()) + + initTimer() + mediaPlayer?.setPlayerOption("play_pos_change_callback", value: 100) + apiConfig?.engine?.setDelegateEx(self, connection: mpkConnection ?? AgoraRtcConnection()) + startSyncPitch() + startSyncScore() + startSyncCloudConvergenceStatus() + } + + private func setParams() { + guard let engine = self.apiConfig?.engine else {return} + engine.setParameters("{\"rtc.enable_nasa2\": true}") + engine.setParameters("{\"rtc.ntp_delay_drop_threshold\": 1000}") + engine.setParameters("{\"rtc.video.enable_sync_render_ntp\": true}") + engine.setParameters("{\"rtc.net.maxS2LDelay\": 800}") + engine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\": true}") + engine.setParameters("{\"rtc.net.maxS2LDelayBroadcast\": 400}") + engine.setParameters("{\"che.audio.neteq.prebuffer\": true}") + engine.setParameters("{\"che.audio.neteq.prebuffer_max_delay\": 600}") + engine.setParameters("{\"che.audio.max_mixed_participants\": 8}") + engine.setParameters("{\"che.audio.custom_bitrate\": 48000}") + engine.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}") + engine.setParameters("{\"che.audio.neteq.targetlevel_offset\": 20}") + engine.setParameters("{\"che.audio.uplink_apm_async_process\": true}") + // 标准音质 + engine.setParameters("{\"che.audio.aec.split_srate_for_48k\": 16000}") + engine.setParameters("{\"che.audio.ans.noise_gate\": 20}")// + engine.setParameters("{\"rtc.use_audio4\": true}") + + //4.3.0 add + // mutipath + enableMultipathing = true + engine.setParameters("{\"rtc.enable_tds_request_on_join\": true}") + engine.setParameters("{\"rtc.remote_path_scheduling_strategy\": 0}") + engine.setParameters("{\"rtc.path_scheduling_strategy\": 0}") + engine.setParameters("{\"rtc.enableMultipath\": true}") + + // 数据上报 + engine.setParameters("{\"rtc.direct_send_custom_event\": true}") + // engine.setParameters("{\"rtc.qos_for_test_purpose\": true}") + } + + func renewInnerDataStreamId() { + let dataStreamConfig = AgoraDataStreamConfig() + dataStreamConfig.ordered = false + dataStreamConfig.syncWithAudio = true + self.apiConfig?.engine?.createDataStreamEx(&dataStreamId, config: dataStreamConfig, connection: singChannelConnection ?? AgoraRtcConnection()) + + sendCustomMessage(with: "renewInnerDataStreamId", dict: [:]) + agoraPrint("renewInnerDataStreamId") + } +} + +//MARK: KTVApiDelegate +extension KTVGiantChorusApiImpl { + + func objectContent(of object: Any) -> [String: Any] { + var content = [String: Any]() + + let mirror = Mirror(reflecting: object) + for child in mirror.children { + if let propertyName = child.label { + if let convertibleValue = convertToJSONSerializable(child.value) { + content[propertyName] = convertibleValue + } + } + } + + return content + } + + func convertToJSONSerializable(_ value: Any) -> Any? { + switch value { + case let value as String: + return value + case let value as Int: + return value + case let value as Double: + return value + case let value as Bool: + return value + case let value as Int?: + return value + case let value as Double?: + return value + case let value as Bool?: + return value + case let value as String?: + return value + default: + return nil + } + } + + func getMusicContentCenter() -> AgoraMusicContentCenter? { + return mcc + } + + func setLrcView(view: KTVLrcViewDelegate) { + sendCustomMessage(with: "renewInnerDataStreamId", dict: [:]) + lrcControl = view + } + + //主要针对本地歌曲播放的主唱伴奏切换的 loadmusic MCC直接忽视这个方法 + func load2Music(url1: String, url2: String, config: KTVSongConfiguration) { + agoraPrint("load2Music called: songUrl url1:(url1),url2:(url2)") + self.songMode = .songUrl + self.songConfig = config + self.songIdentifier = config.songIdentifier + self.songUrl = url1 + self.songUrl2 = url2 + +// if config.autoPlay { +// // 主唱自动播放歌曲 +// if self.singerRole != .leadSinger { +// switchSingerRole(newRole: .soloSinger) { state, failRes in +// +// } +// } +// startSing(url: url1, startPos: 0) +// } + } + + //主要针对本地歌曲播放的主唱伴奏切换的 MCC直接忽视这个方法 + func switchPlaySrc(url: String, syncPts: Bool) { + agoraPrint("switchPlaySrc called: \(url)") + + if self.songUrl != url && self.songUrl2 != url { + print("switchPlaySrc failed: canceled") + return + } + + let curPlayPosition: Int = syncPts ? mediaPlayer?.getPosition() ?? 0 : 0 + mediaPlayer?.stop() + startSing(url: url, startPos: curPlayPosition) + } + + func loadMusic(songCode: Int, config: KTVSongConfiguration, onMusicLoadStateListener: IMusicLoadStateListener) { + sendCustomMessage(with: "loadMusicWithSongCode:\(songCode)", dict: objectContent(of: config)) + agoraPrint("loadMusic songCode:\(songCode) ") + self.songMode = .songCode + self.songCode = songCode + self.songIdentifier = config.songIdentifier + _loadMusic(config: config, mode: config.mode, onMusicLoadStateListener: onMusicLoadStateListener) + } + + func loadMusic(config: KTVSongConfiguration, url: String) { + sendCustomMessage(with: "loadMusicWithUrl:\(url)", dict: objectContent(of: config)) + agoraPrint("loadMusic url:\(url)") + self.songMode = .songUrl + self.songUrl = url + self.songIdentifier = config.songIdentifier +// if config.autoPlay { +// // 主唱自动播放歌曲 +// if singerRole != .leadSinger { +// switchSingerRole(newRole: .soloSinger) { _, _ in +// +// } +// } +// startSing(url: url, startPos: 0) +// } + } + + func getMusicPlayer() -> AgoraRtcMediaPlayerProtocol? { + return mediaPlayer + } + + func addEventHandler(ktvApiEventHandler: KTVApiEventHandlerDelegate) { + sendCustomMessage(with: "addEventHandler", dict: [:]) + agoraPrint("addEventHandler") + if eventHandlers.contains(ktvApiEventHandler) { + return + } + eventHandlers.add(ktvApiEventHandler) + } + + func removeEventHandler(ktvApiEventHandler: KTVApiEventHandlerDelegate) { + sendCustomMessage(with: "removeEventHandler", dict: [:]) + agoraPrint("removeEventHandler") + eventHandlers.remove(ktvApiEventHandler) + } + + func cleanCache() { + sendCustomMessage(with: "cleanCache", dict: [:]) + isRelease = true + mediaPlayer?.stop() + freeTimer() + agoraPrint("cleanCache") + singerRole = .audience + + stopSyncCloudConvergenceStatus() + stopSyncScore() + singingScore = 0 + lrcControl = nil + lyricCallbacks.removeAll() + musicCallbacks.removeAll() + onJoinExChannelCallBack = nil + loadMusicListeners.removeAllObjects() + apiConfig?.engine?.destroyMediaPlayer(mediaPlayer) + mediaPlayer = nil + if apiConfig?.musicType == .mcc { + mcc?.register(nil) + mcc = nil + } + apiConfig = nil + AgoraMusicContentCenter.destroy() + self.eventHandlers.removeAllObjects() + } + + @objc public func enableMutipath(enable: Bool) { + sendCustomMessage(with: "enableMutipath", dict: ["enable":enable]) + agoraPrint("enableMutipath:\(enable)") + enableMultipathing = enable + if singerRole == .coSinger || singerRole == .leadSinger { + if let subChorusConnection = subChorusConnection { + apiConfig?.engine?.setParametersEx("{\"rtc.enableMultipath\": \(enable), \"rtc.path_scheduling_strategy\": 0, \"rtc.remote_path_scheduling_strategy\": 0}", connection: subChorusConnection) + } + } + } + + func renewToken(rtmToken: String, chorusChannelRtcToken: String) { + let dict: [String: Any] = [ + "rtmToken":rtmToken, + "chorusChannelRtcToken":chorusChannelRtcToken + ] + sendCustomMessage(with: "renewToken", dict: dict) + agoraPrint("renewToken rtmToken:\(rtmToken) chorusChannelRtcToken:\(chorusChannelRtcToken)") + // 更新RtmToken + mcc?.renewToken(rtmToken) + // 更新合唱频道RtcToken + if let subChorusConnection = subChorusConnection { + let channelMediaOption = AgoraRtcChannelMediaOptions() + channelMediaOption.token = chorusChannelRtcToken + apiConfig?.engine?.updateChannelEx(with: channelMediaOption, connection: subChorusConnection) + } + } + + func fetchMusicCharts(completion: @escaping MusicChartCallBacks) { + sendCustomMessage(with: "fetchMusicCharts", dict: [:]) + agoraPrint("fetchMusicCharts") + let requestId = mcc!.getMusicCharts() + musicChartDict[requestId] = completion + } + + func searchMusic(musicChartId: Int, + page: Int, + pageSize: Int, + jsonOption: String, + completion:@escaping (String, AgoraMusicContentCenterStateReason, AgoraMusicCollection) -> Void) { + agoraPrint("searchMusic with musicChartId: \(musicChartId)") + let dict: [String: Any] = [ + "musicChartId":musicChartId, + "page": page, + "pageSize": pageSize, + "jsonOption": jsonOption + ] + sendCustomMessage(with: "searchMusic", dict: dict) + let requestId = mcc!.getMusicCollection(musicChartId: musicChartId, page: page, pageSize: pageSize, jsonOption: jsonOption) + musicSearchDict[requestId] = completion + } + + func searchMusic(keyword: String, + page: Int, + pageSize: Int, + jsonOption: String, + completion: @escaping (String, AgoraMusicContentCenterStateReason, AgoraMusicCollection) -> Void) { + agoraPrint("searchMusic with keyword: \(keyword)") + let dict: [String: Any] = [ + "keyword": keyword, + "page": page, + "pageSize": pageSize, + "jsonOption": jsonOption + ] + sendCustomMessage(with: "searchMusic", dict: dict) + let requestId = mcc!.searchMusic(keyWord: keyword, page: page, pageSize: pageSize, jsonOption: jsonOption) + musicSearchDict[requestId] = completion + } + +// func switchSingerRole(newRole: KTVSingRole, onSwitchRoleState: @escaping (KTVSwitchRoleState, KTVSwitchRoleFailReason) -> Void) { +// let oldRole = singerRole +// self.switchSingerRole(oldRole: oldRole, newRole: newRole, token: apiConfig?.chorusChannelToken ?? "", stateCallBack: onSwitchRoleState) +// } + + /** + * 恢复播放 + */ + @objc public func resumeSing() { + sendCustomMessage(with: "resumeSing", dict: [:]) + agoraPrint("resumeSing") + if mediaPlayer?.getPlayerState() == .paused { + mediaPlayer?.resume() + } else { + let ret = mediaPlayer?.play() + agoraPrint("resumeSing ret: \(ret ?? -1)") + } + } + + /** + * 暂停播放 + */ + @objc public func pauseSing() { + sendCustomMessage(with: "pauseSing", dict: [:]) + agoraPrint("pauseSing") + mediaPlayer?.pause() + } + + /** + * 调整进度 + */ + @objc public func seekSing(time: NSInteger) { + sendCustomMessage(with: "seekSing", dict: ["time":time]) + agoraPrint("seekSing") + mediaPlayer?.seek(toPosition: time) + } + + /** + * 选择音轨,原唱、伴唱 + */ +// @objc public func selectPlayerTrackMode(mode: KTVPlayerTrackMode) { +// apiConfig?.engine.selectAudioTrack(mode == .original ? 0 : 1) +// } + + /** + * 设置当前mic开关状态 + */ + @objc public func muteMic(muteStatus: Bool) { + sendCustomMessage(with: "setMicStatus", dict: ["muteStatus":muteStatus]) + agoraPrint("setMicStatus status:\(muteStatus)") + self.isNowMicMuted = muteStatus + if self.singerRole == .leadSinger || self.singerRole == .soloSinger { + apiConfig?.engine?.adjustRecordingSignalVolume(muteStatus ? 0 : 100) + } else { + apiConfig?.engine?.muteLocalAudioStream(muteStatus) + } + } + + @objc public func removeMusic(songCode: Int) { + sendCustomMessage(with: "removeMusic", dict: ["songCode": songCode]) + agoraPrint("removeMusic:\(songCode)") + let ret: Int = mcc?.removeCache(songCode: songCode) ?? 0 + if ret < 0 { + agoraPrint("removeMusic failed: ret:\(ret)") + } + } + + private func agoraPrint(_ message: String) { + apiRepoter?.writeLog(content: message, level: .info) + } + + private func agoraPrintError(_ message: String) { + apiRepoter?.writeLog(content: message, level: .error) + } + +} + +// 主要是角色切换,加入合唱,加入多频道,退出合唱,退出多频道 +extension KTVGiantChorusApiImpl { +// private func switchSingerRole(oldRole: KTVSingRole, newRole: KTVSingRole, token: String, stateCallBack:@escaping ISwitchRoleStateListener) { +// // agoraPrint("switchSingerRole oldRole: \(oldRole.rawValue), newRole: \(newRole.rawValue)") +// if oldRole == .audience && newRole == .soloSinger { +// // 1、KTVSingRoleAudience -》KTVSingRoleMainSinger +// singerRole = newRole +// becomeSoloSinger() +// getEventHander { delegate in +// delegate.onSingerRoleChanged(oldRole: .audience, newRole: .soloSinger) +// } +// +// stateCallBack(.success, .none) +// } else if oldRole == .audience && newRole == .leadSinger { +// becomeSoloSinger() +// joinChorus(role: newRole, token: token, joinExChannelCallBack: {[weak self] flag, status in +// guard let self = self else {return} +// //还原临时变量为观众 +// self.joinChorusNewRole = .audience +// +// if flag == true { +// self.singerRole = newRole +// self.getEventHander { delegate in +// delegate.onSingerRoleChanged(oldRole: .audience, newRole: .leadSinger) +// } +// stateCallBack(.success, .none) +// } else { +// self.leaveChorus(role: .leadSinger) +// stateCallBack(.fail, .joinChannelFail) +// } +// }) +// +// } else if oldRole == .soloSinger && newRole == .audience { +// stopSing() +// singerRole = newRole +// getEventHander { delegate in +// delegate.onSingerRoleChanged(oldRole: .soloSinger, newRole: .audience) +// } +// +// stateCallBack(.success, .none) +// } else if oldRole == .audience && newRole == .coSinger { +// joinChorus(role: newRole, token: token, joinExChannelCallBack: {[weak self] flag, status in +// guard let self = self else {return} +// //还原临时变量为观众 +// self.joinChorusNewRole = .audience +// if flag == true { +// self.singerRole = newRole +// //TODO(chenpan):如果观众变成伴唱,需要重置state,防止同步主唱state因为都是playing不会修改 +// //后面建议改成remote state(通过data stream获取)和local state(通过player didChangedToState获取) +// self.playerState = self.mediaPlayer?.getPlayerState() ?? .idle +// self.getEventHander { delegate in +// delegate.onSingerRoleChanged(oldRole: .audience, newRole: .coSinger) +// } +// stateCallBack(.success, .none) +// } else { +// self.leaveChorus(role: .coSinger) +// stateCallBack(.fail, .joinChannelFail) +// } +// }) +// } else if oldRole == .coSinger && newRole == .audience { +// leaveChorus(role: .coSinger) +// singerRole = newRole +// getEventHander { delegate in +// delegate.onSingerRoleChanged(oldRole: .coSinger, newRole: .audience) +// } +// +// stateCallBack(.success, .none) +// } else if oldRole == .soloSinger && newRole == .leadSinger { +// joinChorus(role: newRole, token: token, joinExChannelCallBack: {[weak self] flag, status in +// guard let self = self else {return} +// //还原临时变量为观众 +// self.joinChorusNewRole = .audience +// if flag == true { +// self.singerRole = newRole +// self.getEventHander { delegate in +// delegate.onSingerRoleChanged(oldRole: .soloSinger, newRole: .leadSinger) +// } +// stateCallBack(.success, .none) +// } else { +// self.leaveChorus(role: .leadSinger) +// stateCallBack(.fail, .joinChannelFail) +// } +// }) +// } else if oldRole == .leadSinger && newRole == .soloSinger { +// leaveChorus(role: .leadSinger) +// singerRole = newRole +// getEventHander { delegate in +// delegate.onSingerRoleChanged(oldRole: .leadSinger, newRole: .soloSinger) +// } +// +// stateCallBack(.success, .none) +// } else if oldRole == .leadSinger && newRole == .audience { +// leaveChorus(role: .leadSinger) +// stopSing() +// singerRole = newRole +// getEventHander { delegate in +// delegate.onSingerRoleChanged(oldRole: .leadSinger, newRole: .audience) +// } +// +// stateCallBack(.success, .none) +// } else { +// stateCallBack(.fail, .noPermission) +// agoraPrint("Error!You can not switch role from \(oldRole.rawValue) to \(newRole.rawValue)!") +// } +// +// } + + func switchSingerRole(newRole: KTVSingRole, onSwitchRoleState: @escaping (KTVSwitchRoleState, KTVSwitchRoleFailReason) -> Void) { + + agoraPrint("switchSingerRole oldRole: \(singerRole), newRole: \(newRole)") + let oldRole = singerRole + + if singerRole == .audience && newRole == .leadSinger { + // 1、Audience -》LeadSinger + // 离开观众频道 + apiConfig?.engine?.leaveChannelEx(AgoraRtcConnection(channelId: apiConfig?.channelName ?? "", localUid: apiConfig?.localUid ?? 0)) + joinChorus(newRole: newRole) + self.singerRole = newRole + self.getEventHander { delegate in + delegate.onSingerRoleChanged(oldRole: .audience, newRole: .leadSinger) + } + onSwitchRoleState(.success, .none) + } else if singerRole == .audience && newRole == .coSinger { + // 2、Audience -》CoSinger + // 离开观众频道 + apiConfig?.engine?.leaveChannelEx(AgoraRtcConnection( channelId: apiConfig?.channelName ?? "", localUid: apiConfig?.localUid ?? 0)) + joinChorus(newRole: newRole) + singerRole = newRole + self.getEventHander { delegate in + delegate.onSingerRoleChanged(oldRole: .audience, newRole: .coSinger) + } + onSwitchRoleState(.success, .none) + } else if singerRole == .coSinger && newRole == .audience { + // 3、CoSinger -》Audience + leaveChorus2(role: singerRole) + // 加入观众频道 + apiConfig?.engine?.joinChannelEx(byToken: apiConfig?.audienceChannelToken, + connection: AgoraRtcConnection(channelId: apiConfig?.channelName ?? "", localUid: apiConfig?.localUid ?? 0), + delegate: self, + mediaOptions: AgoraRtcChannelMediaOptions(), + joinSuccess: {[weak self] _,_, _ in + }) + apiConfig?.engine?.setParametersEx("{\"rtc.use_audio4\": true}", connection: AgoraRtcConnection(channelId: apiConfig?.channelName ?? "", localUid: apiConfig?.localUid ?? 0)) + self.singerRole = newRole + self.getEventHander { delegate in + delegate.onSingerRoleChanged(oldRole: oldRole, newRole: newRole) + } + onSwitchRoleState(.success, .none) + } else if singerRole == .leadSinger && newRole == .audience { + // 4、LeadSinger -》Audience + stopSing() + leaveChorus2(role: singerRole) + // 加入观众频道 + apiConfig?.engine?.joinChannelEx(byToken: apiConfig?.audienceChannelToken, + connection: AgoraRtcConnection(channelId: apiConfig?.channelName ?? "", localUid: apiConfig?.localUid ?? 0), + delegate: self, + mediaOptions: AgoraRtcChannelMediaOptions(), + joinSuccess: {[weak self] _,_, _ in + }) + apiConfig?.engine?.setParametersEx("{\"rtc.use_audio4\": true}", connection: AgoraRtcConnection(channelId: apiConfig?.channelName ?? "", localUid: apiConfig?.localUid ?? 0)) + self.singerRole = newRole + self.getEventHander { delegate in + delegate.onSingerRoleChanged(oldRole: oldRole, newRole: newRole) + } + onSwitchRoleState(.success, .none) + } else { + onSwitchRoleState(.fail, .noPermission) + print("Error! You can not switch role from \(singerRole) to \(newRole)!") + } + } + + private func becomeSoloSinger() { + apiConfig?.engine?.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":false}") + apiConfig?.engine?.setParameters("{\"che.audio.neteq.enable_stable_playout\":false}") + apiConfig?.engine?.setParameters("{\"che.audio.custom_bitrate\": 80000}") + apiConfig?.engine?.setAudioScenario(.chorus) + agoraPrint("becomeSoloSinger") + let mediaOption = AgoraRtcChannelMediaOptions() + mediaOption.autoSubscribeAudio = true + //mediaOption.autoSubscribeVideo = true + if apiConfig?.musicType == .mcc { + mediaOption.publishMediaPlayerId = Int(mediaPlayer?.getMediaPlayerId() ?? 0) + } else { + mediaOption.publishMediaPlayerId = Int(mediaPlayer?.getMediaPlayerId() ?? 0) + } + mediaOption.publishMediaPlayerAudioTrack = true + apiConfig?.engine?.updateChannel(with: mediaOption) + } + + /** + * 加入合唱 + */ + private func joinChorus(role: KTVSingRole, token: String, joinExChannelCallBack: @escaping JoinExChannelCallBack) { + self.onJoinExChannelCallBack = joinExChannelCallBack + if role == .leadSinger { + agoraPrint("joinChorus: KTVSingRoleMainSinger") + joinChorus2ndChannel(newRole: role, token: token) + } else if role == .coSinger { + + let mediaOption = AgoraRtcChannelMediaOptions() + mediaOption.autoSubscribeAudio = true + // mediaOption.autoSubscribeVideo = true + mediaOption.publishMediaPlayerAudioTrack = false + apiConfig?.engine?.updateChannel(with: mediaOption) + + if apiConfig?.musicType == .mcc { + (mediaPlayer as? AgoraMusicPlayerProtocol)?.openMedia(songCode: self.songCode , startPos: 0) + } else { + mediaPlayer?.open(self.songUrl, startPos: 0) + } + + joinChorus2ndChannel(newRole: role, token: token) + + } else if role == .audience { + agoraPrint("joinChorus fail!") + } + } + + private func joinChorus2ndChannel(newRole: KTVSingRole, token: String) { + let role = newRole + if role == .soloSinger || role == .audience { + agoraPrint("joinChorus2ndChannel with wrong role") + return + } + + agoraPrint("joinChorus2ndChannel role: \(role.rawValue)") + if newRole == .coSinger { + apiConfig?.engine?.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":false}") + apiConfig?.engine?.setParameters("{\"che.audio.neteq.enable_stable_playout\":false}") + apiConfig?.engine?.setParameters("{\"che.audio.custom_bitrate\": 48000}") + apiConfig?.engine?.setAudioScenario(.chorus) + } + + let mediaOption = AgoraRtcChannelMediaOptions() + // main singer do not subscribe 2nd channel + // co singer auto sub + mediaOption.autoSubscribeAudio = role != .leadSinger + // mediaOption.autoSubscribeVideo = false + mediaOption.publishMicrophoneTrack = newRole == .leadSinger + mediaOption.enableAudioRecordingOrPlayout = role != .leadSinger + mediaOption.clientRoleType = .broadcaster + + let rtcConnection = AgoraRtcConnection() + rtcConnection.channelId = apiConfig?.chorusChannelName ?? "" + rtcConnection.localUid = UInt(apiConfig?.localUid ?? 0) + subChorusConnection = rtcConnection + + joinChorusNewRole = role + let ret = apiConfig?.engine?.joinChannelEx(byToken: token, connection: rtcConnection, delegate: self, mediaOptions: mediaOption, joinSuccess: nil) + agoraPrint("joinChannelEx ret: \(ret ?? -999)") + if newRole == .coSinger { + let uid = UInt(songConfig?.mainSingerUid ?? 0) + let ret = + apiConfig?.engine?.muteRemoteAudioStreamEx(uid, mute: false, connection: singChannelConnection ?? AgoraRtcConnection()) + agoraPrint("muteRemoteAudioStream: \(uid), ret: \(ret ?? -1)") + } + apiConfig?.engine?.setParametersEx("{\"rtc.use_audio4\": true}", connection: rtcConnection) + + } + + private func leaveChorus2ndChannel(_ role: KTVSingRole) { + guard let config = songConfig else {return} + guard let subConn = subChorusConnection else {return} + if (role == .leadSinger) { + apiConfig?.engine?.leaveChannelEx(subConn) + } else if (role == .coSinger) { + apiConfig?.engine?.leaveChannelEx(subConn) + apiConfig?.engine?.muteRemoteAudioStreamEx(UInt(config.mainSingerUid), mute: false, connection: singChannelConnection ?? AgoraRtcConnection()) + } + } + + /** + * 离开合唱 + */ + + private func leaveChorus(role: KTVSingRole) { + agoraPrint("leaveChorus role: \(singerRole.rawValue)") + if role == .leadSinger { + mainSingerHasJoinChannelEx = false + leaveChorus2ndChannel(role) + } else if role == .coSinger { + mediaPlayer?.stop() + let mediaOption = AgoraRtcChannelMediaOptions() + // mediaOption.autoSubscribeAudio = true + // mediaOption.autoSubscribeVideo = false + mediaOption.publishMediaPlayerAudioTrack = false + apiConfig?.engine?.updateChannel(with: mediaOption) + leaveChorus2ndChannel(role) + apiConfig?.engine?.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":true}") + apiConfig?.engine?.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}") + apiConfig?.engine?.setParameters("{\"che.audio.custom_bitrate\": 48000}") + apiConfig?.engine?.setAudioScenario(.gameStreaming) + } else if role == .audience { + agoraPrint("joinChorus: KTVSingRoleAudience does not need to leaveChorus!") + } + } + +} + +extension KTVGiantChorusApiImpl { + + private func getEventHander(callBack:((KTVApiEventHandlerDelegate)-> Void)) { + for obj in eventHandlers.allObjects { + if obj is KTVApiEventHandlerDelegate { + callBack(obj as! KTVApiEventHandlerDelegate) + } + } + } + + private func _loadMusic(config: KTVSongConfiguration, mode: KTVLoadMusicMode, onMusicLoadStateListener: IMusicLoadStateListener){ + + songConfig = config + lastReceivedPosition = 0 + localPosition = 0 + + if (config.mode == .loadNone) { + return + } + + if mode == .loadLrcOnly { + loadLyric(with: songCode) { [weak self] url in + guard let self = self else { return } + agoraPrint("loadLrcOnly: songCode:\(self.songCode) ulr:\(String(describing: url))") +// if self.songCode != songCode { +// onMusicLoadStateListener.onMusicLoadFail(songCode: songCode, reason: .cancled) +// return +// } + if let urlPath = url, !urlPath.isEmpty { + self.lyricUrlMap[String(self.songCode)] = urlPath + self.setLyric(with: urlPath) { lyricUrl in + onMusicLoadStateListener.onMusicLoadSuccess(songCode: self.songCode, lyricUrl: urlPath) + } + } else { + onMusicLoadStateListener.onMusicLoadFail(songCode: self.songCode, reason: .noLyricUrl) + } + +// if (config.autoPlay) { +// // 主唱自动播放歌曲 +// if self.singerRole != .leadSinger { +// self.switchSingerRole(newRole: .soloSinger) { _, _ in +// +// } +// } +// self.startSing(songCode: self.songCode, startPos: 0) +// } + } + } else { + loadMusicListeners.setObject(onMusicLoadStateListener, forKey: "\(self.songCode)" as NSString) + onMusicLoadStateListener.onMusicLoadProgress(songCode: self.songCode, percent: 0, state: .preloading, msg: "", lyricUrl: "") + // TODO: 只有未缓存时才显示进度条 + if mcc?.isPreloaded(songCode: songCode) != 0 { + onMusicLoadStateListener.onMusicLoadProgress(songCode: self.songCode, percent: 0, state: .preloading, msg: "", lyricUrl: "") + } + + preloadMusic(with: songCode) { [weak self] status, songCode in + guard let self = self else { return } + if self.songCode != songCode { + onMusicLoadStateListener.onMusicLoadFail(songCode: songCode, reason: .cancled) + return + } + if status == .OK { + if mode == .loadMusicAndLrc { + // 需要加载歌词 + self.loadLyric(with: songCode) { url in + self.agoraPrint("loadMusicAndLrc: songCode:\(songCode) status:\(status.rawValue) ulr:\(String(describing: url))") + if self.songCode != songCode { + onMusicLoadStateListener.onMusicLoadFail(songCode: songCode, reason: .cancled) + return + } + if let urlPath = url, !urlPath.isEmpty { + self.lyricUrlMap[String(songCode)] = urlPath + self.setLyric(with: urlPath) { lyricUrl in + onMusicLoadStateListener.onMusicLoadSuccess(songCode: songCode, lyricUrl: urlPath) + } + } else { + onMusicLoadStateListener.onMusicLoadFail(songCode: songCode, reason: .noLyricUrl) + } +// if config.autoPlay { +// // 主唱自动播放歌曲 +// if self.singerRole != .leadSinger { +// self.switchSingerRole(newRole: .soloSinger) { _, _ in +// +// } +// } +// self.startSing(songCode: self.songCode, startPos: 0) +// } + } + } else if mode == .loadMusicOnly { + agoraPrint("loadMusicOnly: songCode:\(songCode) load success") +// if config.autoPlay { +// // 主唱自动播放歌曲 +// if self.singerRole != .leadSinger { +// self.switchSingerRole(newRole: .soloSinger) { _, _ in +// +// } +// } +// self.startSing(songCode: self.songCode, startPos: 0) +// } + onMusicLoadStateListener.onMusicLoadSuccess(songCode: songCode, lyricUrl: "") + } + } else { + agoraPrint("load music failed songCode:\(songCode)") + onMusicLoadStateListener.onMusicLoadFail(songCode: songCode, reason: .musicPreloadFail) + } + } + } + } + + private func loadLyric(with songCode: NSInteger, callBack:@escaping LyricCallback) { + agoraPrint("loadLyric songCode: \(songCode)") + let requestId: String = self.mcc?.getLyric(songCode: songCode, lyricType: 0) ?? "" + self.lyricCallbacks.updateValue(callBack, forKey: requestId) + } + + private func preloadMusic(with songCode: Int, callback: @escaping LoadMusicCallback) { + agoraPrint("preloadMusic songCode: \(songCode)") + if self.mcc?.isPreloaded(songCode: songCode) == 0 { + musicCallbacks.removeValue(forKey: String(songCode)) + callback(.OK, songCode) + return + } + let err = self.mcc?.preload(songCode: songCode) + if err == nil { + musicCallbacks.removeValue(forKey: String(songCode)) + callback(.error, songCode) + return + } + musicCallbacks.updateValue(callback, forKey: String(songCode)) + } + + private func setLyric(with url: String, callBack: @escaping LyricCallback) { + agoraPrint("setLyric url: (url)") + self.lrcControl?.onDownloadLrcData(url: url) + callBack(url) + } + + func startSing(songCode: Int, startPos: Int) { + let dict: [String: Any] = [ + "songCode": songCode, + "startPos": startPos + ] + sendCustomMessage(with: "startSing", dict: dict) + let role = singerRole + agoraPrint("startSing role: \(role.rawValue)") + if self.songCode != songCode { + agoraPrint("startSing failed: canceled") + return + } + mediaPlayer?.setPlayerOption("enable_multi_audio_track", value: 1) + apiConfig?.engine?.adjustPlaybackSignalVolume(Int(remoteVolume)) + let ret = (mediaPlayer as? AgoraMusicPlayerProtocol)?.openMedia(songCode: songCode, startPos: startPos) + mediaPlayer?.setLoopCount(-1) + agoraPrint("startSing->openMedia(\(songCode) fail: \(ret ?? -1)") + } + + func startSing(url: String, startPos: Int) { + let dict: [String: Any] = [ + "url": url, + "startPos": startPos + ] + sendCustomMessage(with: "startSing", dict: dict) + let role = singerRole + agoraPrint("startSing role: \(role.rawValue)") + if self.songUrl != songUrl { + agoraPrint("startSing failed: canceled") + return + } + apiConfig?.engine?.adjustPlaybackSignalVolume(Int(remoteVolume)) + let ret = mediaPlayer?.open(url, startPos: startPos) + agoraPrint("startSing->openMedia(\(url) fail: \(ret ?? -1)") + } + + /** + * 停止播放歌曲 + */ + @objc public func stopSing() { + agoraPrint("stopSing") + sendCustomMessage(with: "stopSing", dict: [:]) + let mediaOption = AgoraRtcChannelMediaOptions() + // mediaOption.autoSubscribeAudio = true + // mediaOption.autoSubscribeVideo = true + mediaOption.publishMediaPlayerAudioTrack = false + apiConfig?.engine?.updateChannelEx(with: mediaOption, connection: singChannelConnection ?? AgoraRtcConnection()) + + if mediaPlayer?.getPlayerState() != .stopped { + mediaPlayer?.stop() + } + + apiConfig?.engine?.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":true}") + apiConfig?.engine?.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}") + apiConfig?.engine?.setParameters("{\"che.audio.custom_bitrate\": 48000}") + apiConfig?.engine?.setAudioScenario(.gameStreaming) + } + + @objc public func setSingingScore(score: Int) { + self.singingScore = score + } + + @objc func setAudienceStreamMessage(dict: [String: Any]) { + sendStreamMessageWithDict(dict) { _ in + + } + } + + @objc public func setAudioPlayoutDelay(audioPlayoutDelay: Int) { + self.audioPlayoutDelay = audioPlayoutDelay + } + + @objc func enableProfessionalStreamerMode(_ enable: Bool) { + if self.isPublishAudio == false {return} + self.enableProfessional = enable + //专业非专业还需要根据是否佩戴耳机来判断是否开启3A + apiConfig?.engine?.setAudioProfile(enable ? .musicHighQualityStereo : .musicStandardStereo) + apiConfig?.engine?.setParameters("{\"che.audio.aec.enable\":\((enable && isWearingHeadPhones) ? false : true)}") + apiConfig?.engine?.setParameters("{\"che.audio.agc.enable\":\((enable && isWearingHeadPhones) ? false : true)}") + apiConfig?.engine?.setParameters("{\"che.audio.ans.enable\":\((enable && isWearingHeadPhones) ? false : true)}") + apiConfig?.engine?.setParameters("{\"che.audio.md.enable\": false}") + } + + func joinChorus(newRole: KTVSingRole) { + agoraPrint("joinChorus: \(newRole)") + let singChannelMediaOptions = AgoraRtcChannelMediaOptions() + singChannelMediaOptions.autoSubscribeAudio = true + singChannelMediaOptions.publishMicrophoneTrack = true + singChannelMediaOptions.clientRoleType = .broadcaster +// singChannelMediaOptions.parameters = "{\"che.audio.max_mixed_participants\": 8}" + if newRole == .leadSinger { + // 主唱不参加TopN + singChannelMediaOptions.isAudioFilterable = false + apiConfig?.engine?.setParameters("{\"che.audio.filter_streams\":\(apiConfig?.routeSelectionConfig.streamNum ?? 0)}") + } else { + apiConfig?.engine?.setParameters("{\"che.audio.filter_streams\":\((apiConfig?.routeSelectionConfig.streamNum ?? 0) - 1)}") + } + + guard let token = apiConfig?.chorusChannelToken, let singConnection = singChannelConnection else {return} + + + // 加入演唱频道 + let ret = apiConfig?.engine?.joinChannelEx(byToken: token, connection: singConnection, delegate: self, mediaOptions: singChannelMediaOptions) + apiConfig?.engine?.setParametersEx("{\"rtc.use_audio4\": true}", connection: singConnection) + if apiConfig?.routeSelectionConfig.type == .topN || apiConfig?.routeSelectionConfig.type == .byDelayAndTopN { + if newRole == .leadSinger { + apiConfig?.engine?.setParameters("{\"che.audio.filter_streams\":\(apiConfig?.routeSelectionConfig.streamNum)}") + } else { + apiConfig?.engine?.setParameters("{\"che.audio.filter_streams\":\((apiConfig?.routeSelectionConfig.streamNum ?? 0) - 1)}") + } + } else { + apiConfig?.engine?.setParameters("{\"che.audio.filter_streams\": 0}") + } + + let res = apiConfig?.engine?.enableAudioVolumeIndicationEx(50, smooth: 10, reportVad: true, connection: singConnection) + switch newRole { + case .leadSinger: + // 更新音频配置 + apiConfig?.engine?.setAudioScenario(.chorus) + apiConfig?.engine?.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":false}") + apiConfig?.engine?.setParameters("{\"che.audio.neteq.enable_stable_playout\":false}") + apiConfig?.engine?.setParameters("{\"che.audio.custom_bitrate\": 80000}") + + // mpk流加入频道 + let options = AgoraRtcChannelMediaOptions() + options.autoSubscribeAudio = false + options.autoSubscribeVideo = false + options.publishMicrophoneTrack = false + options.publishMediaPlayerAudioTrack = true + options.publishMediaPlayerId = Int(mediaPlayer?.getMediaPlayerId() ?? 0) + options.clientRoleType = .broadcaster + // 防止主唱和合唱听见mpk流的声音 + options.enableAudioRecordingOrPlayout = false + + let rtcConnection = AgoraRtcConnection() + rtcConnection.channelId = apiConfig?.chorusChannelName ?? "" + rtcConnection.localUid = UInt(apiConfig?.musicStreamUid ?? 0) + mpkConnection = rtcConnection + + // 加入演唱频道 + let delegate = NSObject() + let ret = apiConfig?.engine?.joinChannelEx(byToken: apiConfig?.musicChannelToken, connection: mpkConnection ?? AgoraRtcConnection(), delegate: nil, mediaOptions: options) + apiConfig?.engine?.setParametersEx("{\"rtc.use_audio4\": true}", connection: mpkConnection ?? AgoraRtcConnection()) + + + case .coSinger: + // 防止主唱和合唱听见mpk流的声音 + apiConfig?.engine?.muteRemoteAudioStreamEx(UInt(apiConfig?.musicStreamUid ?? 0), mute: true, connection: singChannelConnection ?? AgoraRtcConnection()) + + // 更新音频配置 + apiConfig?.engine?.setAudioScenario(.chorus) + apiConfig?.engine?.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":false}") + apiConfig?.engine?.setParameters("{\"che.audio.neteq.enable_stable_playout\":false}") + apiConfig?.engine?.setParameters("{\"che.audio.custom_bitrate\": 48000}") + + // 预加载歌曲成功 + // 导唱 + mediaPlayer?.setPlayerOption("enable_multi_audio_track", value: 1) + if apiConfig?.musicType == .mcc { + (mediaPlayer as? AgoraMusicPlayerProtocol)?.openMedia(songCode: self.songCode , startPos: 0) // TODO open failed + } else { + mediaPlayer?.open(songUrl, startPos: 0) // TODO open failed + } + default: + agoraPrintError("JoinChorus with Wrong role: \(singerRole)") + } + + + apiConfig?.engine?.muteRemoteAudioStreamEx(UInt(apiConfig?.musicStreamUid ?? 0), mute: true, connection: singChannelConnection ?? AgoraRtcConnection()) + // 加入演唱频道后,创建data stream + renewInnerDataStreamId() + } + + func leaveChorus2(role: KTVSingRole) { + agoraPrint("leaveChorus: \(role)") + switch role { + case .leadSinger: + apiConfig?.engine?.leaveChannelEx(mpkConnection ?? AgoraRtcConnection()) + case .coSinger: + mediaPlayer?.stop() + + // 更新音频配置 + apiConfig?.engine?.setAudioScenario(.gameStreaming) + apiConfig?.engine?.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":true}") + apiConfig?.engine?.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}") + apiConfig?.engine?.setParameters("{\"che.audio.custom_bitrate\": 48000}") + default: + agoraPrint("JoinChorus with wrong role: \(singerRole)") + } + apiConfig?.engine?.leaveChannelEx(singChannelConnection ?? AgoraRtcConnection()) + } + +} + +// rtc的代理回调 +extension KTVGiantChorusApiImpl: AgoraRtcEngineDelegate { + + public func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String, withUid uid: UInt, elapsed: Int) { + agoraPrint("didJoinChannel channel:\(channel) uid: \(uid)") + if joinChorusNewRole == .leadSinger { + mainSingerHasJoinChannelEx = true + onJoinExChannelCallBack?(true, nil) + } + if joinChorusNewRole == .coSinger { + self.onJoinExChannelCallBack?(true, nil) + } + if let subChorusConnection = subChorusConnection { + apiConfig?.engine?.enableAudioVolumeIndicationEx(50, smooth: 10, reportVad: true, connection: subChorusConnection) + } + } + + public func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) { + if errorCode != .joinChannelRejected {return} + agoraPrintError("join ex channel failed") + engine.setAudioScenario(.gameStreaming) + if joinChorusNewRole == .leadSinger { + mainSingerHasJoinChannelEx = false + onJoinExChannelCallBack?(false, .joinChannelFail) + } + + if joinChorusNewRole == .coSinger { + self.onJoinExChannelCallBack?(false, .joinChannelFail) + } + } + + //合唱频道的声音回调 + public func rtcEngine(_ engine: AgoraRtcEngineKit, reportAudioVolumeIndicationOfSpeakers speakers: [AgoraRtcAudioVolumeInfo], totalVolume: Int) { + getEventHander { delegate in + delegate.onChorusChannelAudioVolumeIndication(speakers: speakers, totalVolume: totalVolume) + } + didKTVAPIReceiveAudioVolumeIndication(with: speakers, totalVolume: totalVolume) + } + + public func rtcEngine(_ engine: AgoraRtcEngineKit, tokenPrivilegeWillExpire token: String) { + getEventHander { delegate in + delegate.onTokenPrivilegeWillExpire() + } + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, receiveStreamMessageFromUid uid: UInt, streamId: Int, data: Data) { + didKTVAPIReceiveStreamMessageFrom(uid: NSInteger(uid), streamId: streamId, data: data) + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, audioMetadataReceived uid: UInt, metadata: Data) { + guard let time: LrcTime = try? LrcTime(serializedData: metadata) else {return} + if time.type == .lrcTime && self.singerRole == .audience { + self.setProgress(with: Int(time.ts)) + } + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) { + guard let musicId = apiConfig?.musicStreamUid,let mainSingerId = songConfig?.mainSingerUid else {return} + if uid != musicId && subScribeSingerMap.count < 8 { + apiConfig?.engine?.muteRemoteAudioStreamEx(uid, mute: false, connection: singChannelConnection ?? AgoraRtcConnection()) + if uid != mainSingerId { + subScribeSingerMap[Int(uid)] = 0 + } + } else if uid != musicId && subScribeSingerMap.count == 8 { + apiConfig?.engine?.muteRemoteAudioStreamEx(uid, mute: false, connection: singChannelConnection ?? AgoraRtcConnection()) + } + if uid != musicId && uid != mainSingerId { + singerList.append(Int(uid)) + } + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, didLeaveChannelWith stats: AgoraChannelStats) { + subScribeSingerMap.removeAll() + singerList.removeAll() + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, didOfflineOfUid uid: UInt, reason: AgoraUserOfflineReason) { + subScribeSingerMap.removeValue(forKey: Int(uid)) + if let index = singerList.firstIndex(of: Int(uid)) { + singerList.remove(at: index) + } + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, remoteAudioStats stats: AgoraRtcRemoteAudioStats) { + guard let musicId = apiConfig?.musicStreamUid,let mainSingerId = songConfig?.mainSingerUid else {return} + if apiConfig?.routeSelectionConfig.type == .random || apiConfig?.routeSelectionConfig.type == .topN { return } + let uid = stats.uid + if uid == mainSingerId { + mainSingerDelay = stats.e2eDelay + } + if uid != mainSingerId && uid != musicId && subScribeSingerMap[Int(uid)] != nil { + subScribeSingerMap[Int(uid)] = stats.e2eDelay + } + } +} + +//需要外部转发的方法 主要是dataStream相关的 +extension KTVGiantChorusApiImpl { + + @objc func didAudioPublishStateChange(newState: AgoraStreamPublishState) { + self.isPublishAudio = newState == .published + enableProfessionalStreamerMode(self.enableProfessional) + agoraPrint("PublishStateChange:\(newState)") + } + + @objc func didAudioRouteChanged( routing: AgoraAudioOutputRouting) { + agoraPrint("Route changed:\(routing)") + self.audioRouting = routing.rawValue + let headPhones: [AgoraAudioOutputRouting] = [.headset, .bluetoothDeviceHfp, .bluetoothDeviceA2dp, .headsetNoMic] + let wearHeadPhone: Bool = headPhones.contains(routing) + if wearHeadPhone == self.isWearingHeadPhones { + return + } + self.isWearingHeadPhones = wearHeadPhone + enableProfessionalStreamerMode(self.enableProfessional) + } + + @objc public func didKTVAPIReceiveStreamMessageFrom(uid: NSInteger, streamId: NSInteger, data: Data) { + let role = singerRole + if isRelease {return} + guard let dict = dataToDictionary(data: data), let cmd = dict["cmd"] as? String else { return } + agoraPrint("recv dict:\(dict)") + switch cmd { + case "setLrcTime": + handleSetLrcTimeCommand(dict: dict, role: role) + case "PlayerState": + handlePlayerStateCommand(dict: dict, role: role) + case "setVoicePitch": + handleSetVoicePitchCommand(dict: dict, role: role) + default: + break + } + } + + private func handleSetLrcTimeCommand(dict: [String: Any], role: KTVSingRole) { + guard let position = dict["time"] as? Int64, + let duration = dict["duration"] as? Int64, + let realPosition = dict["realTime"] as? Int64, + // let songCode = dict["songCode"] as? Int64, + let mainSingerState = dict["playerState"] as? Int, + let ntpTime = dict["ntp"] as? Int, + let songId = dict["songIdentifier"] as? String + else { return } + #if DUBUG + print("realTime:\(realPosition) position:\(position) lastNtpTime:\(lastNtpTime) ntpTime:\(ntpTime) ntpGap:\(ntpTime - self.lastNtpTime) ") + #endif + //如果接收到的歌曲和自己本地的歌曲不一致就不更新进度 +// guard songCode == self.songCode else { +// agoraPrint("local songCode[\(songCode)] is not equal to recv songCode[\(self.songCode)] role: \(singerRole.rawValue)") +// return +// } + + self.lastNtpTime = ntpTime + self.remotePlayerDuration = TimeInterval(duration) + + let state = AgoraMediaPlayerState(rawValue: mainSingerState) ?? .stopped +// self.lastMainSingerUpdateTime = Date().milListamp +// self.remotePlayerPosition = TimeInterval(realPosition) + if self.playerState != state { + #if DUBUG + print("[setLrcTime] recv state: \(self.playerState.rawValue)->\(state.rawValue) role: \(singerRole.rawValue) role: \(singerRole.rawValue)") + #endif + if state == .playing, singerRole == .coSinger, playerState == .openCompleted { + //如果是伴唱等待主唱开始播放,seek 到指定位置开始播放保证歌词显示位置准确 + self.localPlayerPosition = self.lastMainSingerUpdateTime - Double(position) + print("localPlayerPosition:playerKit:handleSetLrcTimeCommand \(localPlayerPosition)") + agoraPrint("seek toPosition: \(position)") + mediaPlayer?.seek(toPosition: Int(position)) + } + + syncPlayStateFromRemote(state: state, needDisplay: false) + } + + if role == .coSinger { + self.lastMainSingerUpdateTime = Date().milListamp + self.remotePlayerPosition = TimeInterval(realPosition) + handleCoSingerRole(dict: dict) + } else if role == .audience { + if dict.keys.contains("ver") { + recvFromDataStream = false + } else { + recvFromDataStream = true + if self.songIdentifier == songId { + self.lastMainSingerUpdateTime = Date().milListamp + self.remotePlayerPosition = TimeInterval(realPosition) + } else { + self.lastMainSingerUpdateTime = 0 + self.remotePlayerPosition = 0 + } + handleAudienceRole(dict: dict) + } + } + } + + private func handlePlayerStateCommand(dict: [String: Any], role: KTVSingRole) { + let mainSingerState: Int = dict["state"] as? Int ?? 0 + let state = AgoraMediaPlayerState(rawValue: mainSingerState) ?? .idle + +// if state == .playing, singerRole == .coSinger, playerState == .openCompleted { +// //如果是伴唱等待主唱开始播放,seek 到指定位置开始播放保证歌词显示位置准确 +// self.localPlayerPosition = getPlayerCurrentTime() +// print("localPlayerPosition:playerKit:handlePlayerStateCommand \(localPlayerPosition)") +// agoraPrint("seek toPosition: \(self.localPlayerPosition)") +// mediaPlayer?.seek(toPosition: Int(self.localPlayerPosition)) +// } + + agoraPrint("recv state with MainSinger: \(state.rawValue)") + syncPlayStateFromRemote(state: state, needDisplay: true) + } + + private func handleSetVoicePitchCommand(dict: [String: Any], role: KTVSingRole) { + if role == .audience, let voicePitch = dict["pitch"] as? Double { + self.pitch = voicePitch + } + } + + private func handleCoSingerRole(dict: [String: Any]) { + + if mediaPlayer?.getPlayerState() == .playing { + let localNtpTime = getNtpTimeInMs() + let localPosition = localNtpTime - Int(localPlayerSystemTime) + localPosition + let expectPosition = Int(dict["time"] as? Int64 ?? 0) + localNtpTime - Int(dict["ntp"] as? Int64 ?? 0) + self.audioPlayoutDelay + let threshold = expectPosition - Int(localPosition) + let ntpTime = dict["ntp"] as? Int ?? 0 + let time = dict["time"] as? Int64 ?? 0 + #if DUBUG + agoraPrint("checkNtp, diff:\(threshold), localNtp:\(getNtpTimeInMs()), localPosition:\(localPosition), audioPlayoutDelay:\(audioPlayoutDelay), remoteDiff:\(String(describing: ntpTime - Int(time)))") + #endif + if abs(threshold) > 50 { + agoraPrint("need seek, time:\(threshold)") + mediaPlayer?.seek(toPosition: expectPosition) + } + } + + } + + private func handleAudienceRole(dict: [String: Any]) { + // do something for audience role + guard let position = dict["time"] as? Int64, + let duration = dict["duration"] as? Int64, + let realPosition = dict["realTime"] as? Int64, + let songCode = dict["songCode"] as? Int64, + let mainSingerState = dict["playerState"] as? Int + else { return } + } + + @objc public func didKTVAPIReceiveAudioVolumeIndication(with speakers: [AgoraRtcAudioVolumeInfo], totalVolume: NSInteger) { + if playerState != .playing {return} + if singerRole == .audience {return} + + guard var pitch: Double = speakers.first?.voicePitch else {return} + pitch = isNowMicMuted ? 0 : pitch + //如果mpk不是playing状态 pitch = 0 + if mediaPlayer?.getPlayerState() != .playing {pitch = 0} + self.pitch = pitch + //将主唱的pitch同步到观众 +// if isMainSinger() { +// let dict: [String: Any] = [ "cmd": "setVoicePitch", +// "pitch": pitch, +// ] +// sendStreamMessageWithDict(dict, success: nil) +// } + } + + @objc public func didKTVAPILocalAudioStats(stats: AgoraRtcLocalAudioStats) { + if useCustomAudioSource == true {return} + audioPlayoutDelay = Int(stats.audioPlayoutDelay) + } + + @objc func didAudioMetadataReceived( uid: UInt, metadata: Data) { + guard let time: LrcTime = try? LrcTime(serializedData: metadata) else {return} + if time.type == .lrcTime && self.singerRole == .audience { + self.setProgress(with: Int(time.ts)) + } + } + +} + +//private method +extension KTVGiantChorusApiImpl { + + private func initTimer() { + + guard timer == nil else { return } + + timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true, block: {[weak self] timer in + guard let self = self else { + timer.invalidate() + return + } + + var current = self.getPlayerCurrentTime() + if self.singerRole == .audience && (Date().milListamp - (self.lastMainSingerUpdateTime )) > 1000 { + return + } + + if self.singerRole != .audience && (Date().milListamp - (self.lastReceivedPosition )) > 1000 { + return + } + + if self.oldPitch == self.pitch && (self.oldPitch != 0 && self.pitch != 0) { + self.pitch = -1 + } + + if self.singerRole != .audience { + current = Date().milListamp - self.lastReceivedPosition + Double(self.localPosition) + } + if self.singerRole == .audience && !recvFromDataStream { + + } else { + if self.singerRole != .audience { + current = Date().milListamp - self.lastReceivedPosition + Double(self.localPosition) + if self.singerRole == .leadSinger || self.singerRole == .soloSinger { + var time: LrcTime = LrcTime() + time.forward = true + time.ts = Int64(current) + Int64(self.startHighTime) + time.songID = songIdentifier + time.type = .lrcTime + //大合唱的uid是musicuid + time.uid = Int32(Int(apiConfig?.musicStreamUid ?? 0)) + sendMetaMsg(with: time) + } + } + self.setProgress(with: Int(current) + Int(self.startHighTime)) + } + self.oldPitch = self.pitch + }) + } + + private func setPlayerState(with state: AgoraMediaPlayerState) { + playerState = state + updateRemotePlayBackVolumeIfNeed() + updateTimer(with: state) + } + + private func updateRemotePlayBackVolumeIfNeed() { + let role = singerRole + if role == .audience { + apiConfig?.engine?.adjustPlaybackSignalVolume(100) + return + } + + let vol = self.playerState == .playing ? remoteVolume : 100 + apiConfig?.engine?.adjustPlaybackSignalVolume(Int(vol)) + } + + private func updateTimer(with state: AgoraMediaPlayerState) { + DispatchQueue.main.async { + if state == .paused || state == .stopped { + self.pauseTimer() + } else if state == .playing { + self.startTimer() + } + } + } + + //timer method + private func startTimer() { + guard let timer = self.timer else {return} + if isPause == false { + RunLoop.current.add(timer, forMode: .common) + self.timer?.fire() + } else { + resumeTimer() + } + } + + private func resumeTimer() { + if isPause == false {return} + isPause = false + timer?.fireDate = Date() + } + + private func pauseTimer() { + if isPause == true {return} + isPause = true + timer?.fireDate = Date.distantFuture + } + + private func freeTimer() { + guard let _ = self.timer else {return} + self.timer?.invalidate() + self.timer = nil + } + + private func getPlayerCurrentTime() -> TimeInterval { + let role = singerRole + if role == .soloSinger || role == .leadSinger{ + let time = Date().milListamp - localPlayerPosition + return time + } else if role == .coSinger { + if playerState == .playing || playerState == .paused { + let time = Date().milListamp - localPlayerPosition + return time + } + } + + var position = Date().milListamp - self.lastMainSingerUpdateTime + remotePlayerPosition + if playerState != .playing { + position = remotePlayerPosition + } + return position + } + + private func syncPlayStateFromRemote(state: AgoraMediaPlayerState, needDisplay: Bool) { + let role = singerRole + if role == .coSinger { + if state == .stopped { + // stopSing() + } else if state == .paused { + pausePlay() + } else if state == .playing { + resumeSing() + } else if (state == .playBackAllLoopsCompleted && needDisplay == true) { + getEventHander { delegate in + delegate.onMusicPlayerStateChanged(state: state, reason: .none, isLocal: true) + } + } + } else { + self.playerState = state + getEventHander { delegate in + delegate.onMusicPlayerStateChanged(state: self.playerState, reason: .none, isLocal: false) + } + } + } + + private func pausePlay() { + mediaPlayer?.pause() + } + + private func dataToDictionary(data: Data) -> [String: Any]? { + do { + let json = try JSONSerialization.jsonObject(with: data, options: []) + return json as? [String: Any] + } catch { + print("Error decoding data: (error.localizedDescription)") + return nil + } + } + + private func compactDictionaryToData(_ dict: [String: Any]) -> Data? { + do { + let jsonData = try JSONSerialization.data(withJSONObject: dict, options: []) + return jsonData + } catch { + print("Error encoding data: (error.localizedDescription)") + return nil + } + } + + private func getNtpTimeInMs() -> Int { + var localNtpTime: Int = Int(apiConfig?.engine?.getNtpWallTimeInMs() ?? 0) + + if localNtpTime != 0 { + localNtpTime = localNtpTime + 2208988800 * 1000 + } + + return localNtpTime + } + + private func syncPlayState(state: AgoraMediaPlayerState, reason: AgoraMediaPlayerReason) { + let dict: [String: Any] = ["cmd": "PlayerState", "userId": apiConfig?.localUid as Any, "state": state.rawValue, "reason": "\(reason.rawValue)"] + sendStreamMessageWithDict(dict, success: nil) + } + +// private func sendCustomMessage(with event: String, label: String) { +// apiConfig?.engine?.sendCustomReportMessage(messageId, category: version, event: event, label: label, value: 0) +// apiRepoter?.reportFuncEvent(name: event, value: <#T##[String : Any]#>, ext: <#T##[String : Any]#>) +// } + + private func sendCustomMessage(with event: String, dict: [String: Any]) { + apiRepoter?.reportFuncEvent(name: event, value: dict, ext: [:]) + } + + private func sendStreamMessageWithDict(_ dict: [String: Any], success: ((_ success: Bool) -> Void)?) { + let messageData = compactDictionaryToData(dict as [String: Any]) + let code = apiConfig?.engine?.sendStreamMessageEx(dataStreamId, data: messageData ?? Data(), connection: singChannelConnection ?? AgoraRtcConnection()) + if code == 0 && success != nil { success!(true) } + if code != 0 { + print("sendStreamMessage fail: \(String(describing: code))") + } + } + + private func syncPlayState(_ state: AgoraMediaPlayerState) { + let dict: [String: Any] = [ "cmd": "PlayerState", "userId": apiConfig?.localUid as Any, "state": "\(state.rawValue)" ] + sendStreamMessageWithDict(dict, success: nil) + } + + private func setProgress(with pos: Int) { + lrcControl?.onUpdatePitch(pitch: Float(self.pitch)) + lrcControl?.onUpdateProgress(progress: pos > 200 ? pos - 200 : pos) + } + + private func sendMetaMsg(with time: LrcTime) { + let data: Data? = try? time.serializedData() + let code = apiConfig?.engine?.sendAudioMetadataEx(mpkConnection ?? AgoraRtcConnection(), metadata: data ?? Data()) + if code != 0 { + // agoraPrint("sendStreamMessage fail: \(String(describing: code))") + } + } +} + +//主要是MPK的回调 +extension KTVGiantChorusApiImpl: AgoraRtcMediaPlayerDelegate { + + func AgoraRtcMediaPlayer(_ playerKit: AgoraRtcMediaPlayerProtocol, didChangedTo position_ms: Int, atTimestamp timestamp_ms: TimeInterval) { + self.lastReceivedPosition = Date().milListamp + self.localPosition = Int(position_ms) + self.localPlayerSystemTime = timestamp_ms + self.localPlayerPosition = Date().milListamp - Double(position_ms) + if isMainSinger() && getPlayerCurrentTime() > TimeInterval(self.audioPlayoutDelay) { + let dict: [String: Any] = [ "cmd": "setLrcTime", + "duration": self.playerDuration, + "time": position_ms - audioPlayoutDelay, + "realTime":position_ms, + "ntp": timestamp_ms, + "playerState": self.playerState.rawValue, + "songIdentifier": songIdentifier, + "forward": true, + "ver":2, + ] + #if DEBUG + print("position_ms:\(position_ms), ntp:\(getNtpTimeInMs()), delta:\(self.getNtpTimeInMs() - position_ms), autoPlayoutDelay:\(self.audioPlayoutDelay), state:\(self.playerState.rawValue)") + #endif + sendStreamMessageWithDict(dict, success: nil) + } + } + + func AgoraRtcMediaPlayer(_ playerKit: any AgoraRtcMediaPlayerProtocol, didChangedTo state: AgoraMediaPlayerState, reason: AgoraMediaPlayerReason) { + agoraPrint("agoraRtcMediaPlayer didChangedToState: \(state.rawValue) \(self.songCode)") + if isRelease {return} + self.playerState = state + if state == .openCompleted { + self.localPlayerPosition = Date().milListamp + self.playerDuration = TimeInterval(mediaPlayer?.getDuration() ?? 0) + playerKit.selectMultiAudioTrack(1, publishTrackIndex: 1) + if isMainSinger() { //主唱播放,通过同步消息“setLrcTime”通知伴唱play + playerKit.play() + } + self.startProcessDelay() + } else if state == .stopped { + apiConfig?.engine?.adjustPlaybackSignalVolume(100) + self.localPlayerPosition = Date().milListamp + self.playerDuration = 0 + } + else if state == .paused { + apiConfig?.engine?.adjustPlaybackSignalVolume(100) + } else if state == .playing { + apiConfig?.engine?.adjustPlaybackSignalVolume(Int(remoteVolume)) + self.localPlayerPosition = Date().milListamp - Double(mediaPlayer?.getPosition() ?? 0) + } else if state == .stopped { + self.stopProcessDelay() + } + + if isMainSinger() { + syncPlayState(state: state, reason: reason) + } + agoraPrint("recv state with player callback : \(state.rawValue)") + if state == .playBackAllLoopsCompleted && singerRole == .coSinger {//可能存在伴唱不返回allloopbackComplete状态 这个状态通过主唱的playerState来同步 + return + } + getEventHander { delegate in + delegate.onMusicPlayerStateChanged(state: state, reason: .none, isLocal: true) + } + } + + private func isMainSinger() -> Bool { + return singerRole == .soloSinger || singerRole == .leadSinger + } +} + +//主要是MCC的回调 +extension KTVGiantChorusApiImpl: AgoraMusicContentCenterEventDelegate { + + func onSongSimpleInfoResult(_ requestId: String, songCode: Int, simpleInfo: String?, reason: AgoraMusicContentCenterStateReason) { + if let jsonData = simpleInfo?.data(using: .utf8) { + do { + let jsonMsg = try JSONSerialization.jsonObject(with: jsonData, options: []) as! [String: Any] + let format = jsonMsg["format"] as! [String: Any] + let highPart = format["highPart"] as! [[String: Any]] + let highStartTime = highPart[0]["highStartTime"] as! Int + let highEndTime = highPart[0]["highEndTime"] as! Int + let time = highStartTime + startHighTime = time + self.lrcControl?.onHighPartTime(highStartTime: highStartTime, highEndTime: highEndTime) + } catch { + agoraPrintError("Error while parsing JSON: \(error.localizedDescription)") + } + } + if (reason == .errorGateway) { + getEventHander { delegate in + delegate.onTokenPrivilegeWillExpire() + } + } + } + + func onMusicChartsResult(_ requestId: String, result: [AgoraMusicChartInfo], reason: AgoraMusicContentCenterStateReason) { + guard let callback = musicChartDict[requestId] else {return} + callback(requestId, reason, result) + musicChartDict.removeValue(forKey: requestId) + if (reason == .errorGateway) { + getEventHander { delegate in + delegate.onTokenPrivilegeWillExpire() + } + } + } + + func onMusicCollectionResult(_ requestId: String, result: AgoraMusicCollection, reason: AgoraMusicContentCenterStateReason) { + guard let callback = musicSearchDict[requestId] else {return} + callback(requestId, reason, result) + musicSearchDict.removeValue(forKey: requestId) + if (reason == .errorGateway) { + getEventHander { delegate in + delegate.onTokenPrivilegeWillExpire() + } + } + } + + func onLyricResult(_ requestId: String, songCode: Int, lyricUrl: String?, reason: AgoraMusicContentCenterStateReason) { + guard let lrcUrl = lyricUrl else {return} + let callback = self.lyricCallbacks[requestId] + guard let lyricCallback = callback else { return } + self.lyricCallbacks.removeValue(forKey: requestId) + if (reason == .errorGateway) { + getEventHander { delegate in + delegate.onTokenPrivilegeWillExpire() + } + } + if lrcUrl.isEmpty { + lyricCallback(nil) + return + } + lyricCallback(lrcUrl) + } + + func onPreLoadEvent(_ requestId: String, songCode: Int, percent: Int, lyricUrl: String?, state: AgoraMusicContentCenterPreloadState, reason: AgoraMusicContentCenterStateReason) { + if let listener = self.loadMusicListeners.object(forKey: "\(songCode)" as NSString) as? IMusicLoadStateListener { + listener.onMusicLoadProgress(songCode: songCode, percent: percent, state: state, msg: String(reason.rawValue), lyricUrl: lyricUrl) + } + if (state == .preloading) { return } + let SongCode = "\(songCode)" + guard let block = self.musicCallbacks[SongCode] else { return } + self.musicCallbacks.removeValue(forKey: SongCode) + if (reason == .errorGateway) { + getEventHander { delegate in + delegate.onTokenPrivilegeWillExpire() + } + } + block(state, songCode) + } + +} + +extension KTVGiantChorusApiImpl { + + private func sendSyncPitch(_ pitch: Double) { + var msg: [String:Any] = [:] + msg["cmd"] = "setVoicePitch" + msg["pitch"] = pitch + sendStreamMessageWithDict(msg) { _ in + + } + } + + private func startSyncPitch() { + print("startSyncPitch") + mStopSyncPitch = false + let queue = DispatchQueue(label: "com.example.syncpitch") + mSyncPitchTimer = DispatchSource.makeTimerSource(queue: queue) + mSyncPitchTimer?.schedule(deadline: .now(), repeating: .milliseconds(50)) + mSyncPitchTimer?.setEventHandler { [weak self] in + guard let self = self else { return } + if !self.mStopSyncPitch && + playerState == .playing && + (singerRole == .leadSinger || singerRole == .soloSinger) { + self.sendSyncPitch(pitch) + } + } + mSyncPitchTimer?.resume() + } + + private func stopSyncPitch() { + print("stopSyncPitch") + mStopSyncPitch = true + pitch = 0.0 + + mSyncPitchTimer?.cancel() + mSyncPitchTimer = nil + } + + private func sendSyncScore() { + print("sendSyncScore") + var dictionary: [String: Any] = [:] + dictionary["service"] = "audio_smart_mixer" + dictionary["version"] = "V1" + var payload: [String: Any] = [:] + payload["cname"] = apiConfig?.chorusChannelName + payload["uid"] = String(apiConfig?.localUid ?? 0) + payload["uLv"] = -1 + payload["specialLabel"] = 0 + payload["audioRoute"] = audioRouting + payload["vocalScore"] = singingScore + dictionary["payload"] = payload + sendStreamMessageWithDict(dictionary) { _ in + + } + } + + private func startSyncScore() { + print("startSyncScore") + mStopSyncScore = false + let queue = DispatchQueue(label: "com.example.syncscore") + mSyncScoreTimer = DispatchSource.makeTimerSource(queue: queue) + mSyncScoreTimer?.schedule(deadline: .now(), repeating: .milliseconds(3000)) + mSyncScoreTimer?.setEventHandler { [weak self] in + guard let self = self else { return } + if !self.mStopSyncScore && + playerState == .playing && + (singerRole == .leadSinger || singerRole == .coSinger) { + self.sendSyncScore() + } + } + mSyncScoreTimer?.resume() + } + + private func stopSyncScore() { + print("stopSyncScore") + mStopSyncScore = true + singingScore = 0 + + mSyncScoreTimer?.cancel() + mSyncScoreTimer = nil + } + + // -1: unknown,0:非K歌状态,1:K歌播放状态,2:K歌暂停状态) + private func getCloudConvergenceStatus() -> Int { + var status = -1 + switch playerState { + case .playing: + status = 1 + case .paused: + status = 2 + default: + break + } + return status + } + + private func sendSyncCloudConvergenceStatus() { + print("sendSyncCloudConvergenceStatus") + var dictionary: [String: Any] = [:] + dictionary["service"] = "audio_smart_mixer_status" + dictionary["version"] = "V1" + var payload: [String: Any] = [:] + payload["Ts"] = getNtpTimeInMs() + payload["cname"] = apiConfig?.chorusChannelName + payload["status"] = getCloudConvergenceStatus() + payload["bgmUID"] = mpkConnection?.localUid + payload["leadsingerUID"] = String(songConfig?.mainSingerUid ?? 0) + dictionary["payload"] = payload + sendStreamMessageWithDict(dictionary) { _ in + + } + } + + private func startSyncCloudConvergenceStatus() { + print("startSyncCloudConvergenceStatus") + mStopSyncCloudConvergenceStatus = false + let queue = DispatchQueue(label: "com.example.synccloudconvergencestatus") + mSyncCloudConvergenceStatusTimer = DispatchSource.makeTimerSource(queue: queue) + mSyncCloudConvergenceStatusTimer?.schedule(deadline: .now(), repeating: .milliseconds(200)) + mSyncCloudConvergenceStatusTimer?.setEventHandler { [weak self] in + guard let self = self else { return } + if !self.mStopSyncCloudConvergenceStatus && + singerRole == .leadSinger { + self.sendSyncCloudConvergenceStatus() + } + } + mSyncCloudConvergenceStatusTimer?.resume() + } + + private func stopSyncCloudConvergenceStatus() { + print("stopSyncCloudConvergenceStatus") + mStopSyncCloudConvergenceStatus = true + + mSyncCloudConvergenceStatusTimer?.cancel() + mSyncCloudConvergenceStatusTimer = nil + } + +} + +extension KTVGiantChorusApiImpl { + + private func processDelayTask() { + if !mStopProcessDelay && singerRole != .audience { + let n = singerRole == .leadSinger ? apiConfig?.routeSelectionConfig.streamNum : (apiConfig?.routeSelectionConfig.streamNum ?? 1) - 1 + let sortedEntries = subScribeSingerMap.sorted(by: { $0.value < $1.value }) + let other = Array(sortedEntries.dropFirst(3)) + var drop = [Int]() + + if n ?? 3 > 3 { + for (uid, _) in other.dropLast(n! - 3) { + drop.append(uid) + apiConfig?.engine?.muteRemoteAudioStreamEx(UInt(uid), mute: true, connection: singChannelConnection ?? AgoraRtcConnection()) + subScribeSingerMap.removeValue(forKey: uid) + } + } + + agoraPrint("选路重新订阅, drop:\(drop)") + + let filteredList = singerList.filter { !subScribeSingerMap.keys.contains($0) } + let filteredList2 = filteredList.filter { !drop.contains($0) } + let shuffledList = filteredList2.shuffled() + + if subScribeSingerMap.count < 8 { + let randomSingers = Array(shuffledList.prefix(8 - subScribeSingerMap.count)) + agoraPrintError("选路重新订阅, newSingers:\(randomSingers)") + + for singer in randomSingers { + subScribeSingerMap[singer] = 0 + apiConfig?.engine?.muteRemoteAudioStreamEx(UInt(singer), mute: false, connection: singChannelConnection ?? AgoraRtcConnection()) + } + } + + agoraPrint("选路重新订阅, newSubScribeSingerMap:\(subScribeSingerMap)") + } + } + + private func processSubscribeTask() { + if !mStopProcessDelay && singerRole != .audience { + let n = singerRole == .leadSinger ? apiConfig?.routeSelectionConfig.streamNum : (apiConfig?.routeSelectionConfig.streamNum ?? 0) - 1 + let sortedEntries = subScribeSingerMap.sorted(by: { $0.value < $1.value }) + let mustToHave = Array(sortedEntries.prefix(3)) + + for (uid, _) in mustToHave { + apiConfig?.engine?.adjustUserPlaybackSignalVolumeEx(UInt(uid), volume: 100, connection: singChannelConnection ?? AgoraRtcConnection()) + } + + let other = Array(sortedEntries.dropFirst(3)) + + if n ?? 3 > 3 { + for (uid, delay) in Array(other.prefix(n! - 3)) { + if delay > 300 { + apiConfig?.engine?.adjustUserPlaybackSignalVolumeEx(UInt(uid), volume: 0, connection: singChannelConnection ?? AgoraRtcConnection()) + } else { + apiConfig?.engine?.adjustUserPlaybackSignalVolumeEx(UInt(uid), volume: 100, connection: singChannelConnection ?? AgoraRtcConnection()) + } + } + + for (uid, _) in Array(other.dropFirst(n! - 3)) { + apiConfig?.engine?.adjustUserPlaybackSignalVolumeEx(UInt(uid), volume: 0, connection: singChannelConnection ?? AgoraRtcConnection()) + } + } + + agoraPrint("选路排序+调整播放音量, mustToHave:\(mustToHave), other:\(other)") + } + } + + private func startProcessDelay() { + guard apiConfig?.routeSelectionConfig.type != .topN && apiConfig?.routeSelectionConfig.type != .random else { return } + + mStopProcessDelay = false + + // 创建并配置 processDelayTimer + processDelayFuture = DispatchSource.makeTimerSource() + processDelayFuture?.schedule(deadline: .now() + .seconds(10), repeating: .seconds(20)) + processDelayFuture?.setEventHandler { [weak self] in + // 执行 mProcessDelayTask + self?.processDelayTask() + } + processDelayFuture?.resume() + + // 创建并配置 processSubscribeTimer + processSubscribeFuture = DispatchSource.makeTimerSource() + processSubscribeFuture?.schedule(deadline: .now() + .seconds(15), repeating: .seconds(20)) + processSubscribeFuture?.setEventHandler { [weak self] in + // 执行 mProcessSubscribeTask + self?.processSubscribeTask() + } + processSubscribeFuture?.resume() + } + + private func stopProcessDelay() { + mStopProcessDelay = true + + processDelayFuture?.cancel() + processDelayFuture = nil + processSubscribeFuture?.cancel() + processSubscribeFuture = nil + } +} + + +extension Date { + /// 获取当前 秒级 时间戳 - 10位 + /// + var timeStamp : TimeInterval { + let timeInterval: TimeInterval = self.timeIntervalSince1970 + return timeInterval + } + /// 获取当前 毫秒级 时间戳 - 13位 + var milListamp : TimeInterval { + let timeInterval: TimeInterval = self.timeIntervalSince1970 + let millisecond = CLongLong(round(timeInterval*1000)) + return TimeInterval(millisecond) + } +} + diff --git a/KTVAPI/iOS/Classes/LrcTime.pb.swift b/KTVAPI/iOS/Classes/LrcTime.pb.swift new file mode 100644 index 0000000..b790fb2 --- /dev/null +++ b/KTVAPI/iOS/Classes/LrcTime.pb.swift @@ -0,0 +1,140 @@ +// DO NOT EDIT. +// swift-format-ignore-file +// +// Generated by the Swift generator plugin for the protocol buffer compiler. +// Source: LrcTime.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/apple/swift-protobuf/ + +import Foundation +import SwiftProtobuf + +// If the compiler emits an error on this type, it is because this file +// was generated by a version of the `protoc` Swift plug-in that is +// incompatible with the version of SwiftProtobuf to which you are linking. +// Please ensure that you are building against the same version of the API +// that was used to generate this file. +fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { + struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} + typealias Version = _2 +} + +enum MsgType: SwiftProtobuf.Enum { + typealias RawValue = Int + case unknownType // = 0 + case lrcTime // = 1001 + case UNRECOGNIZED(Int) + + init() { + self = .unknownType + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .unknownType + case 1001: self = .lrcTime + default: self = .UNRECOGNIZED(rawValue) + } + } + + var rawValue: Int { + switch self { + case .unknownType: return 0 + case .lrcTime: return 1001 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + static let allCases: [MsgType] = [ + .unknownType, + .lrcTime, + ] + +} + +struct LrcTime: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var type: MsgType = .unknownType + + var forward: Bool = false + + var ts: Int64 = 0 + + var songID: String = String() + + var uid: Int32 = 0 + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +extension MsgType: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 0: .same(proto: "UNKNOWN_TYPE"), + 1001: .same(proto: "LRC_TIME"), + ] +} + +extension LrcTime: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = "LrcTime" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "type"), + 2: .same(proto: "forward"), + 3: .same(proto: "ts"), + 4: .same(proto: "songId"), + 5: .same(proto: "uid"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.type) }() + case 2: try { try decoder.decodeSingularBoolField(value: &self.forward) }() + case 3: try { try decoder.decodeSingularInt64Field(value: &self.ts) }() + case 4: try { try decoder.decodeSingularStringField(value: &self.songID) }() + case 5: try { try decoder.decodeSingularInt32Field(value: &self.uid) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.type != .unknownType { + try visitor.visitSingularEnumField(value: self.type, fieldNumber: 1) + } + if self.forward != false { + try visitor.visitSingularBoolField(value: self.forward, fieldNumber: 2) + } + if self.ts != 0 { + try visitor.visitSingularInt64Field(value: self.ts, fieldNumber: 3) + } + if !self.songID.isEmpty { + try visitor.visitSingularStringField(value: self.songID, fieldNumber: 4) + } + if self.uid != 0 { + try visitor.visitSingularInt32Field(value: self.uid, fieldNumber: 5) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: LrcTime, rhs: LrcTime) -> Bool { + if lhs.type != rhs.type {return false} + if lhs.forward != rhs.forward {return false} + if lhs.ts != rhs.ts {return false} + if lhs.songID != rhs.songID {return false} + if lhs.uid != rhs.uid {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/KTVAPI/iOS/Classes/LrcTime.proto b/KTVAPI/iOS/Classes/LrcTime.proto new file mode 100644 index 0000000..084175b --- /dev/null +++ b/KTVAPI/iOS/Classes/LrcTime.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +enum MsgType { + UNKNOWN_TYPE = 0; + LRC_TIME = 1001; +} + +message LrcTime { + MsgType type = 1; + bool forward = 2; + int64 ts = 3; + string songId = 4; + int32 uid = 5; +} diff --git a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo.xcodeproj/project.pbxproj b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo.xcodeproj/project.pbxproj index 39ac526..41ef2c7 100644 --- a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo.xcodeproj/project.pbxproj +++ b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo.xcodeproj/project.pbxproj @@ -7,7 +7,13 @@ objects = { /* Begin PBXBuildFile section */ - E3CB4D602A935EBD00322389 /* 成都.xml in Resources */ = {isa = PBXBuildFile; fileRef = E3CB4D5F2A935EBD00322389 /* 成都.xml */; }; + E38BDE782B6F7A78007A2834 /* KTVGiantChorusApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = E38BDE772B6F7A77007A2834 /* KTVGiantChorusApiImpl.swift */; }; + E38BDE7A2B6F7ABD007A2834 /* ApiManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E38BDE792B6F7ABD007A2834 /* ApiManager.swift */; }; + E39BAF762B6CC695002C692F /* LrcTime.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = E39BAF742B6CC695002C692F /* LrcTime.pb.swift */; }; + E39BAF772B6CC695002C692F /* LrcTime.proto in Sources */ = {isa = PBXBuildFile; fileRef = E39BAF752B6CC695002C692F /* LrcTime.proto */; }; + E39BAF792B6CCC65002C692F /* 不如跳舞.xml in Resources */ = {isa = PBXBuildFile; fileRef = E39BAF782B6CCC65002C692F /* 不如跳舞.xml */; }; + E39BAF7B2B6CCC74002C692F /* 不如跳舞.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = E39BAF7A2B6CCC74002C692F /* 不如跳舞.mp4 */; }; + E3EC073C2BD8BB8600CB8279 /* APIReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3EC073B2BD8BB8600CB8279 /* APIReporter.swift */; }; E3ED270B2A822E9D0087B7AA /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3ED270A2A822E9D0087B7AA /* AppDelegate.swift */; }; E3ED270D2A822E9D0087B7AA /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3ED270C2A822E9D0087B7AA /* SceneDelegate.swift */; }; E3ED270F2A822E9D0087B7AA /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3ED270E2A822E9D0087B7AA /* ViewController.swift */; }; @@ -28,7 +34,6 @@ E3ED27392A8312120087B7AA /* AgoraStringExtention.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3ED27312A8312120087B7AA /* AgoraStringExtention.swift */; }; E3ED273A2A8312120087B7AA /* AgoraURLExtention.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3ED27322A8312120087B7AA /* AgoraURLExtention.swift */; }; E3ED273B2A8312120087B7AA /* AgoraDownLoadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3ED27332A8312120087B7AA /* AgoraDownLoadManager.swift */; }; - E3FE65332B20638D001D6BF9 /* 成都.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = E3FE65322B20638D001D6BF9 /* 成都.mp3 */; }; F33427D772BC45C43FBC8F23 /* Pods_KTVApiDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AFBE8E6CA2314F17EF33468 /* Pods_KTVApiDemo.framework */; }; /* End PBXBuildFile section */ @@ -36,7 +41,13 @@ 0AFBE8E6CA2314F17EF33468 /* Pods_KTVApiDemo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_KTVApiDemo.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 91649F2302F7D5A73F303630 /* Pods-KTVApiDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-KTVApiDemo.release.xcconfig"; path = "Target Support Files/Pods-KTVApiDemo/Pods-KTVApiDemo.release.xcconfig"; sourceTree = ""; }; AFA8596CCA93FAF74AD1D2D5 /* Pods-KTVApiDemo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-KTVApiDemo.debug.xcconfig"; path = "Target Support Files/Pods-KTVApiDemo/Pods-KTVApiDemo.debug.xcconfig"; sourceTree = ""; }; - E3CB4D5F2A935EBD00322389 /* 成都.xml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = "成都.xml"; sourceTree = ""; }; + E38BDE772B6F7A77007A2834 /* KTVGiantChorusApiImpl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KTVGiantChorusApiImpl.swift; sourceTree = ""; }; + E38BDE792B6F7ABD007A2834 /* ApiManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApiManager.swift; sourceTree = ""; }; + E39BAF742B6CC695002C692F /* LrcTime.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LrcTime.pb.swift; sourceTree = ""; }; + E39BAF752B6CC695002C692F /* LrcTime.proto */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.protobuf; path = LrcTime.proto; sourceTree = ""; }; + E39BAF782B6CCC65002C692F /* 不如跳舞.xml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = "不如跳舞.xml"; sourceTree = ""; }; + E39BAF7A2B6CCC74002C692F /* 不如跳舞.mp4 */ = {isa = PBXFileReference; lastKnownFileType = file; path = "不如跳舞.mp4"; sourceTree = ""; }; + E3EC073B2BD8BB8600CB8279 /* APIReporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIReporter.swift; sourceTree = ""; }; E3ED27072A822E9D0087B7AA /* KTVApiDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KTVApiDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; E3ED270A2A822E9D0087B7AA /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; E3ED270C2A822E9D0087B7AA /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -59,7 +70,6 @@ E3ED27312A8312120087B7AA /* AgoraStringExtention.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AgoraStringExtention.swift; sourceTree = ""; }; E3ED27322A8312120087B7AA /* AgoraURLExtention.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AgoraURLExtention.swift; sourceTree = ""; }; E3ED27332A8312120087B7AA /* AgoraDownLoadManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AgoraDownLoadManager.swift; sourceTree = ""; }; - E3FE65322B20638D001D6BF9 /* 成都.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = "成都.mp3"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -115,13 +125,14 @@ E3ED27232A8230A00087B7AA /* KeyCenter.swift */, E3ED270A2A822E9D0087B7AA /* AppDelegate.swift */, E3ED270C2A822E9D0087B7AA /* SceneDelegate.swift */, - E3CB4D5F2A935EBD00322389 /* 成都.xml */, - E3FE65322B20638D001D6BF9 /* 成都.mp3 */, E3ED270E2A822E9D0087B7AA /* ViewController.swift */, + E39BAF782B6CCC65002C692F /* 不如跳舞.xml */, + E39BAF7A2B6CCC74002C692F /* 不如跳舞.mp4 */, E3ED27252A8236750087B7AA /* KTVViewController.swift */, E3ED27292A826B0D0087B7AA /* KTVLyricView.swift */, E3ED272B2A8312120087B7AA /* FileDownloadCache */, E3ED27272A8243480087B7AA /* NetworkManager.swift */, + E38BDE792B6F7ABD007A2834 /* ApiManager.swift */, E3ED271E2A822ED30087B7AA /* KTVAPI */, E3ED27102A822E9D0087B7AA /* Main.storyboard */, E3ED27132A822E9E0087B7AA /* Assets.xcassets */, @@ -134,8 +145,12 @@ E3ED271E2A822ED30087B7AA /* KTVAPI */ = { isa = PBXGroup; children = ( + E3EC073B2BD8BB8600CB8279 /* APIReporter.swift */, E3ED271F2A822ED30087B7AA /* KTVApiImpl.swift */, E3ED27202A822ED30087B7AA /* KTVApi.swift */, + E38BDE772B6F7A77007A2834 /* KTVGiantChorusApiImpl.swift */, + E39BAF742B6CC695002C692F /* LrcTime.pb.swift */, + E39BAF752B6CC695002C692F /* LrcTime.proto */, ); path = KTVAPI; sourceTree = ""; @@ -217,9 +232,9 @@ files = ( E3ED27172A822E9E0087B7AA /* LaunchScreen.storyboard in Resources */, E3ED27142A822E9E0087B7AA /* Assets.xcassets in Resources */, - E3FE65332B20638D001D6BF9 /* 成都.mp3 in Resources */, - E3CB4D602A935EBD00322389 /* 成都.xml in Resources */, E3ED27122A822E9D0087B7AA /* Main.storyboard in Resources */, + E39BAF7B2B6CCC74002C692F /* 不如跳舞.mp4 in Resources */, + E39BAF792B6CCC65002C692F /* 不如跳舞.xml in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -275,11 +290,15 @@ E3ED27392A8312120087B7AA /* AgoraStringExtention.swift in Sources */, E3ED273B2A8312120087B7AA /* AgoraDownLoadManager.swift in Sources */, E3ED27212A822ED30087B7AA /* KTVApiImpl.swift in Sources */, + E3EC073C2BD8BB8600CB8279 /* APIReporter.swift in Sources */, + E39BAF772B6CC695002C692F /* LrcTime.proto in Sources */, E3ED27222A822ED30087B7AA /* KTVApi.swift in Sources */, E3ED270F2A822E9D0087B7AA /* ViewController.swift in Sources */, E3ED270B2A822E9D0087B7AA /* AppDelegate.swift in Sources */, E3ED27342A8312120087B7AA /* AgoraMiguXmlLrcParse.swift in Sources */, + E39BAF762B6CC695002C692F /* LrcTime.pb.swift in Sources */, E3ED27282A8243480087B7AA /* NetworkManager.swift in Sources */, + E38BDE7A2B6F7ABD007A2834 /* ApiManager.swift in Sources */, E3ED27242A8230A00087B7AA /* KeyCenter.swift in Sources */, E3ED27382A8312120087B7AA /* AgoraCacheFileHandle.swift in Sources */, E3ED272A2A826B0D0087B7AA /* KTVLyricView.swift in Sources */, @@ -288,6 +307,7 @@ E3ED27352A8312120087B7AA /* AgoraLrcParse.swift in Sources */, E3ED273A2A8312120087B7AA /* AgoraURLExtention.swift in Sources */, E3ED27372A8312120087B7AA /* AgoraLrcModel.swift in Sources */, + E38BDE782B6F7A78007A2834 /* KTVGiantChorusApiImpl.swift in Sources */, E3ED27362A8312120087B7AA /* AgoraRequestTask.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo.xcodeproj/xcuserdata/cp.xcuserdatad/xcschemes/xcschememanagement.plist b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo.xcodeproj/xcuserdata/cp.xcuserdatad/xcschemes/xcschememanagement.plist index f426412..6d4c9a4 100644 --- a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo.xcodeproj/xcuserdata/cp.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo.xcodeproj/xcuserdata/cp.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ KTVApiDemo.xcscheme_^#shared#^_ orderHint - 6 + 8 diff --git a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/ApiManager.swift b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/ApiManager.swift new file mode 100644 index 0000000..989b0c8 --- /dev/null +++ b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/ApiManager.swift @@ -0,0 +1,233 @@ +import Foundation +class ApiManager { + static let shared = ApiManager() + + private let domain = "https://api.sd-rtn.com" + //private let domain: String = "http://218.205.37.50:16000" + //private let testIp: String = "218.205.37.50" + + private let TAG = "ApiManager" + + private var tokenName = "" + private var taskId = "" + + private lazy var session: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 30 + configuration.timeoutIntervalForResource = 30 + + return URLSession(configuration: configuration) + }() + + func fetchCloudToken() -> String? { + var token: String? = nil + + do { + let timeInterval: TimeInterval = Date().timeIntervalSince1970 + let millisecond = CLongLong(round(timeInterval*1000)) + let acquireOjb = try JSONSerialization.data(withJSONObject: [ + "instanceId": "\(Int(millisecond))" + ]) + + let url = getTokenUrl(domain: domain, appId: KeyCenter.AppId) + guard let requestUrl = URL(string: url) else {return ""} + var request = URLRequest(url: requestUrl) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(getBasicAuth(), forHTTPHeaderField: "Authorization") + request.httpBody = acquireOjb + + let semaphore = DispatchSemaphore(value: 0) + + let task = session.dataTask(with: request) { (data, response, error) in + if let error = error { + print("getToken error: \(error.localizedDescription)") + token = nil + // VLToast.toast("ktv_merge_failed_and create".toSceneLocalization() as String) + } else if let data = data { + do { + guard let responseDict = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], let tokenName = responseDict["tokenName"] as? String else { + return + } + + token = tokenName + } catch { + print("getToken error: \(error.localizedDescription)") + // VLToast.toast("ktv_merge_failed_and create".toSceneLocalization() as String) + token = nil + } + } + + semaphore.signal() + } + + task.resume() + semaphore.wait() + + } catch { + print("getToken error: \(error.localizedDescription)") + //VLToast.toast("ktv_merge_failed_and create") + token = nil + } + + return token + } + + func fetchStartCloud(mainChannel: String, cloudRtcUid: Int, inputToken: String, outputToken: String, completion: @escaping ((Bool)->Void)) { + let token = fetchCloudToken() + + if token == nil { + print("云端合流uid 请求报错 token is null") + completion(false) + return + } else { + tokenName = token! + } + + do { + let inputRetObj: [String: Any] = [ + "rtcUid": 0, + "rtcToken": inputToken, + "rtcChannel": mainChannel + ] + + let intObj: [String: Any] = ["rtc": inputRetObj] + + let audioOptionObj: [String: Any] = [ + "profileType": "AUDIO_PROFILE_MUSIC_HIGH_QUALITY_STEREO", + "fullChannelMixer": "native-mixer-weighted" + ] + + let outputRetObj: [String: Any] = [ + "rtcUid": cloudRtcUid, + "rtcToken": outputToken, + "rtcChannel": "\(mainChannel)_ad" + ] + + let dataStreamObj: [String: Any] = [ + "source": ["audioMetaData": true], + "sink": [:] + ] + + let outputsObj: [String: Any] = [ + "audioOption": audioOptionObj, + "rtc": outputRetObj, + "metaDataOption": dataStreamObj + ] + + let transcoderObj: [String: Any] = [ + "audioInputs": [intObj], + "idleTimeout": 300, + "outputs": [outputsObj] + ] + + let postBody: [String: Any] = [ + "services": [ + "cloudTranscoder": [ + "serviceType": "cloudTranscoderV2", + "config": [ + "transcoder": transcoderObj + ] + ] + ] + ] + + let url = startTaskUrl(domain: domain, appId: KeyCenter.AppId, tokenName: tokenName) + guard let requestUrl = URL(string: url) else {return} + var request = URLRequest(url: requestUrl) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(getBasicAuth(), forHTTPHeaderField: "Authorization") + request.httpBody = try JSONSerialization.data(withJSONObject: postBody, options: []) + + // let semaphore = DispatchSemaphore(value: 0) + + let task = session.dataTask(with: request) { (data, response, error) in + if let error = error { + print("云端合流uid 请求报错: \(error.localizedDescription)") + completion(false) + // VLToast.toast("ktv_merge_failed_and create".toSceneLocalization() as String) + } else if let data = data { + do { + guard let responseDict = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], let taskId = responseDict["taskId"] as? String else { + completion(false) + return + } + + self.taskId = taskId + completion(true) + // VLToast.toast("ktv_merge_success".toSceneLocalization() as String) + print("合流成功") + } catch { + print("云端合流uid 请求报错: \(error.localizedDescription)") + completion(false) + // VLToast.toast("ktv_merge_failed_and create".toSceneLocalization() as String) + } + } + + // semaphore.signal() + } + + task.resume() + // semaphore.wait() + + } catch { + print("云端合流uid 请求报错: \(error.localizedDescription)") + completion(false) + // VLToast.toast("ktv_merge_failed_and create".toSceneLocalization() as String) + } + } + + func fetchStopCloud() { + if taskId.isEmpty || tokenName.isEmpty { + print("云端合流任务停止失败 taskId || tokenName is null") + return + } + + do { + let url = deleteTaskUrl(domain: domain, appid: KeyCenter.AppId, taskid: taskId, tokenName: tokenName) + guard let requestUrl = URL(string: url) else {return} + var request = URLRequest(url: requestUrl) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(getBasicAuth(), forHTTPHeaderField: "Authorization") + + // let semaphore = DispatchSemaphore(value: 0) + + let task = session.dataTask(with: request) { (data, response, error) in + // Handle response + + // semaphore.signal() + } + + task.resume() + // semaphore.wait() + + } catch { + print("云端合流任务停止失败: \(error.localizedDescription)") + } + } + + private func getTokenUrl(domain: String, appId: String) -> String { + return String(format: "%@/v1/projects/%@/rtsc/cloud-transcoder/builderTokens", domain, appId) + } + + private func startTaskUrl(domain: String, appId: String, tokenName: String) -> String { + return String(format: "%@/v1/projects/%@/rtsc/cloud-transcoder/tasks?builderToken=%@", domain, appId, tokenName) + } + + private func deleteTaskUrl(domain: String, appid: String, taskid: String, tokenName: String) -> String { + return String(format: "%@/v1/projects/%@/rtsc/cloud-transcoder/tasks/%@?builderToken=%@", domain, appid, taskid, tokenName) + } + + private func getBasicAuth() -> String { + // 拼接客户 ID 和客户密钥并使用 base64 编码 + let plainCredentials = "\(KeyCenter.RestfulApiKey!):\(KeyCenter.RestfulApiSecret!)" + guard let base64Credentials = plainCredentials.data(using: .utf8)?.base64EncodedString() else { + return "" + } + // 创建 authorization header + return "Basic \(base64Credentials)" + } + +} diff --git a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/Base.lproj/Main.storyboard b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/Base.lproj/Main.storyboard index 554f906..eb88c14 100644 --- a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/Base.lproj/Main.storyboard +++ b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/Base.lproj/Main.storyboard @@ -1,9 +1,9 @@ - + - + @@ -40,29 +40,22 @@ - + - - + - + @@ -96,7 +89,7 @@ - + @@ -105,13 +98,25 @@ + + + + + + + + + + + + @@ -124,6 +129,7 @@ + diff --git a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/Info.plist b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/Info.plist index 8e6b5a8..79d3033 100644 --- a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/Info.plist +++ b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/Info.plist @@ -2,6 +2,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/APIReporter.swift b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/APIReporter.swift new file mode 100644 index 0000000..936e59b --- /dev/null +++ b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/APIReporter.swift @@ -0,0 +1,159 @@ +// +// APIReporter.swift +// CallAPI +// +// Created by wushengtao on 2024/4/8. +// + +import AgoraRtcKit + + +/// 场景化类型 +public enum APIType: Int { + case ktv = 1 //K歌 + case call = 2 //呼叫 + case beauty = 3 //美颜 + case videoLoader = 4 //秒开/秒切 + case pk = 5 //团战 + case vitualSpace = 6 // + case screenSpace = 7 //屏幕共享 + case audioScenario = 8 //音频scenario +} + +enum APIEventType: Int { + case api = 0 //api事件 + case cost //耗时事件 + case custom //自定义事件 +} + +struct ApiEventKey { + static let type = "type" + static let desc = "desc" + static let apiValue = "apiValue" + static let ts = "ts" + static let ext = "ext" +} + +struct APICostEvent { + static let channelUsage = "channelUsage" //频道使用耗时 + static let firstFrameActual = "firstFrameActual" //首帧实际耗时 + static let firstFramePerceived = "firstFramePerceived" //首帧感官耗时 +} + +let formatter = DateFormatter() +func debugApiPrint(_ message: String) { +#if DEBUG + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + let timeString = formatter.string(from: Date()) + print("\(timeString) \(message)") +#endif +} + +@objcMembers +public class APIReporter: NSObject { + private var engine: AgoraRtcEngineKit + private let messsageId: String = "agora:scenarioAPI" + private var category: String + private var durationEventStartMap: [String: Int64] = [:] + + //MARK: public + public init(type: APIType, version: String, engine: AgoraRtcEngineKit) { + self.category = "\(type.rawValue)_iOS_\(version)" + self.engine = engine + super.init() + + configParameters() + } + + public func reportFuncEvent(name: String, value: [String: Any], ext: [String: Any]) { + let content = "[APIReporter]reportFuncEvent: \(name) value: \(value) ext: \(ext)" + debugApiPrint(content) + let eventMap: [String: Any] = [ApiEventKey.type: APIEventType.api.rawValue, ApiEventKey.desc: name] + let labelMap: [String: Any] = [ApiEventKey.apiValue: value, ApiEventKey.ts: getCurrentTs(), ApiEventKey.ext: ext] + let event = convertToJSONString(eventMap) ?? "" + let label = convertToJSONString(labelMap) ?? "" + engine.sendCustomReportMessage(messsageId, + category: category, + event: event, + label: label, + value: 0) + } + + public func startDurationEvent(name: String) { + durationEventStartMap[name] = getCurrentTs() + } + + public func endDurationEvent(name: String, ext: [String: Any]) { + guard let beginTs = durationEventStartMap[name] else {return} + durationEventStartMap.removeValue(forKey: name) + let ts = getCurrentTs() + let cost = Int(ts - beginTs) + + reportCostEvent(ts: ts, name: name, cost: cost, ext: ext) + } + + public func reportCostEvent(name: String, cost: Int, ext: [String: Any]) { + durationEventStartMap.removeValue(forKey: name) + reportCostEvent(ts: getCurrentTs(), name: name, cost: cost, ext: ext) + } + + public func reportCustomEvent(name: String, ext: [String: Any]) { + let content = "[APIReporter]reportCustomEvent: \(name) ext: \(ext)" + debugApiPrint(content) + let eventMap: [String: Any] = [ApiEventKey.type: APIEventType.custom.rawValue, ApiEventKey.desc: name] + let labelMap: [String: Any] = [ApiEventKey.ts: getCurrentTs(), ApiEventKey.ext: ext] + let event = convertToJSONString(eventMap) ?? "" + let label = convertToJSONString(labelMap) ?? "" + engine.sendCustomReportMessage(messsageId, + category: category, + event: event, + label: label, + value: 0) + } + + public func writeLog(content: String, level: AgoraLogLevel) { + engine.writeLog(level, content: content) + } + + public func cleanCache() { + durationEventStartMap.removeAll() + } + + //MARK: private + private func reportCostEvent(ts: Int64, name: String, cost: Int, ext: [String: Any]) { + let content = "[APIReporter]reportCostEvent: \(name) cost: \(cost) ms ext: \(ext)" + debugApiPrint(content) + writeLog(content: content, level: .info) + let eventMap: [String: Any] = [ApiEventKey.type: APIEventType.cost.rawValue, ApiEventKey.desc: name] + let labelMap: [String: Any] = [ApiEventKey.ts: ts, ApiEventKey.ext: ext] + let event = convertToJSONString(eventMap) ?? "" + let label = convertToJSONString(labelMap) ?? "" + engine.sendCustomReportMessage(messsageId, + category: category, + event: event, + label: label, + value: cost) + } + + private func configParameters() { +// engine.setParameters("{\"rtc.qos_for_test_purpose\": true}") + engine.setParameters("{\"rtc.direct_send_custom_event\": true}") + engine.setParameters("{\"rtc.log_external_input\": true}") + } + + private func getCurrentTs() -> Int64 { + return Int64(round(Date().timeIntervalSince1970 * 1000.0)) + } + + private func convertToJSONString(_ dictionary: [String: Any]) -> String? { + do { + let jsonData = try JSONSerialization.data(withJSONObject: dictionary, options: []) + if let jsonString = String(data: jsonData, encoding: .utf8) { + return jsonString + } + } catch { + writeLog(content: "[APIReporter]convert to json fail: \(error) dictionary: \(dictionary)", level: .warn) + } + return nil + } +} diff --git a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/KTVApi.swift b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/KTVApi.swift index d1fca98..16395b8 100644 --- a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/KTVApi.swift +++ b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/KTVApi.swift @@ -65,16 +65,13 @@ import AgoraRtcKit /// 加入合唱失败原因 @objc public enum KTVJoinChorusFailReason: Int { - case musicPreloadFail //歌曲预加载失败 case musicOpenFail //歌曲打开失败 case joinChannelFail //加入ex频道失败 - case musicPreloadFailAndJoinChannelFail } @objc public enum KTVType: Int { case normal case singbattle - case cantata case singRelay } @@ -88,7 +85,7 @@ import AgoraRtcKit /// - status: <#status description#> /// - msg: <#msg description#> /// - lyricUrl: <#lyricUrl description#> - func onMusicLoadProgress(songCode: Int, percent: Int, status: AgoraMusicContentCenterPreloadStatus, msg: String?, lyricUrl: String?) + func onMusicLoadProgress(songCode: Int, percent: Int, state: AgoraMusicContentCenterPreloadState, msg: String?, lyricUrl: String?) /// 歌曲加载成功 /// - Parameters: @@ -105,17 +102,6 @@ import AgoraRtcKit func onMusicLoadFail(songCode: Int, reason: KTVLoadSongFailReason) } - -//public protocol KTVJoinChorusStateListener: NSObjectProtocol { -// -// /// 加入合唱成功 -// func onJoinChorusSuccess() -// -// /// 加入合唱失败 -// /// - Parameter reason: 失败原因 -// func onJoinChorusFail(reason: KTVJoinChorusFailReason) -//} - @objc public protocol KTVLrcViewDelegate: NSObjectProtocol { func onUpdatePitch(pitch: Float) func onUpdateProgress(progress: Int) @@ -131,7 +117,7 @@ import AgoraRtcKit /// - error: <#error description#> /// - isLocal: <#isLocal description#> func onMusicPlayerStateChanged(state: AgoraMediaPlayerState, - error: AgoraMediaPlayerError, + reason: AgoraMediaPlayerReason, isLocal: Bool) @@ -160,6 +146,73 @@ import AgoraRtcKit func onMusicPlayerProgressChanged(with progress: Int) } +// 大合唱中演唱者互相收听对方音频流的选路策略 +enum GiantChorusRouteSelectionType: Int { + case random = 0 // 随机选取几条流 + case byDelay = 1 // 根据延迟选择最低的几条流 + case topN = 2 // 根据音强选流 + case byDelayAndTopN = 3 // 同时开始延迟选路和音强选流 +} + +// 大合唱中演唱者互相收听对方音频流的选路配置 +@objc public class GiantChorusRouteSelectionConfig: NSObject { + let type: GiantChorusRouteSelectionType // 选路策略 + let streamNum: Int // 最大选取的流个数(推荐6) + + init(type: GiantChorusRouteSelectionType, streamNum: Int) { + self.type = type + self.streamNum = streamNum + } +} + +@objc open class GiantChorusConfiguration: NSObject { + var appId: String + var rtmToken: String + weak var engine: AgoraRtcEngineKit? + var channelName: String + var localUid: Int = 0 + var chorusChannelName: String + var chorusChannelToken: String + var maxCacheSize: Int = 10 + var musicType: loadMusicType = .mcc + var audienceChannelToken: String = "" + var musicStreamUid: Int = 0 + var musicChannelToken: String = "" + var routeSelectionConfig: GiantChorusRouteSelectionConfig = GiantChorusRouteSelectionConfig(type: .byDelay, streamNum: 6) + var mccDomain: String? + @objc public + init(appId: String, + rtmToken: String, + engine: AgoraRtcEngineKit, + localUid: Int, + audienceChannelName: String, + audienceChannelToken: String, + chorusChannelName: String, + chorusChannelToken: String, + musicStreamUid: Int, + musicChannelToken: String, + maxCacheSize: Int, + musicType: loadMusicType, + routeSelectionConfig: GiantChorusRouteSelectionConfig, + mccDomain: String? + ) { + self.appId = appId + self.rtmToken = rtmToken + self.engine = engine + self.channelName = audienceChannelName + self.localUid = localUid + self.chorusChannelName = chorusChannelName + self.chorusChannelToken = chorusChannelToken + self.maxCacheSize = maxCacheSize + self.musicType = musicType + self.audienceChannelToken = audienceChannelToken + self.musicStreamUid = musicStreamUid + self.musicChannelToken = musicChannelToken + self.routeSelectionConfig = routeSelectionConfig + self.mccDomain = mccDomain + } +} + @objc open class KTVApiConfig: NSObject{ var appId: String var rtmToken: String @@ -171,7 +224,7 @@ import AgoraRtcKit var type: KTVType = .normal var maxCacheSize: Int = 10 var musicType: loadMusicType = .mcc - var isDebugMode: Bool = false + var mccDomain: String? @objc public init(appId: String, rtmToken: String, @@ -181,9 +234,9 @@ import AgoraRtcKit chorusChannelName: String, chorusChannelToken: String, type: KTVType, - maxCacheSize: Int, musicType: loadMusicType, - isDebugMode: Bool + maxCacheSize: Int, + mccDomain: String? ) { self.appId = appId self.rtmToken = rtmToken @@ -195,49 +248,49 @@ import AgoraRtcKit self.type = type self.maxCacheSize = maxCacheSize self.musicType = musicType - self.isDebugMode = isDebugMode + self.mccDomain = mccDomain } + + } /// 歌曲加载配置信息 @objcMembers open class KTVSongConfiguration: NSObject { public var songIdentifier: String = "" - public var autoPlay: Bool = false //是否加载完成自动播放 public var mainSingerUid: Int = 0 //主唱uid public var mode: KTVLoadMusicMode = .loadMusicAndLrc - - func printObjectContent() -> String { - var content = "" - - let mirror = Mirror(reflecting: self) - for child in mirror.children { - if let propertyName = child.label { - if let propertyValue = child.value as? CustomStringConvertible { - content += "\(propertyName): \(propertyValue)\n" - } else { - content += "\(propertyName): \(child.value)\n" - } - } - } - - return content - } + public var songCutter: Bool = false +// func printObjectContent() -> String { +// var content = "" +// +// let mirror = Mirror(reflecting: self) +// for child in mirror.children { +// if let propertyName = child.label { +// if let propertyValue = child.value as? CustomStringConvertible { +// content += "\(propertyName): \(propertyValue)\n" +// } else { +// content += "\(propertyName): \(child.value)\n" +// } +// } +// } +// +// return content +// } } public typealias LyricCallback = ((String?) -> Void) -public typealias LoadMusicCallback = ((AgoraMusicContentCenterPreloadStatus, NSInteger) -> Void) +public typealias LoadMusicCallback = ((AgoraMusicContentCenterPreloadState, NSInteger) -> Void) public typealias ISwitchRoleStateListener = (KTVSwitchRoleState, KTVSwitchRoleFailReason) -> Void -public typealias MusicChartCallBacks = (String, AgoraMusicContentCenterStatusCode, [AgoraMusicChartInfo]?) -> Void -public typealias MusicResultCallBacks = (String, AgoraMusicContentCenterStatusCode, AgoraMusicCollection) -> Void +public typealias MusicChartCallBacks = (String, AgoraMusicContentCenterStateReason, [AgoraMusicChartInfo]?) -> Void +public typealias MusicResultCallBacks = (String, AgoraMusicContentCenterStateReason, AgoraMusicCollection) -> Void public typealias JoinExChannelCallBack = ((Bool, KTVJoinChorusFailReason?)-> Void) @objc public protocol KTVApiDelegate: NSObjectProtocol { - /// 初始化 - /// - Parameter config: <#config description#> - init(config: KTVApiConfig) + @objc optional func createKtvApi(config: KTVApiConfig) //小合唱必选 + @objc optional func createKTVGiantChorusApi(config: GiantChorusConfiguration) //大合唱必选 /// 订阅KTVApi事件 /// - Parameter ktvApiEventHandler: <#ktvApiEventHandler description#> @@ -410,4 +463,6 @@ public typealias JoinExChannelCallBack = ((Bool, KTVJoinChorusFailReason?)-> Voi */ func removeMusic(songCode: Int) + + @objc func didAudioMetadataReceived( uid: UInt, metadata: Data) } diff --git a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/KTVApiImpl.swift b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/KTVApiImpl.swift index f76082f..60d71d8 100644 --- a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/KTVApiImpl.swift +++ b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/KTVApiImpl.swift @@ -7,25 +7,21 @@ import Foundation import AgoraRtcKit - +import SwiftProtobuf /// 加载歌曲状态 -@objc public enum KTVLoadSongState: Int { +@objc fileprivate enum KTVLoadSongState: Int { case idle = -1 //空闲 case ok = 0 //成功 case failed //失败 case inProgress //加载中 } -enum KTVSongMode: Int { +fileprivate enum KTVSongMode: Int { case songCode case songUrl } -private func agoraPrint(_ message: String) { - print(message) -} - -@objc class KTVApiImpl: NSObject{ +@objc class KTVApiImpl: NSObject, KTVApiDelegate{ private var apiConfig: KTVApiConfig? @@ -55,6 +51,7 @@ private func agoraPrint(_ message: String) { private var startHighTime: Int = 0 private var isRelease: Bool = false private var songUrl2: String = "" + private var enableMultipathing = true private var playerState: AgoraMediaPlayerState = .idle { didSet { agoraPrint("playerState did changed: \(oldValue.rawValue)->\(playerState.rawValue)") @@ -83,6 +80,11 @@ private func agoraPrint(_ message: String) { private var songUrl: String = "" private var songCode: Int = 0 private var songIdentifier: String = "" + + private let tag = "KTV_API_LOG" + private let messageId = "agora:scenarioAPI" + private let version = "5.0.0" + private let lyricSyncVersion = 2 private var singerRole: KTVSingRole = .audience { didSet { @@ -93,24 +95,29 @@ private func agoraPrint(_ message: String) { private var timer: Timer? private var isPause: Bool = false - + private var recvFromDataStream = false public var remoteVolume: Int = 30 private var joinChorusNewRole: KTVSingRole = .audience private var oldPitch: Double = 0 private var isWearingHeadPhones: Bool = false private var enableProfessional: Bool = false private var isPublishAudio: Bool = false + private var preludeDuration: Int64 = 0 private lazy var apiDelegateHandler = KTVApiRTCDelegateHandler(with: self) + + private var totalSize: Int = 0 + + private var apiRepoter: APIReporter? + deinit { mcc?.register(nil) agoraPrint("deinit KTVApiImpl") } - - @objc required init(config: KTVApiConfig) { - super.init() - agoraPrint("init KTVApiImpl") + + @objc func createKtvApi(config: KTVApiConfig) { self.apiConfig = config + apiRepoter = APIReporter(type: .ktv, version: version, engine: apiConfig?.engine ?? AgoraRtcEngineKit()) setParams() if config.musicType == .mcc { @@ -121,11 +128,14 @@ private func agoraPrint(_ message: String) { contentCenterConfiguration.token = config.rtmToken contentCenterConfiguration.rtcEngine = config.engine contentCenterConfiguration.maxCacheSize = UInt(config.maxCacheSize) - if config.isDebugMode { - //如果这一块报错为contentCenterConfiguration没有mccDomain这个属性 说明该版本不支持这个 可以注释掉这行代码。完全不影响 - contentCenterConfiguration.mccDomain = "api-test.agora.io" + if let domain = config.mccDomain { + contentCenterConfiguration.mccDomain = domain } mcc = AgoraMusicContentCenter.sharedContentCenter(config: contentCenterConfiguration) + if mcc == nil { + agoraPrint("mcc create fail") +// assert(mcc != nil, "mcc == nil") + } mcc?.register(self) // ------------------ 初始化音乐播放器实例 ------------------ mediaPlayer = mcc?.createMusicPlayer(delegate: self) @@ -137,8 +147,11 @@ private func agoraPrint(_ message: String) { mediaPlayer?.adjustPlayoutVolume(50) mediaPlayer?.adjustPublishSignalVolume(50) } + apiConfig?.engine?.addDelegate(apiDelegateHandler) + mediaPlayer?.setPlayerOption("play_pos_change_callback", value: 100) initTimer() + agoraPrint("init KTVApiImpl") } private func setParams() { @@ -153,13 +166,24 @@ private func agoraPrint(_ message: String) { engine.setParameters("{\"che.audio.neteq.prebuffer_max_delay\": 600}") engine.setParameters("{\"che.audio.max_mixed_participants\": 8}") engine.setParameters("{\"che.audio.custom_bitrate\": 48000}") - engine.setParameters("{\"che.audio.direct.uplink_process\": false}") engine.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}") engine.setParameters("{\"che.audio.neteq.targetlevel_offset\": 20}") engine.setParameters("{\"che.audio.ans.noise_gate\": 20}") + engine.setParameters("{\"rtc.use_audio4\": true}") if apiConfig?.type == .singRelay { engine.setParameters("{\"che.audio.aiaec.working_mode\": 1}") } + + //4.3.0 add + enableMultipathing = true +// engine.setParameters("{\"rtc.enable_tds_request_on_join\": true}") +// engine.setParameters("{\"rtc.remote_path_scheduling_strategy\": 0}") + engine.setParameters("{\"rtc.path_scheduling_strategy\": 0}") + // engine.setParameters("{\"rtc.enableMultipath\": true}") + engine.setParameters("{\"rtc.log_external_input\":true}") + // 数据上报 + engine.setParameters("{\"rtc.direct_send_custom_event\": true}") + // engine.setParameters("{\"rtc.qos_for_test_purpose\": true}") } func renewInnerDataStreamId() { @@ -167,19 +191,58 @@ private func agoraPrint(_ message: String) { dataStreamConfig.ordered = false dataStreamConfig.syncWithAudio = true self.apiConfig?.engine?.createDataStream(&dataStreamId, config: dataStreamConfig) - sendCustomMessage(with: "renewInnerDataStreamId", label: "") + sendCustomMessage(with: "renewInnerDataStreamId", dict: [:]) + agoraPrint("renewInnerDataStreamId") } } //MARK: KTVApiDelegate -extension KTVApiImpl: KTVApiDelegate { +extension KTVApiImpl { + + func objectContent(of object: Any) -> [String: Any] { + var content = [String: Any]() + + let mirror = Mirror(reflecting: object) + for child in mirror.children { + if let propertyName = child.label { + if let convertibleValue = convertToJSONSerializable(child.value) { + content[propertyName] = convertibleValue + } + } + } + + return content + } + + func convertToJSONSerializable(_ value: Any) -> Any? { + switch value { + case let value as String: + return value + case let value as Int: + return value + case let value as Double: + return value + case let value as Bool: + return value + case let value as Int?: + return value + case let value as Double?: + return value + case let value as Bool?: + return value + case let value as String?: + return value + default: + return nil + } + } func getMusicContentCenter() -> AgoraMusicContentCenter? { return mcc } func setLrcView(view: KTVLrcViewDelegate) { - sendCustomMessage(with: "renewInnerDataStreamId", label: "view:\(view.description)") + sendCustomMessage(with: "setLrcView", dict: [:]) lrcControl = view } @@ -192,15 +255,14 @@ extension KTVApiImpl: KTVApiDelegate { self.songUrl = url1 self.songUrl2 = url2 - if config.autoPlay { - // 主唱自动播放歌曲 - if self.singerRole != .leadSinger { - switchSingerRole(newRole: .soloSinger) { state, failRes in - - } - } - startSing(url: url1, startPos: 0) - } +// if config.autoPlay { +// // 主唱自动播放歌曲 +// if self.singerRole != .leadSinger { +// switchSingerRole(newRole: .soloSinger) { state, failRes in +// } +// } +// startSing(url: url1, startPos: 0) + // } } //主要针对本地歌曲播放的主唱伴奏切换的 MCC直接忽视这个方法 @@ -218,8 +280,8 @@ extension KTVApiImpl: KTVApiDelegate { } func loadMusic(songCode: Int, config: KTVSongConfiguration, onMusicLoadStateListener: IMusicLoadStateListener) { - sendCustomMessage(with: "loadMusic", label: "config:\(config.printObjectContent())") - agoraPrint("loadMusic songCode:\(songCode) ") + sendCustomMessage(with: "loadMusic", dict: objectContent(of: config)) + agoraPrint("loadMusic songCode:\(songCode) mode:\(config.mode.rawValue)") self.songMode = .songCode self.songCode = songCode self.songIdentifier = config.songIdentifier @@ -227,28 +289,27 @@ extension KTVApiImpl: KTVApiDelegate { } func loadMusic(config: KTVSongConfiguration, url: String) { - sendCustomMessage(with: "loadMusic", label: "config:\(config.printObjectContent()), url:\(url)") + sendCustomMessage(with: "loadMusicWithUrl:\(url)", dict: objectContent(of: config)) self.songMode = .songUrl self.songUrl = url self.songIdentifier = config.songIdentifier - if config.autoPlay { - // 主唱自动播放歌曲 - if singerRole != .leadSinger { - switchSingerRole(newRole: .soloSinger) { _, _ in - - } - } - startSing(url: url, startPos: 0) - } +// if config.autoPlay { +// // 主唱自动播放歌曲 +// if singerRole != .leadSinger { +// switchSingerRole(newRole: .soloSinger) { _, _ in +// } +// } +// startSing(url: url, startPos: 0) +// } } func getMusicPlayer() -> AgoraRtcMediaPlayerProtocol? { - sendCustomMessage(with: "getMusicPlayer", label: "") + sendCustomMessage(with: "getMusicPlayer", dict: [:]) return mediaPlayer } func addEventHandler(ktvApiEventHandler: KTVApiEventHandlerDelegate) { - sendCustomMessage(with: "addEventHandler", label: "") + sendCustomMessage(with: "addEventHandler", dict: [:]) if eventHandlers.contains(ktvApiEventHandler) { return } @@ -256,12 +317,12 @@ extension KTVApiImpl: KTVApiDelegate { } func removeEventHandler(ktvApiEventHandler: KTVApiEventHandlerDelegate) { - sendCustomMessage(with: "removeEventHandler", label: "") + sendCustomMessage(with: "removeEventHandler", dict: [:]) eventHandlers.remove(ktvApiEventHandler) } func cleanCache() { - sendCustomMessage(with: "cleanCache", label: "") + sendCustomMessage(with: "cleanCache", dict: [:]) isRelease = true freeTimer() agoraPrint("cleanCache") @@ -282,7 +343,12 @@ extension KTVApiImpl: KTVApiDelegate { } func renewToken(rtmToken: String, chorusChannelRtcToken: String) { - sendCustomMessage(with: "renewToken", label: "rtmToken:\(rtmToken), chorusChannelRtcToken:\(chorusChannelRtcToken)") + + let dict: [String: Any] = [ + "rtmToken":rtmToken, + "chorusChannelRtcToken":chorusChannelRtcToken + ] + sendCustomMessage(with: "renewToken", dict: dict) // 更新RtmToken mcc?.renewToken(rtmToken) // 更新合唱频道RtcToken @@ -294,7 +360,7 @@ extension KTVApiImpl: KTVApiDelegate { } func fetchMusicCharts(completion: @escaping MusicChartCallBacks) { - sendCustomMessage(with: "fetchMusicCharts", label: "") + sendCustomMessage(with: "fetchMusicCharts", dict: [:]) agoraPrint("fetchMusicCharts") let requestId = mcc!.getMusicCharts() musicChartDict[requestId] = completion @@ -304,9 +370,15 @@ extension KTVApiImpl: KTVApiDelegate { page: Int, pageSize: Int, jsonOption: String, - completion:@escaping (String, AgoraMusicContentCenterStatusCode, AgoraMusicCollection) -> Void) { + completion:@escaping (String, AgoraMusicContentCenterStateReason, AgoraMusicCollection) -> Void) { agoraPrint("searchMusic with musicChartId: \(musicChartId)") - sendCustomMessage(with: "searchMusic", label: "musicChartId:\(musicChartId), page:\(page), pageSize:\(pageSize), jsonOption:\(jsonOption)") + let dict: [String: Any] = [ + "musicChartId":musicChartId, + "page": page, + "pageSize": pageSize, + "jsonOption": jsonOption + ] + sendCustomMessage(with: "searchMusic", dict: dict) let requestId = mcc!.getMusicCollection(musicChartId: musicChartId, page: page, pageSize: pageSize, jsonOption: jsonOption) musicSearchDict[requestId] = completion } @@ -315,25 +387,38 @@ extension KTVApiImpl: KTVApiDelegate { page: Int, pageSize: Int, jsonOption: String, - completion: @escaping (String, AgoraMusicContentCenterStatusCode, AgoraMusicCollection) -> Void) { + completion: @escaping (String, AgoraMusicContentCenterStateReason, AgoraMusicCollection) -> Void) { agoraPrint("searchMusic with keyword: \(keyword)") - sendCustomMessage(with: "searchMusic", label: "keyword:\(keyword), page:\(page), pageSize:\(pageSize), jsonOption:\(jsonOption)") + let dict: [String: Any] = [ + "keyword": keyword, + "page": page, + "pageSize": pageSize, + "jsonOption": jsonOption + ] + sendCustomMessage(with: "searchMusic", dict: dict) let requestId = mcc!.searchMusic(keyWord: keyword, page: page, pageSize: pageSize, jsonOption: jsonOption) musicSearchDict[requestId] = completion } func switchSingerRole(newRole: KTVSingRole, onSwitchRoleState: @escaping (KTVSwitchRoleState, KTVSwitchRoleFailReason) -> Void) { let oldRole = singerRole - sendCustomMessage(with: "switchSingerRole", label: "oldRole:\(oldRole.rawValue), newRole: \(newRole.rawValue)") + + let dict: [String: Any] = [ + "oldRole": oldRole.rawValue, + "newRole": newRole.rawValue + ] + sendCustomMessage(with: "switchSingerRole", dict: dict) agoraPrint("switchSingerRole oldRole:\(oldRole.rawValue), newRole: \(newRole.rawValue)") - if ((oldRole == .leadSinger || oldRole == .soloSinger) && (newRole == .coSinger || newRole == .audience) && isNowMicMuted) { - apiConfig?.engine?.muteLocalAudioStream(true) - apiConfig?.engine?.adjustRecordingSignalVolume(100) - } else if ((oldRole == .audience || oldRole == .coSinger) && (newRole == .leadSinger || newRole == .soloSinger) && isNowMicMuted) { - apiConfig?.engine?.adjustRecordingSignalVolume(0) - apiConfig?.engine?.muteLocalAudioStream(false) - } +// if (apiConfig?.type != .singRelay) { +// if ((oldRole == .leadSinger || oldRole == .soloSinger) && (newRole == .coSinger || newRole == .audience) && isNowMicMuted) { +// apiConfig?.engine?.muteLocalAudioStream(true) +// apiConfig?.engine?.adjustRecordingSignalVolume(100) +// } else if ((oldRole == .audience || oldRole == .coSinger) && (newRole == .leadSinger || newRole == .soloSinger) && isNowMicMuted) { +// apiConfig?.engine?.adjustRecordingSignalVolume(0) +// apiConfig?.engine?.muteLocalAudioStream(false) +// } +// } self.switchSingerRole(oldRole: oldRole, newRole: newRole, token: apiConfig?.chorusChannelToken ?? "", stateCallBack: onSwitchRoleState) } @@ -342,7 +427,7 @@ extension KTVApiImpl: KTVApiDelegate { * 恢复播放 */ @objc public func resumeSing() { - sendCustomMessage(with: "resumeSing", label: "") + sendCustomMessage(with: "resumeSing", dict: [:]) agoraPrint("resumeSing") if mediaPlayer?.getPlayerState() == .paused { mediaPlayer?.resume() @@ -356,7 +441,7 @@ extension KTVApiImpl: KTVApiDelegate { * 暂停播放 */ @objc public func pauseSing() { - sendCustomMessage(with: "pauseSing", label: "") + sendCustomMessage(with: "pauseSing", dict: [:]) agoraPrint("pauseSing") mediaPlayer?.pause() } @@ -365,7 +450,7 @@ extension KTVApiImpl: KTVApiDelegate { * 调整进度 */ @objc public func seekSing(time: NSInteger) { - sendCustomMessage(with: "seekSing", label: "") + sendCustomMessage(with: "seekSing", dict: ["time":time]) agoraPrint("seekSing") mediaPlayer?.seek(toPosition: time) } @@ -381,25 +466,60 @@ extension KTVApiImpl: KTVApiDelegate { * 设置当前mic开关状态 */ @objc public func muteMic(muteStatus: Bool) { - sendCustomMessage(with: "setMicStatus", label: "\(muteStatus)") + sendCustomMessage(with: "setMicStatus", dict: ["muteStatus":muteStatus]) self.isNowMicMuted = muteStatus - if self.singerRole == .leadSinger || self.singerRole == .soloSinger { - apiConfig?.engine?.adjustRecordingSignalVolume(muteStatus ? 0 : 100) + if (apiConfig?.type != .singRelay) { + if self.singerRole == .leadSinger || self.singerRole == .soloSinger { + apiConfig?.engine?.adjustRecordingSignalVolume(muteStatus ? 0 : 100) + } else { +// let channelMediaOptions = AgoraRtcChannelMediaOptions() +// channelMediaOptions.publishMicrophoneTrack = !muteStatus +// channelMediaOptions.clientRoleType = .broadcaster +// apiConfig?.engine?.updateChannel(with: channelMediaOptions) +// apiConfig?.engine?.muteLocalAudioStream(muteStatus) + + apiConfig?.engine?.adjustRecordingSignalVolume(muteStatus ? 0 : 100) + } } else { - apiConfig?.engine?.muteLocalAudioStream(muteStatus) + apiConfig?.engine?.adjustRecordingSignalVolume(muteStatus ? 0 : 100) } } @objc public func removeMusic(songCode: Int) { - sendCustomMessage(with: "removeMusic", label: "songCode:\(songCode)") + sendCustomMessage(with: "removeMusic", dict: ["songCode": songCode]) let ret: Int = mcc?.removeCache(songCode: songCode) ?? 0 if ret < 0 { agoraPrint("removeMusic failed: ret:\(ret)") } } + + @objc public func enableMutipath(enable: Bool) { + sendCustomMessage(with: "enableMutipath", dict: ["enable":enable]) + agoraPrint("enableMutipath:\(enable)") + enableMultipathing = enable + if singerRole == .coSinger || singerRole == .leadSinger { + if let subChorusConnection = subChorusConnection { + apiConfig?.engine?.setParametersEx("{\"rtc.enableMultipath\": \(enable), \"rtc.path_scheduling_strategy\": 0, \"rtc.remote_path_scheduling_strategy\": 0}", connection: subChorusConnection) + } + } + } + private func agoraPrint(_ message: String) { + #if DEBUG + print("[KTVAPI]\(message)") + #endif + apiRepoter?.writeLog(content: "[KTVAPI]\(message)", level: .info) + } + + private func agoraPrintError(_ message: String) { + #if DEBUG + print("[KTVAPI][Error]\(message)") + #endif + apiRepoter?.writeLog(content: "[KTVAPI][Error]\(message)", level: .error) + } } + // 主要是角色切换,加入合唱,加入多频道,退出合唱,退出多频道 extension KTVApiImpl { private func switchSingerRole(oldRole: KTVSingRole, newRole: KTVSingRole, token: String, stateCallBack:@escaping ISwitchRoleStateListener) { @@ -606,10 +726,10 @@ extension KTVApiImpl { let rtcConnection = AgoraRtcConnection() rtcConnection.channelId = apiConfig?.chorusChannelName ?? "" rtcConnection.localUid = UInt(apiConfig?.localUid ?? 0) - subChorusConnection = rtcConnection + subChorusConnection = rtcConnection joinChorusNewRole = role - let ret = apiConfig?.engine?.joinChannelEx(byToken: token, connection: rtcConnection, delegate: self, mediaOptions: mediaOption, joinSuccess: nil) + let ret = apiConfig?.engine?.joinChannelEx(byToken: token, connection: rtcConnection, delegate: self, mediaOptions: mediaOption, joinSuccess: nil) agoraPrint("joinChannelEx ret: \(ret ?? -999)") if newRole == .coSinger { let uid = UInt(songConfig?.mainSingerUid ?? 0) @@ -617,6 +737,10 @@ extension KTVApiImpl { apiConfig?.engine?.muteRemoteAudioStream(uid, mute: true) agoraPrint("muteRemoteAudioStream: \(uid), ret: \(ret ?? -1)") } + if enableMultipathing { + apiConfig?.engine?.setParametersEx("{\"rtc.path_scheduling_strategy\":0, \"rtc.enableMultipath\": true, \"rtc.remote_path_scheduling_strategy\": 0}", connection: rtcConnection) + } + apiConfig?.engine?.setParameters("{\"rtc.use_audio4\": true}") } private func leaveChorus2ndChannel(_ role: KTVSingRole) { @@ -666,7 +790,6 @@ extension KTVApiImpl { } private func _loadMusic(config: KTVSongConfiguration, mode: KTVLoadMusicMode, onMusicLoadStateListener: IMusicLoadStateListener){ - songConfig = config lastReceivedPosition = 0 localPosition = 0 @@ -676,6 +799,7 @@ extension KTVApiImpl { } if (config.mode == .loadNone) { + agoraPrint("load music none") return } @@ -696,23 +820,23 @@ extension KTVApiImpl { onMusicLoadStateListener.onMusicLoadFail(songCode: self.songCode, reason: .noLyricUrl) } - if (config.autoPlay) { - // 主唱自动播放歌曲 - if self.singerRole != .leadSinger { - self.switchSingerRole(newRole: .soloSinger) { _, _ in - - } - } - self.startSing(songCode: self.songCode, startPos: 0) - } +// if (config.autoPlay) { +// // 主唱自动播放歌曲 +// if self.singerRole != .leadSinger { +// self.switchSingerRole(newRole: .soloSinger) { _, _ in +// } +// } +// self.startSing(songCode: self.songCode, startPos: 0) +// } } } else { loadMusicListeners.setObject(onMusicLoadStateListener, forKey: "\(self.songCode)" as NSString) - onMusicLoadStateListener.onMusicLoadProgress(songCode: self.songCode, percent: 0, status: .preloading, msg: "", lyricUrl: "") + // onMusicLoadStateListener.onMusicLoadProgress(songCode: self.songCode, percent: 0, status: .preloading, msg: "", lyricUrl: "") // TODO: 只有未缓存时才显示进度条 if mcc?.isPreloaded(songCode: songCode) != 0 { - onMusicLoadStateListener.onMusicLoadProgress(songCode: self.songCode, percent: 0, status: .preloading, msg: "", lyricUrl: "") + onMusicLoadStateListener.onMusicLoadProgress(songCode: self.songCode, percent: 0, state: .preloading, msg: "", lyricUrl: "") } + preloadMusic(with: songCode) { [weak self] status, songCode in guard let self = self else { return } if self.songCode != songCode { @@ -723,7 +847,6 @@ extension KTVApiImpl { if mode == .loadMusicAndLrc { // 需要加载歌词 self.loadLyric(with: songCode) { url in - agoraPrint("loadMusicAndLrc: songCode:\(songCode) status:\(status.rawValue) ulr:\(String(describing: url))") if self.songCode != songCode { onMusicLoadStateListener.onMusicLoadFail(songCode: songCode, reason: .cancled) return @@ -732,35 +855,35 @@ extension KTVApiImpl { self.lyricUrlMap[String(songCode)] = urlPath self.setLyric(with: urlPath) { lyricUrl in onMusicLoadStateListener.onMusicLoadSuccess(songCode: songCode, lyricUrl: urlPath) + self.agoraPrint("loadMusicAndLrc: songCode:\(songCode) status:\(status.rawValue) ulr:\(String(describing: url))") } } else { onMusicLoadStateListener.onMusicLoadFail(songCode: songCode, reason: .noLyricUrl) + self.agoraPrint("loadMusicAndLrc: songCode:\(songCode) status:\(status.rawValue) ulr:\(String(describing: url))") } - if config.autoPlay { - // 主唱自动播放歌曲 - if self.singerRole != .leadSinger { - self.switchSingerRole(newRole: .soloSinger) { _, _ in - - } - } - self.startSing(songCode: self.songCode, startPos: 0) - } +// if config.autoPlay { +// // 主唱自动播放歌曲 +// if self.singerRole != .leadSinger { +// self.switchSingerRole(newRole: .soloSinger) { _, _ in +// } +// } +// self.startSing(songCode: self.songCode, startPos: 0) +// } } } else if mode == .loadMusicOnly { agoraPrint("loadMusicOnly: songCode:\(songCode) load success") - if config.autoPlay { - // 主唱自动播放歌曲 - if self.singerRole != .leadSinger { - self.switchSingerRole(newRole: .soloSinger) { _, _ in - - } - } - self.startSing(songCode: self.songCode, startPos: 0) - } +// if config.autoPlay { +// // 主唱自动播放歌曲 +// if self.singerRole != .leadSinger { +// self.switchSingerRole(newRole: .soloSinger) { _, _ in +// } +// } +// self.startSing(songCode: self.songCode, startPos: 0) +// } onMusicLoadStateListener.onMusicLoadSuccess(songCode: songCode, lyricUrl: "") } } else { - agoraPrint("load music failed songCode:\(songCode)") + agoraPrintError("load music failed songCode:\(songCode)") onMusicLoadStateListener.onMusicLoadFail(songCode: songCode, reason: .musicPreloadFail) } } @@ -769,18 +892,28 @@ extension KTVApiImpl { private func loadLyric(with songCode: NSInteger, callBack:@escaping LyricCallback) { agoraPrint("loadLyric songCode: \(songCode)") - let requestId: String = self.mcc?.getLyric(songCode: songCode, lyricType: 0) ?? "" + guard let mcc = self.mcc else { + agoraPrint("loadLyric songCode: \(songCode) fail") + callBack(nil) + return + } + let requestId: String = mcc.getLyric(songCode: songCode, lyricType: 0) self.lyricCallbacks.updateValue(callBack, forKey: requestId) } private func preloadMusic(with songCode: Int, callback: @escaping LoadMusicCallback) { agoraPrint("preloadMusic songCode: \(songCode)") - if self.mcc?.isPreloaded(songCode: songCode) == 0 { + guard let mcc = self.mcc else { + agoraPrint("preloadMusic songCode: \(songCode) fail") + callback(.error, songCode) + return + } + if mcc.isPreloaded(songCode: songCode) == 0 { musicCallbacks.removeValue(forKey: String(songCode)) callback(.OK, songCode) return } - let err = self.mcc?.preload(songCode: songCode, jsonOption: nil) + let err = mcc.preload(songCode: songCode, jsonOption: nil) if err != 0 { musicCallbacks.removeValue(forKey: String(songCode)) callback(.error, songCode) @@ -796,11 +929,15 @@ extension KTVApiImpl { } func startSing(songCode: Int, startPos: Int) { - sendCustomMessage(with: "startSing", label: "songCode:\(songCode), startPos: \(startPos)") + let dict: [String: Any] = [ + "songCode": songCode, + "startPos": startPos + ] + sendCustomMessage(with: "startSing", dict: dict) let role = singerRole agoraPrint("startSing role: \(role.rawValue)") if self.songCode != songCode { - agoraPrint("startSing failed: canceled") + agoraPrintError("startSing failed: canceled") return } @@ -809,20 +946,24 @@ extension KTVApiImpl { } apiConfig?.engine?.adjustPlaybackSignalVolume(Int(remoteVolume)) let ret = (mediaPlayer as? AgoraMusicPlayerProtocol)?.openMedia(songCode: songCode, startPos: startPos) - agoraPrint("startSing->openMedia(\(songCode) fail: \(ret ?? -1)") + agoraPrintError("startSing->openMedia(\(songCode) fail: \(ret ?? -1)") } func startSing(url: String, startPos: Int) { - sendCustomMessage(with: "startSing", label: "url:\(url), startPos: \(startPos)") + let dict: [String: Any] = [ + "url": url, + "startPos": startPos + ] + sendCustomMessage(with: "startSing", dict: dict) let role = singerRole agoraPrint("startSing role: \(role.rawValue)") if self.songUrl != songUrl { - agoraPrint("startSing failed: canceled") + agoraPrintError("startSing failed: canceled") return } apiConfig?.engine?.adjustPlaybackSignalVolume(Int(remoteVolume)) let ret = mediaPlayer?.open(url, startPos: 0) - agoraPrint("startSing->openMedia(\(url) fail: \(ret ?? -1)") + agoraPrintError("startSing->openMedia(\(url) fail: \(ret ?? -1)") } /** @@ -830,7 +971,7 @@ extension KTVApiImpl { */ @objc public func stopSing() { agoraPrint("stopSing") - sendCustomMessage(with: "stopSing", label: "") + sendCustomMessage(with: "stopSing", dict: [:]) let mediaOption = AgoraRtcChannelMediaOptions() mediaOption.publishMediaPlayerAudioTrack = false apiConfig?.engine?.updateChannel(with: mediaOption) @@ -850,6 +991,7 @@ extension KTVApiImpl { @objc func enableProfessionalStreamerMode(_ enable: Bool) { if self.isPublishAudio == false {return} + agoraPrint("enableProfessionalStreamerMode enable:\(enable)") self.enableProfessional = enable //专业非专业还需要根据是否佩戴耳机来判断是否开启3A apiConfig?.engine?.setAudioProfile(enable ? .musicHighQualityStereo : .musicStandardStereo) @@ -868,6 +1010,7 @@ extension KTVApiImpl { } } + } // rtc的子频道代理回调 @@ -888,9 +1031,8 @@ extension KTVApiImpl: AgoraRtcEngineDelegate { } public func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) { - agoraPrint("didOccurError: \(errorCode.rawValue)") + agoraPrintError("didOccurError: \(errorCode.rawValue)") if errorCode != .joinChannelRejected {return} - agoraPrint("join ex channel failed") engine.setAudioScenario(.gameStreaming) if joinChorusNewRole == .leadSinger { mainSingerHasJoinChannelEx = false @@ -928,27 +1070,17 @@ extension KTVApiImpl { let ntpTime = dict["ntp"] as? Int, let songId = dict["songIdentifier"] as? String else { return } - agoraPrint("realTime:\(realPosition) position:\(position) lastNtpTime:\(lastNtpTime) ntpTime:\(ntpTime) ntpGap:\(ntpTime - self.lastNtpTime) ") - //如果接收到的歌曲和自己本地的歌曲不一致就不更新进度 -// guard songCode == self.songCode else { -// agoraPrint("local songCode[\(songCode)] is not equal to recv songCode[\(self.songCode)] role: \(singerRole.rawValue)") -// return -// } self.lastNtpTime = ntpTime self.remotePlayerDuration = TimeInterval(duration) let state = AgoraMediaPlayerState(rawValue: mainSingerState) ?? .stopped -// self.lastMainSingerUpdateTime = Date().milListamp -// self.remotePlayerPosition = TimeInterval(realPosition) if self.playerState != state { agoraPrint("[setLrcTime] recv state: \(self.playerState.rawValue)->\(state.rawValue) role: \(singerRole.rawValue) role: \(singerRole.rawValue)") if state == .playing, singerRole == .coSinger, playerState == .openCompleted { //如果是伴唱等待主唱开始播放,seek 到指定位置开始播放保证歌词显示位置准确 self.localPlayerPosition = self.lastMainSingerUpdateTime - Double(position) - print("localPlayerPosition:playerKit:handleSetLrcTimeCommand \(localPlayerPosition)") - agoraPrint("seek toPosition: \(position)") mediaPlayer?.seek(toPosition: Int(position)) } @@ -960,28 +1092,25 @@ extension KTVApiImpl { self.remotePlayerPosition = TimeInterval(realPosition) handleCoSingerRole(dict: dict) } else if role == .audience { - if self.songIdentifier == songId { - self.lastMainSingerUpdateTime = Date().milListamp - self.remotePlayerPosition = TimeInterval(realPosition) + if dict.keys.contains("ver") { + recvFromDataStream = false } else { - self.lastMainSingerUpdateTime = 0 - self.remotePlayerPosition = 0 + recvFromDataStream = true + if self.songIdentifier == songId { + self.lastMainSingerUpdateTime = Date().milListamp + self.remotePlayerPosition = TimeInterval(realPosition) + } else { + self.lastMainSingerUpdateTime = 0 + self.remotePlayerPosition = 0 + } + handleAudienceRole(dict: dict) } - handleAudienceRole(dict: dict) } } private func handlePlayerStateCommand(dict: [String: Any], role: KTVSingRole) { let mainSingerState: Int = dict["state"] as? Int ?? 0 let state = AgoraMediaPlayerState(rawValue: mainSingerState) ?? .idle -// -// if state == .playing, singerRole == .coSinger, playerState == .openCompleted { -// //如果是伴唱等待主唱开始播放,seek 到指定位置开始播放保证歌词显示位置准确 -// self.localPlayerPosition = getPlayerCurrentTime() -// print("localPlayerPosition:playerKit:handlePlayerStateCommand \(localPlayerPosition)") -// agoraPrint("seek toPosition: \(self.localPlayerPosition)") -// mediaPlayer?.seek(toPosition: Int(self.localPlayerPosition)) -// } agoraPrint("recv state with MainSinger: \(state.rawValue)") syncPlayStateFromRemote(state: state, needDisplay: true) @@ -1010,9 +1139,9 @@ extension KTVApiImpl { let threshold = expectPosition - Int(localPosition) let ntpTime = dict["ntp"] as? Int ?? 0 let time = dict["time"] as? Int64 ?? 0 - agoraPrint("checkNtp, diff:\(threshold), localNtp:\(getNtpTimeInMs()), localPosition:\(localPosition), audioPlayoutDelay:\(audioPlayoutDelay), remoteDiff:\(String(describing: ntpTime - Int(time)))") + // agoraPrint("checkNtp, diff:\(threshold), localNtp:\(getNtpTimeInMs()), localPosition:\(localPosition), audioPlayoutDelay:\(audioPlayoutDelay), remoteDiff:\(String(describing: ntpTime - Int(time)))") if abs(threshold) > 50 { - print("expectPosition:\(expectPosition)") + agoraPrint("expectPosition:\(expectPosition)") mediaPlayer?.seek(toPosition: expectPosition) } } @@ -1026,7 +1155,7 @@ extension KTVApiImpl { let mainSingerUid = dict["uid"] as? Int ?? 0 songConfig?.mainSingerUid = mainSingerUid let ret = apiConfig?.engine?.muteRemoteAudioStream(UInt(mainSingerUid), mute: true) - print("ret:\(ret)") + agoraPrint("handleCosingerToLeadSinger:ret:\(String(describing: ret))") } } } @@ -1073,7 +1202,31 @@ extension KTVApiImpl { if self.singerRole != .audience { current = Date().milListamp - self.lastReceivedPosition + Double(self.localPosition) } - self.setProgress(with: Int(current) + Int(self.startHighTime)) + + if self.singerRole == .audience && !recvFromDataStream { + + } else { + var curTime:Int64 = Int64(current) + Int64(self.startHighTime) + if songConfig?.songCutter == true { + curTime = curTime - preludeDuration > 0 ? curTime - preludeDuration : curTime + } + if self.singerRole != .audience { + current = Date().milListamp - self.lastReceivedPosition + Double(self.localPosition) + + if self.singerRole == .leadSinger || self.singerRole == .soloSinger { + var time: LrcTime = LrcTime() + time.forward = true + time.ts = curTime + time.songID = songIdentifier + time.type = .lrcTime + //大合唱的uid是musicuid + time.uid = Int32(apiConfig?.localUid ?? 0) + sendMetaMsg(with: time) + } + } + self.setProgress(with: Int(curTime)) + } + self.oldPitch = self.pitch }) } @@ -1163,13 +1316,13 @@ extension KTVApiImpl { resumeSing() } else if (state == .playBackAllLoopsCompleted && needDisplay == true) { getEventHander { delegate in - delegate.onMusicPlayerStateChanged(state: state, error: .none, isLocal: true) + delegate.onMusicPlayerStateChanged(state: state, reason: .none, isLocal: true) } } } else { self.playerState = state getEventHander { delegate in - delegate.onMusicPlayerStateChanged(state: self.playerState, error: .none, isLocal: false) + delegate.onMusicPlayerStateChanged(state: self.playerState, reason: .none, isLocal: false) } } } @@ -1208,22 +1361,25 @@ extension KTVApiImpl { return localNtpTime } - private func syncPlayState(state: AgoraMediaPlayerState, error: AgoraMediaPlayerError) { - let dict: [String: Any] = ["cmd": "PlayerState", "userId": apiConfig?.localUid as Any, "state": state.rawValue, "error": "\(error.rawValue)"] + private func syncPlayState(state: AgoraMediaPlayerState, reason: AgoraMediaPlayerReason) { + let dict: [String: Any] = ["cmd": "PlayerState", "userId": apiConfig?.localUid as Any, "state": state.rawValue, "error": "\(reason.rawValue)"] sendStreamMessageWithDict(dict, success: nil) } - private func sendCustomMessage(with event: String, label: String) { - apiConfig?.engine?.sendCustomReportMessage("scenarioAPI", category: "1_ios_4.0.0", event: event, label: label, value: 0) + private func sendCustomMessage(with event: String, dict: [String: Any]) { + apiRepoter?.reportFuncEvent(name: event, value: dict, ext: [:]) } private func sendStreamMessageWithDict(_ dict: [String: Any], success: ((_ success: Bool) -> Void)?) { let messageData = compactDictionaryToData(dict as [String: Any]) + let sizeInBits = (messageData ?? Data()).count * 8 + totalSize += sizeInBits let code = apiConfig?.engine?.sendStreamMessage(dataStreamId, data: messageData ?? Data()) if code == 0 && success != nil { success!(true) } if code != 0 { agoraPrint("sendStreamMessage fail: \(String(describing: code))") } +// print("totalSize:\(totalSize)") } private func syncPlayState(_ state: AgoraMediaPlayerState) { @@ -1235,6 +1391,14 @@ extension KTVApiImpl { lrcControl?.onUpdatePitch(pitch: Float(self.pitch)) lrcControl?.onUpdateProgress(progress: pos > 200 ? pos - 200 : pos) } + + private func sendMetaMsg(with time: LrcTime) { + let data: Data? = try? time.serializedData() + let code = apiConfig?.engine?.sendAudioMetadata(data ?? Data()) + if code != 0 { + agoraPrintError("sendStreamMessage fail: \(String(describing: code))") + } + } } //主要是MPK的回调 @@ -1253,11 +1417,10 @@ extension KTVApiImpl: AgoraRtcMediaPlayerDelegate { "realTime":position_ms, "ntp": timestamp_ms, "playerState": self.playerState.rawValue, - "songIdentifier": songIdentifier - // "songCode": self.songCode + "songIdentifier": songIdentifier, + "ver":2, ] - agoraPrint("position_ms:\(position_ms), ntp:\(getNtpTimeInMs()), delta:\(self.getNtpTimeInMs() - position_ms), autoPlayoutDelay:\(self.audioPlayoutDelay)") - print("autoPlayoutDelay:\(self.audioPlayoutDelay)") + // agoraPrint("position_ms:\(position_ms), ntp:\(getNtpTimeInMs()), delta:\(self.getNtpTimeInMs() - position_ms), autoPlayoutDelay:\(self.audioPlayoutDelay)") sendStreamMessageWithDict(dict) { _ in @@ -1275,12 +1438,12 @@ extension KTVApiImpl: AgoraRtcMediaPlayerDelegate { } - func AgoraRtcMediaPlayer(_ playerKit: AgoraRtcMediaPlayerProtocol, didChangedTo state: AgoraMediaPlayerState, error: AgoraMediaPlayerError) { + func AgoraRtcMediaPlayer(_ playerKit: AgoraRtcMediaPlayerProtocol, didChangedTo state: AgoraMediaPlayerState, reason: AgoraMediaPlayerReason) { agoraPrint("agoraRtcMediaPlayer didChangedToState: \(state.rawValue) \(self.songCode)") if isRelease {return} if state == .openCompleted { self.localPlayerPosition = Date().milListamp - print("localPlayerPosition:playerKit:openCompleted \(localPlayerPosition)") + agoraPrint("localPlayerPosition:playerKit:openCompleted \(localPlayerPosition)") self.playerDuration = TimeInterval(mediaPlayer?.getDuration() ?? 0) if isMainSinger() { //主唱播放,通过同步消息“setLrcTime”通知伴唱play playerKit.play() @@ -1298,11 +1461,11 @@ extension KTVApiImpl: AgoraRtcMediaPlayerDelegate { } else if state == .playing { apiConfig?.engine?.adjustPlaybackSignalVolume(Int(remoteVolume)) self.localPlayerPosition = Date().milListamp - Double(mediaPlayer?.getPosition() ?? 0) - print("localPlayerPosition:playerKit:playing \(localPlayerPosition)") + agoraPrint("localPlayerPosition:playerKit:playing \(localPlayerPosition)") } if isMainSinger() { - syncPlayState(state: state, error: error) + syncPlayState(state: state, reason: reason) } self.playerState = state agoraPrint("recv state with player callback : \(state.rawValue)") @@ -1310,10 +1473,10 @@ extension KTVApiImpl: AgoraRtcMediaPlayerDelegate { return } getEventHander { delegate in - delegate.onMusicPlayerStateChanged(state: state, error: .none, isLocal: true) + delegate.onMusicPlayerStateChanged(state: state, reason: .none, isLocal: true) } } - + private func isMainSinger() -> Bool { return singerRole == .soloSinger || singerRole == .leadSinger } @@ -1322,7 +1485,7 @@ extension KTVApiImpl: AgoraRtcMediaPlayerDelegate { //主要是MCC的回调 extension KTVApiImpl: AgoraMusicContentCenterEventDelegate { - func onSongSimpleInfoResult(_ requestId: String, songCode: Int, simpleInfo: String?, errorCode: AgoraMusicContentCenterStatusCode) { + func onSongSimpleInfoResult(_ requestId: String, songCode: Int, simpleInfo: String?, reason: AgoraMusicContentCenterStateReason) { if let jsonData = simpleInfo?.data(using: .utf8) { do { let jsonMsg = try JSONSerialization.jsonObject(with: jsonData, options: []) as! [String: Any] @@ -1330,96 +1493,102 @@ extension KTVApiImpl: AgoraMusicContentCenterEventDelegate { let highPart = format["highPart"] as! [[String: Any]] let highStartTime = highPart[0]["highStartTime"] as! Int let highEndTime = highPart[0]["highEndTime"] as! Int + if highPart[0].keys.contains("preludeDuration") { + self.preludeDuration = highPart[0]["preludeDuration"] as! Int64 + } let time = highStartTime startHighTime = time self.lrcControl?.onHighPartTime(highStartTime: highStartTime, highEndTime: highEndTime) } catch { - print("Error while parsing JSON: \(error.localizedDescription)") + agoraPrintError("Error while parsing JSON: \(error.localizedDescription)") } } - if (errorCode == .errorGateway) { + if (reason == .errorGateway) { getEventHander { delegate in delegate.onTokenPrivilegeWillExpire() } } } - - func onMusicChartsResult(_ requestId: String, result: [AgoraMusicChartInfo], errorCode: AgoraMusicContentCenterStatusCode) { + + func onMusicChartsResult(_ requestId: String, result: [AgoraMusicChartInfo], reason: AgoraMusicContentCenterStateReason) { guard let callback = musicChartDict[requestId] else {return} - callback(requestId, errorCode, result) + callback(requestId, reason, result) musicChartDict.removeValue(forKey: requestId) - if (errorCode == .errorGateway) { + if (reason == .errorGateway) { getEventHander { delegate in delegate.onTokenPrivilegeWillExpire() } } } - func onMusicCollectionResult(_ requestId: String, result: AgoraMusicCollection, errorCode: AgoraMusicContentCenterStatusCode) { + func onMusicCollectionResult(_ requestId: String, result: AgoraMusicCollection, reason: AgoraMusicContentCenterStateReason) { guard let callback = musicSearchDict[requestId] else {return} - callback(requestId, errorCode, result) + callback(requestId, reason, result) musicSearchDict.removeValue(forKey: requestId) - if (errorCode == .errorGateway) { + if (reason == .errorGateway) { getEventHander { delegate in delegate.onTokenPrivilegeWillExpire() } } } - func onLyricResult(_ requestId: String, songCode: Int, lyricUrl: String?, errorCode: AgoraMusicContentCenterStatusCode) { + func onLyricResult(_ requestId: String, songCode: Int, lyricUrl: String?, reason: AgoraMusicContentCenterStateReason) { + agoraPrint("onLyricResult requestId: \(requestId) songCode: \(songCode) lyricUrl: \(lyricUrl ?? "") reason: \(reason.rawValue)") guard let lrcUrl = lyricUrl else {return} let callback = self.lyricCallbacks[requestId] guard let lyricCallback = callback else { return } self.lyricCallbacks.removeValue(forKey: requestId) - if (errorCode == .errorGateway) { + if (reason == .errorGateway) { getEventHander { delegate in delegate.onTokenPrivilegeWillExpire() } } if lrcUrl.isEmpty { lyricCallback(nil) + agoraPrintError("onLyricResult: lrcUrl.isEmpty") return } lyricCallback(lrcUrl) + agoraPrint("onLyricResult: lrcUrl is \(lrcUrl)") } - func onPreLoadEvent(_ requestId: String, songCode: Int, percent: Int, lyricUrl: String?, status: AgoraMusicContentCenterPreloadStatus, errorCode: AgoraMusicContentCenterStatusCode) { + func onPreLoadEvent(_ requestId: String, songCode: Int, percent: Int, lyricUrl: String?, state: AgoraMusicContentCenterPreloadState, reason: AgoraMusicContentCenterStateReason) { if let listener = self.loadMusicListeners.object(forKey: "\(songCode)" as NSString) as? IMusicLoadStateListener { - listener.onMusicLoadProgress(songCode: songCode, percent: percent, status: status, msg: String(errorCode.rawValue), lyricUrl: lyricUrl) + listener.onMusicLoadProgress(songCode: songCode, percent: percent, state: state, msg: String(reason.rawValue), lyricUrl: lyricUrl) } - if (status == .preloading) { return } - agoraPrint("songCode:\(songCode), status:\(status.rawValue), code:\(errorCode.rawValue)") + if (state == .preloading) { return } + agoraPrint("songCode:\(songCode), status:\(state.rawValue), code:\(reason.rawValue)") let SongCode = "\(songCode)" guard let block = self.musicCallbacks[SongCode] else { return } self.musicCallbacks.removeValue(forKey: SongCode) - if (errorCode == .errorGateway) { + if (reason == .errorGateway) { getEventHander { delegate in delegate.onTokenPrivilegeWillExpire() } } - block(status, songCode) + block(state, songCode) } } -extension Date { - /// 获取当前 秒级 时间戳 - 10位 - /// - var timeStamp : TimeInterval { - let timeInterval: TimeInterval = self.timeIntervalSince1970 - return timeInterval - } - /// 获取当前 毫秒级 时间戳 - 13位 - var milListamp : TimeInterval { - let timeInterval: TimeInterval = self.timeIntervalSince1970 - let millisecond = CLongLong(round(timeInterval*1000)) - return TimeInterval(millisecond) - } -} +//extension Date { +// /// 获取当前 秒级 时间戳 - 10位 +// /// +// var timeStamp : TimeInterval { +// let timeInterval: TimeInterval = self.timeIntervalSince1970 +// return timeInterval +// } +// /// 获取当前 毫秒级 时间戳 - 13位 +// var milListamp : TimeInterval { +// let timeInterval: TimeInterval = self.timeIntervalSince1970 +// let millisecond = CLongLong(round(timeInterval*1000)) +// return TimeInterval(millisecond) +// } +//} extension KTVApiImpl: KTVApiRTCDelegate { func didJoinChannel(channel: String, withUid uid: UInt, elapsed: Int) { - print("ktvapi加入主频道成功") + agoraPrint("ktvapi加入主频道成功") } func didJoinedOfUid(uid: UInt, elapsed: Int) { @@ -1454,11 +1623,12 @@ extension KTVApiImpl: KTVApiRTCDelegate { func didAudioPublishStateChange(channelId: String, oldState: AgoraStreamPublishState, newState: AgoraStreamPublishState, elapseSinceLastState: Int32) { self.isPublishAudio = newState == .published enableProfessionalStreamerMode(self.enableProfessional) - print("PublishStateChange:\(newState)") + agoraPrint("PublishStateChange:\(newState)") } func receiveStreamMessageFromUid(uid: UInt, streamId: Int, data: Data) { let role = singerRole + if isRelease {return} guard let dict = dataToDictionary(data: data), let cmd = dict["cmd"] as? String else { return } switch cmd { @@ -1481,8 +1651,8 @@ extension KTVApiImpl: KTVApiRTCDelegate { } func didRTCAudioRouteChanged(routing: AgoraAudioOutputRouting) { - print("Route changed:\(routing)") - let headPhones: [AgoraAudioOutputRouting] = [.headset, .headsetBluetooth, .headsetNoMic] + agoraPrint("Route changed:\(routing)") + let headPhones: [AgoraAudioOutputRouting] = [.headset, .bluetoothDeviceHfp, .bluetoothDeviceA2dp, .headsetNoMic] let wearHeadPhone: Bool = headPhones.contains(routing) if wearHeadPhone == self.isWearingHeadPhones { return @@ -1490,7 +1660,17 @@ extension KTVApiImpl: KTVApiRTCDelegate { self.isWearingHeadPhones = wearHeadPhone enableProfessionalStreamerMode(self.enableProfessional) } + + func audioMetadataReceived(uid: UInt, metadata: Data) { + guard let time: LrcTime = try? LrcTime(serializedData: metadata) else {return} + if time.type == .lrcTime && self.singerRole == .audience { + self.setProgress(with: Int(time.ts)) + } + } + @objc func didAudioMetadataReceived( uid: UInt, metadata: Data) { + + } } /*----这一块的代码主要是用来处理主频道的RTC代理事件,外部不再需要手动转代理,😁---*/ @@ -1502,6 +1682,7 @@ protocol KTVApiRTCDelegate: NSObjectProtocol { func didAudioPublishStateChange(channelId: String, oldState: AgoraStreamPublishState, newState: AgoraStreamPublishState, elapseSinceLastState: Int32) func receiveStreamMessageFromUid(uid: UInt, streamId: Int, data: Data) func localAudioStats(stats: AgoraRtcLocalAudioStats) + func audioMetadataReceived( uid: UInt, metadata: Data) } class KTVApiRTCDelegateHandler: NSObject, AgoraRtcEngineDelegate { @@ -1538,5 +1719,15 @@ class KTVApiRTCDelegateHandler: NSObject, AgoraRtcEngineDelegate { func rtcEngine(_ engine: AgoraRtcEngineKit, localAudioStats stats: AgoraRtcLocalAudioStats) { delegate.localAudioStats(stats: stats) } + + func rtcEngine(_ engine: AgoraRtcEngineKit, audioMetadataReceived uid: UInt, metadata: Data) { + delegate.audioMetadataReceived(uid: uid, metadata: metadata) + } } + +extension KTVApiImpl { + @objc public func isSongLoading(songCode: String) -> Bool { + return musicCallbacks[songCode] == nil ? false : true + } +} diff --git a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/KTVGiantChorusApiImpl.swift b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/KTVGiantChorusApiImpl.swift new file mode 100644 index 0000000..cdc5458 --- /dev/null +++ b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/KTVGiantChorusApiImpl.swift @@ -0,0 +1,2059 @@ +// +// KTVApiImpl.swift +// AgoraEntScenarios +// +// Created by wushengtao on 2023/3/14. +// + +import Foundation +import AgoraRtcKit + +/// 加载歌曲状态 +@objc fileprivate enum KTVLoadSongState: Int { + case idle = -1 //空闲 + case ok = 0 //成功 + case failed //失败 + case inProgress //加载中 +} + +fileprivate enum KTVSongMode: Int { + case songCode + case songUrl +} + +@objc class KTVGiantChorusApiImpl: NSObject, KTVApiDelegate{ + + private var apiConfig: GiantChorusConfiguration? + + private var songConfig: KTVSongConfiguration? + private var subChorusConnection: AgoraRtcConnection? + private var singChannelConnection: AgoraRtcConnection? + private var mpkConnection: AgoraRtcConnection? + + private var eventHandlers: NSHashTable = NSHashTable.weakObjects() + private var loadMusicListeners: NSMapTable = NSMapTable(keyOptions: .copyIn, valueOptions: .weakMemory) + + // private var musicPlayer: AgoraRtcMediaPlayerProtocol? //mcc + private var mediaPlayer: AgoraRtcMediaPlayerProtocol? //local + private var mcc: AgoraMusicContentCenter? + + private var loadSongMap = Dictionary() + private var lyricUrlMap = Dictionary() + private var loadDict = Dictionary() + private var lyricCallbacks = Dictionary() + private var musicCallbacks = Dictionary() + + private var hasSendPreludeEndPosition: Bool = false + private var hasSendEndPosition: Bool = false + + //multipath + private var enableMultipathing: Bool = true + + private var audioPlayoutDelay: NSInteger = 0 + private var isNowMicMuted: Bool = false + private var loadSongState: KTVLoadSongState = .idle + private var lastNtpTime: Int = 0 + private var startHighTime: Int = 0 + private var isRelease: Bool = false + private var songUrl2: String = "" + private var playerState: AgoraMediaPlayerState = .idle { + didSet { + agoraPrint("playerState did changed: \(oldValue.rawValue)->\(playerState.rawValue)") + updateRemotePlayBackVolumeIfNeed() + updateTimer(with: playerState) + } + } + private var pitch: Double = 0 + private var localPlayerPosition: TimeInterval = 0 + private var remotePlayerPosition: TimeInterval = 0 + private var remotePlayerDuration: TimeInterval = 0 + private var localPlayerSystemTime: TimeInterval = 0 + private var lastMainSingerUpdateTime: TimeInterval = 0 + private var playerDuration: TimeInterval = 0 + // private lazy var apiDelegateHandler = KTVApiRTCDelegateHandler(with: self) + + private var musicChartDict: [String: MusicChartCallBacks] = [:] + private var musicSearchDict: Dictionary = Dictionary() + private var onJoinExChannelCallBack : JoinExChannelCallBack? + private var mainSingerHasJoinChannelEx: Bool = false + private var dataStreamId: Int = 0 + private var lastReceivedPosition: TimeInterval = 0 + private var localPosition: Int = 0 + + private var songMode: KTVSongMode = .songCode + private var useCustomAudioSource:Bool = false + private var songUrl: String = "" + private var songCode: Int = 0 + private var songIdentifier: String = "" + + private var singerRole: KTVSingRole = .audience { + didSet { + agoraPrint("singerRole changed: \(oldValue.rawValue)->\(singerRole.rawValue)") + } + } + private var lrcControl: KTVLrcViewDelegate? + + private var timer: Timer? + private var isPause: Bool = false + + private var singingScore: Int = 0 + + public var remoteVolume: Int = 30 + private var joinChorusNewRole: KTVSingRole = .audience + private var oldPitch: Double = 0 + private var isWearingHeadPhones: Bool = false + private var enableProfessional: Bool = false + private var isPublishAudio: Bool = false + private var audioRouting: Int = -1 + private var recvFromDataStream = false + //大合唱独有 + private var mStopSyncPitch = true + private var mSyncPitchTimer: DispatchSourceTimer? + private var mStopSyncScore = true + private var mSyncScoreTimer: DispatchSourceTimer? + private var mStopSyncCloudConvergenceStatus = true + private var mSyncCloudConvergenceStatusTimer: DispatchSourceTimer? + private var mStopProcessDelay = true + private var processDelayFuture: DispatchSourceTimer? + private var processSubscribeFuture: DispatchSourceTimer? + private var subScribeSingerMap = [Int: Int]() // + private var singerList = [Int]() // + private var mainSingerDelay = 0 + + private let tag = "KTV_API_LOG" + private let messageId = "agora:scenarioAPI" + private let version = "5.0.0" + private let lyricSyncVersion = 2 + + private var apiRepoter: APIReporter? + + deinit { + mcc?.register(nil) + agoraPrint("deinit KTVApiImpl") + } + + func createKTVGiantChorusApi(config: GiantChorusConfiguration) { + self.apiConfig = config + agoraPrint("createKTVGiantChorusApi") + self.singChannelConnection = AgoraRtcConnection(channelId: config.chorusChannelName, localUid: config.localUid) + + setParams() + + if config.musicType == .mcc { + // ------------------ 初始化内容中心 ------------------ + let contentCenterConfiguration = AgoraMusicContentCenterConfig() + contentCenterConfiguration.appId = config.appId + contentCenterConfiguration.mccUid = config.localUid + contentCenterConfiguration.token = config.rtmToken + contentCenterConfiguration.rtcEngine = config.engine + contentCenterConfiguration.maxCacheSize = UInt(config.maxCacheSize) + if let domain = config.mccDomain { + contentCenterConfiguration.mccDomain = domain + } + mcc = AgoraMusicContentCenter.sharedContentCenter(config: contentCenterConfiguration) + mcc?.register(self) + // ------------------ 初始化音乐播放器实例 ------------------ + mediaPlayer = mcc?.createMusicPlayer(delegate: self) + mediaPlayer?.adjustPlayoutVolume(50) + mediaPlayer?.adjustPublishSignalVolume(50) + } else { + mediaPlayer = apiConfig?.engine?.createMediaPlayer(with: self) + // 音量最佳实践调整 + mediaPlayer?.adjustPlayoutVolume(50) + mediaPlayer?.adjustPublishSignalVolume(50) + } + + apiRepoter = APIReporter(type: .ktv, version: version, engine: apiConfig?.engine ?? AgoraRtcEngineKit()) + + initTimer() + mediaPlayer?.setPlayerOption("play_pos_change_callback", value: 100) + apiConfig?.engine?.setDelegateEx(self, connection: mpkConnection ?? AgoraRtcConnection()) + startSyncPitch() + startSyncScore() + startSyncCloudConvergenceStatus() + } + + private func setParams() { + guard let engine = self.apiConfig?.engine else {return} + engine.setParameters("{\"rtc.enable_nasa2\": true}") + engine.setParameters("{\"rtc.ntp_delay_drop_threshold\": 1000}") + engine.setParameters("{\"rtc.video.enable_sync_render_ntp\": true}") + engine.setParameters("{\"rtc.net.maxS2LDelay\": 800}") + engine.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\": true}") + engine.setParameters("{\"rtc.net.maxS2LDelayBroadcast\": 400}") + engine.setParameters("{\"che.audio.neteq.prebuffer\": true}") + engine.setParameters("{\"che.audio.neteq.prebuffer_max_delay\": 600}") + engine.setParameters("{\"che.audio.max_mixed_participants\": 8}") + engine.setParameters("{\"che.audio.custom_bitrate\": 48000}") + engine.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}") + engine.setParameters("{\"che.audio.neteq.targetlevel_offset\": 20}") + engine.setParameters("{\"che.audio.uplink_apm_async_process\": true}") + // 标准音质 + engine.setParameters("{\"che.audio.aec.split_srate_for_48k\": 16000}") + engine.setParameters("{\"che.audio.ans.noise_gate\": 20}")// + engine.setParameters("{\"rtc.use_audio4\": true}") + + //4.3.0 add + // mutipath + enableMultipathing = true + engine.setParameters("{\"rtc.enable_tds_request_on_join\": true}") + engine.setParameters("{\"rtc.remote_path_scheduling_strategy\": 0}") + engine.setParameters("{\"rtc.path_scheduling_strategy\": 0}") + engine.setParameters("{\"rtc.enableMultipath\": true}") + + // 数据上报 + engine.setParameters("{\"rtc.direct_send_custom_event\": true}") + // engine.setParameters("{\"rtc.qos_for_test_purpose\": true}") + } + + func renewInnerDataStreamId() { + let dataStreamConfig = AgoraDataStreamConfig() + dataStreamConfig.ordered = false + dataStreamConfig.syncWithAudio = true + self.apiConfig?.engine?.createDataStreamEx(&dataStreamId, config: dataStreamConfig, connection: singChannelConnection ?? AgoraRtcConnection()) + + sendCustomMessage(with: "renewInnerDataStreamId", dict: [:]) + agoraPrint("renewInnerDataStreamId") + } +} + +//MARK: KTVApiDelegate +extension KTVGiantChorusApiImpl { + + func objectContent(of object: Any) -> [String: Any] { + var content = [String: Any]() + + let mirror = Mirror(reflecting: object) + for child in mirror.children { + if let propertyName = child.label { + if let convertibleValue = convertToJSONSerializable(child.value) { + content[propertyName] = convertibleValue + } + } + } + + return content + } + + func convertToJSONSerializable(_ value: Any) -> Any? { + switch value { + case let value as String: + return value + case let value as Int: + return value + case let value as Double: + return value + case let value as Bool: + return value + case let value as Int?: + return value + case let value as Double?: + return value + case let value as Bool?: + return value + case let value as String?: + return value + default: + return nil + } + } + + func getMusicContentCenter() -> AgoraMusicContentCenter? { + return mcc + } + + func setLrcView(view: KTVLrcViewDelegate) { + sendCustomMessage(with: "renewInnerDataStreamId", dict: [:]) + lrcControl = view + } + + //主要针对本地歌曲播放的主唱伴奏切换的 loadmusic MCC直接忽视这个方法 + func load2Music(url1: String, url2: String, config: KTVSongConfiguration) { + agoraPrint("load2Music called: songUrl url1:(url1),url2:(url2)") + self.songMode = .songUrl + self.songConfig = config + self.songIdentifier = config.songIdentifier + self.songUrl = url1 + self.songUrl2 = url2 + +// if config.autoPlay { +// // 主唱自动播放歌曲 +// if self.singerRole != .leadSinger { +// switchSingerRole(newRole: .soloSinger) { state, failRes in +// +// } +// } +// startSing(url: url1, startPos: 0) +// } + } + + //主要针对本地歌曲播放的主唱伴奏切换的 MCC直接忽视这个方法 + func switchPlaySrc(url: String, syncPts: Bool) { + agoraPrint("switchPlaySrc called: \(url)") + + if self.songUrl != url && self.songUrl2 != url { + print("switchPlaySrc failed: canceled") + return + } + + let curPlayPosition: Int = syncPts ? mediaPlayer?.getPosition() ?? 0 : 0 + mediaPlayer?.stop() + startSing(url: url, startPos: curPlayPosition) + } + + func loadMusic(songCode: Int, config: KTVSongConfiguration, onMusicLoadStateListener: IMusicLoadStateListener) { + sendCustomMessage(with: "loadMusicWithSongCode:\(songCode)", dict: objectContent(of: config)) + agoraPrint("loadMusic songCode:\(songCode) ") + self.songMode = .songCode + self.songCode = songCode + self.songIdentifier = config.songIdentifier + _loadMusic(config: config, mode: config.mode, onMusicLoadStateListener: onMusicLoadStateListener) + } + + func loadMusic(config: KTVSongConfiguration, url: String) { + sendCustomMessage(with: "loadMusicWithUrl:\(url)", dict: objectContent(of: config)) + agoraPrint("loadMusic url:\(url)") + self.songMode = .songUrl + self.songUrl = url + self.songIdentifier = config.songIdentifier +// if config.autoPlay { +// // 主唱自动播放歌曲 +// if singerRole != .leadSinger { +// switchSingerRole(newRole: .soloSinger) { _, _ in +// +// } +// } +// startSing(url: url, startPos: 0) +// } + } + + func getMusicPlayer() -> AgoraRtcMediaPlayerProtocol? { + return mediaPlayer + } + + func addEventHandler(ktvApiEventHandler: KTVApiEventHandlerDelegate) { + sendCustomMessage(with: "addEventHandler", dict: [:]) + agoraPrint("addEventHandler") + if eventHandlers.contains(ktvApiEventHandler) { + return + } + eventHandlers.add(ktvApiEventHandler) + } + + func removeEventHandler(ktvApiEventHandler: KTVApiEventHandlerDelegate) { + sendCustomMessage(with: "removeEventHandler", dict: [:]) + agoraPrint("removeEventHandler") + eventHandlers.remove(ktvApiEventHandler) + } + + func cleanCache() { + sendCustomMessage(with: "cleanCache", dict: [:]) + isRelease = true + mediaPlayer?.stop() + freeTimer() + agoraPrint("cleanCache") + singerRole = .audience + + stopSyncCloudConvergenceStatus() + stopSyncScore() + singingScore = 0 + lrcControl = nil + lyricCallbacks.removeAll() + musicCallbacks.removeAll() + onJoinExChannelCallBack = nil + loadMusicListeners.removeAllObjects() + apiConfig?.engine?.destroyMediaPlayer(mediaPlayer) + mediaPlayer = nil + if apiConfig?.musicType == .mcc { + mcc?.register(nil) + mcc = nil + } + apiConfig = nil + AgoraMusicContentCenter.destroy() + self.eventHandlers.removeAllObjects() + } + + @objc public func enableMutipath(enable: Bool) { + sendCustomMessage(with: "enableMutipath", dict: ["enable":enable]) + agoraPrint("enableMutipath:\(enable)") + enableMultipathing = enable + if singerRole == .coSinger || singerRole == .leadSinger { + if let subChorusConnection = subChorusConnection { + apiConfig?.engine?.setParametersEx("{\"rtc.enableMultipath\": \(enable), \"rtc.path_scheduling_strategy\": 0, \"rtc.remote_path_scheduling_strategy\": 0}", connection: subChorusConnection) + } + } + } + + func renewToken(rtmToken: String, chorusChannelRtcToken: String) { + let dict: [String: Any] = [ + "rtmToken":rtmToken, + "chorusChannelRtcToken":chorusChannelRtcToken + ] + sendCustomMessage(with: "renewToken", dict: dict) + agoraPrint("renewToken rtmToken:\(rtmToken) chorusChannelRtcToken:\(chorusChannelRtcToken)") + // 更新RtmToken + mcc?.renewToken(rtmToken) + // 更新合唱频道RtcToken + if let subChorusConnection = subChorusConnection { + let channelMediaOption = AgoraRtcChannelMediaOptions() + channelMediaOption.token = chorusChannelRtcToken + apiConfig?.engine?.updateChannelEx(with: channelMediaOption, connection: subChorusConnection) + } + } + + func fetchMusicCharts(completion: @escaping MusicChartCallBacks) { + sendCustomMessage(with: "fetchMusicCharts", dict: [:]) + agoraPrint("fetchMusicCharts") + let requestId = mcc!.getMusicCharts() + musicChartDict[requestId] = completion + } + + func searchMusic(musicChartId: Int, + page: Int, + pageSize: Int, + jsonOption: String, + completion:@escaping (String, AgoraMusicContentCenterStateReason, AgoraMusicCollection) -> Void) { + agoraPrint("searchMusic with musicChartId: \(musicChartId)") + let dict: [String: Any] = [ + "musicChartId":musicChartId, + "page": page, + "pageSize": pageSize, + "jsonOption": jsonOption + ] + sendCustomMessage(with: "searchMusic", dict: dict) + let requestId = mcc!.getMusicCollection(musicChartId: musicChartId, page: page, pageSize: pageSize, jsonOption: jsonOption) + musicSearchDict[requestId] = completion + } + + func searchMusic(keyword: String, + page: Int, + pageSize: Int, + jsonOption: String, + completion: @escaping (String, AgoraMusicContentCenterStateReason, AgoraMusicCollection) -> Void) { + agoraPrint("searchMusic with keyword: \(keyword)") + let dict: [String: Any] = [ + "keyword": keyword, + "page": page, + "pageSize": pageSize, + "jsonOption": jsonOption + ] + sendCustomMessage(with: "searchMusic", dict: dict) + let requestId = mcc!.searchMusic(keyWord: keyword, page: page, pageSize: pageSize, jsonOption: jsonOption) + musicSearchDict[requestId] = completion + } + +// func switchSingerRole(newRole: KTVSingRole, onSwitchRoleState: @escaping (KTVSwitchRoleState, KTVSwitchRoleFailReason) -> Void) { +// let oldRole = singerRole +// self.switchSingerRole(oldRole: oldRole, newRole: newRole, token: apiConfig?.chorusChannelToken ?? "", stateCallBack: onSwitchRoleState) +// } + + /** + * 恢复播放 + */ + @objc public func resumeSing() { + sendCustomMessage(with: "resumeSing", dict: [:]) + agoraPrint("resumeSing") + if mediaPlayer?.getPlayerState() == .paused { + mediaPlayer?.resume() + } else { + let ret = mediaPlayer?.play() + agoraPrint("resumeSing ret: \(ret ?? -1)") + } + } + + /** + * 暂停播放 + */ + @objc public func pauseSing() { + sendCustomMessage(with: "pauseSing", dict: [:]) + agoraPrint("pauseSing") + mediaPlayer?.pause() + } + + /** + * 调整进度 + */ + @objc public func seekSing(time: NSInteger) { + sendCustomMessage(with: "seekSing", dict: ["time":time]) + agoraPrint("seekSing") + mediaPlayer?.seek(toPosition: time) + } + + /** + * 选择音轨,原唱、伴唱 + */ +// @objc public func selectPlayerTrackMode(mode: KTVPlayerTrackMode) { +// apiConfig?.engine.selectAudioTrack(mode == .original ? 0 : 1) +// } + + /** + * 设置当前mic开关状态 + */ + @objc public func muteMic(muteStatus: Bool) { + sendCustomMessage(with: "setMicStatus", dict: ["muteStatus":muteStatus]) + agoraPrint("setMicStatus status:\(muteStatus)") + self.isNowMicMuted = muteStatus + if self.singerRole == .leadSinger || self.singerRole == .soloSinger { + apiConfig?.engine?.adjustRecordingSignalVolume(muteStatus ? 0 : 100) + } else { + apiConfig?.engine?.muteLocalAudioStream(muteStatus) + } + } + + @objc public func removeMusic(songCode: Int) { + sendCustomMessage(with: "removeMusic", dict: ["songCode": songCode]) + agoraPrint("removeMusic:\(songCode)") + let ret: Int = mcc?.removeCache(songCode: songCode) ?? 0 + if ret < 0 { + agoraPrint("removeMusic failed: ret:\(ret)") + } + } + + private func agoraPrint(_ message: String) { + apiRepoter?.writeLog(content: message, level: .info) + } + + private func agoraPrintError(_ message: String) { + apiRepoter?.writeLog(content: message, level: .error) + } + +} + +// 主要是角色切换,加入合唱,加入多频道,退出合唱,退出多频道 +extension KTVGiantChorusApiImpl { +// private func switchSingerRole(oldRole: KTVSingRole, newRole: KTVSingRole, token: String, stateCallBack:@escaping ISwitchRoleStateListener) { +// // agoraPrint("switchSingerRole oldRole: \(oldRole.rawValue), newRole: \(newRole.rawValue)") +// if oldRole == .audience && newRole == .soloSinger { +// // 1、KTVSingRoleAudience -》KTVSingRoleMainSinger +// singerRole = newRole +// becomeSoloSinger() +// getEventHander { delegate in +// delegate.onSingerRoleChanged(oldRole: .audience, newRole: .soloSinger) +// } +// +// stateCallBack(.success, .none) +// } else if oldRole == .audience && newRole == .leadSinger { +// becomeSoloSinger() +// joinChorus(role: newRole, token: token, joinExChannelCallBack: {[weak self] flag, status in +// guard let self = self else {return} +// //还原临时变量为观众 +// self.joinChorusNewRole = .audience +// +// if flag == true { +// self.singerRole = newRole +// self.getEventHander { delegate in +// delegate.onSingerRoleChanged(oldRole: .audience, newRole: .leadSinger) +// } +// stateCallBack(.success, .none) +// } else { +// self.leaveChorus(role: .leadSinger) +// stateCallBack(.fail, .joinChannelFail) +// } +// }) +// +// } else if oldRole == .soloSinger && newRole == .audience { +// stopSing() +// singerRole = newRole +// getEventHander { delegate in +// delegate.onSingerRoleChanged(oldRole: .soloSinger, newRole: .audience) +// } +// +// stateCallBack(.success, .none) +// } else if oldRole == .audience && newRole == .coSinger { +// joinChorus(role: newRole, token: token, joinExChannelCallBack: {[weak self] flag, status in +// guard let self = self else {return} +// //还原临时变量为观众 +// self.joinChorusNewRole = .audience +// if flag == true { +// self.singerRole = newRole +// //TODO(chenpan):如果观众变成伴唱,需要重置state,防止同步主唱state因为都是playing不会修改 +// //后面建议改成remote state(通过data stream获取)和local state(通过player didChangedToState获取) +// self.playerState = self.mediaPlayer?.getPlayerState() ?? .idle +// self.getEventHander { delegate in +// delegate.onSingerRoleChanged(oldRole: .audience, newRole: .coSinger) +// } +// stateCallBack(.success, .none) +// } else { +// self.leaveChorus(role: .coSinger) +// stateCallBack(.fail, .joinChannelFail) +// } +// }) +// } else if oldRole == .coSinger && newRole == .audience { +// leaveChorus(role: .coSinger) +// singerRole = newRole +// getEventHander { delegate in +// delegate.onSingerRoleChanged(oldRole: .coSinger, newRole: .audience) +// } +// +// stateCallBack(.success, .none) +// } else if oldRole == .soloSinger && newRole == .leadSinger { +// joinChorus(role: newRole, token: token, joinExChannelCallBack: {[weak self] flag, status in +// guard let self = self else {return} +// //还原临时变量为观众 +// self.joinChorusNewRole = .audience +// if flag == true { +// self.singerRole = newRole +// self.getEventHander { delegate in +// delegate.onSingerRoleChanged(oldRole: .soloSinger, newRole: .leadSinger) +// } +// stateCallBack(.success, .none) +// } else { +// self.leaveChorus(role: .leadSinger) +// stateCallBack(.fail, .joinChannelFail) +// } +// }) +// } else if oldRole == .leadSinger && newRole == .soloSinger { +// leaveChorus(role: .leadSinger) +// singerRole = newRole +// getEventHander { delegate in +// delegate.onSingerRoleChanged(oldRole: .leadSinger, newRole: .soloSinger) +// } +// +// stateCallBack(.success, .none) +// } else if oldRole == .leadSinger && newRole == .audience { +// leaveChorus(role: .leadSinger) +// stopSing() +// singerRole = newRole +// getEventHander { delegate in +// delegate.onSingerRoleChanged(oldRole: .leadSinger, newRole: .audience) +// } +// +// stateCallBack(.success, .none) +// } else { +// stateCallBack(.fail, .noPermission) +// agoraPrint("Error!You can not switch role from \(oldRole.rawValue) to \(newRole.rawValue)!") +// } +// +// } + + func switchSingerRole(newRole: KTVSingRole, onSwitchRoleState: @escaping (KTVSwitchRoleState, KTVSwitchRoleFailReason) -> Void) { + + agoraPrint("switchSingerRole oldRole: \(singerRole), newRole: \(newRole)") + let oldRole = singerRole + + if singerRole == .audience && newRole == .leadSinger { + // 1、Audience -》LeadSinger + // 离开观众频道 + apiConfig?.engine?.leaveChannelEx(AgoraRtcConnection(channelId: apiConfig?.channelName ?? "", localUid: apiConfig?.localUid ?? 0)) + joinChorus(newRole: newRole) + self.singerRole = newRole + self.getEventHander { delegate in + delegate.onSingerRoleChanged(oldRole: .audience, newRole: .leadSinger) + } + onSwitchRoleState(.success, .none) + } else if singerRole == .audience && newRole == .coSinger { + // 2、Audience -》CoSinger + // 离开观众频道 + apiConfig?.engine?.leaveChannelEx(AgoraRtcConnection( channelId: apiConfig?.channelName ?? "", localUid: apiConfig?.localUid ?? 0)) + joinChorus(newRole: newRole) + singerRole = newRole + self.getEventHander { delegate in + delegate.onSingerRoleChanged(oldRole: .audience, newRole: .coSinger) + } + onSwitchRoleState(.success, .none) + } else if singerRole == .coSinger && newRole == .audience { + // 3、CoSinger -》Audience + leaveChorus2(role: singerRole) + // 加入观众频道 + apiConfig?.engine?.joinChannelEx(byToken: apiConfig?.audienceChannelToken, + connection: AgoraRtcConnection(channelId: apiConfig?.channelName ?? "", localUid: apiConfig?.localUid ?? 0), + delegate: self, + mediaOptions: AgoraRtcChannelMediaOptions(), + joinSuccess: {[weak self] _,_, _ in + }) + apiConfig?.engine?.setParametersEx("{\"rtc.use_audio4\": true}", connection: AgoraRtcConnection(channelId: apiConfig?.channelName ?? "", localUid: apiConfig?.localUid ?? 0)) + self.singerRole = newRole + self.getEventHander { delegate in + delegate.onSingerRoleChanged(oldRole: oldRole, newRole: newRole) + } + onSwitchRoleState(.success, .none) + } else if singerRole == .leadSinger && newRole == .audience { + // 4、LeadSinger -》Audience + stopSing() + leaveChorus2(role: singerRole) + // 加入观众频道 + apiConfig?.engine?.joinChannelEx(byToken: apiConfig?.audienceChannelToken, + connection: AgoraRtcConnection(channelId: apiConfig?.channelName ?? "", localUid: apiConfig?.localUid ?? 0), + delegate: self, + mediaOptions: AgoraRtcChannelMediaOptions(), + joinSuccess: {[weak self] _,_, _ in + }) + apiConfig?.engine?.setParametersEx("{\"rtc.use_audio4\": true}", connection: AgoraRtcConnection(channelId: apiConfig?.channelName ?? "", localUid: apiConfig?.localUid ?? 0)) + self.singerRole = newRole + self.getEventHander { delegate in + delegate.onSingerRoleChanged(oldRole: oldRole, newRole: newRole) + } + onSwitchRoleState(.success, .none) + } else { + onSwitchRoleState(.fail, .noPermission) + print("Error! You can not switch role from \(singerRole) to \(newRole)!") + } + } + + private func becomeSoloSinger() { + apiConfig?.engine?.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":false}") + apiConfig?.engine?.setParameters("{\"che.audio.neteq.enable_stable_playout\":false}") + apiConfig?.engine?.setParameters("{\"che.audio.custom_bitrate\": 80000}") + apiConfig?.engine?.setAudioScenario(.chorus) + agoraPrint("becomeSoloSinger") + let mediaOption = AgoraRtcChannelMediaOptions() + mediaOption.autoSubscribeAudio = true + //mediaOption.autoSubscribeVideo = true + if apiConfig?.musicType == .mcc { + mediaOption.publishMediaPlayerId = Int(mediaPlayer?.getMediaPlayerId() ?? 0) + } else { + mediaOption.publishMediaPlayerId = Int(mediaPlayer?.getMediaPlayerId() ?? 0) + } + mediaOption.publishMediaPlayerAudioTrack = true + apiConfig?.engine?.updateChannel(with: mediaOption) + } + + /** + * 加入合唱 + */ + private func joinChorus(role: KTVSingRole, token: String, joinExChannelCallBack: @escaping JoinExChannelCallBack) { + self.onJoinExChannelCallBack = joinExChannelCallBack + if role == .leadSinger { + agoraPrint("joinChorus: KTVSingRoleMainSinger") + joinChorus2ndChannel(newRole: role, token: token) + } else if role == .coSinger { + + let mediaOption = AgoraRtcChannelMediaOptions() + mediaOption.autoSubscribeAudio = true + // mediaOption.autoSubscribeVideo = true + mediaOption.publishMediaPlayerAudioTrack = false + apiConfig?.engine?.updateChannel(with: mediaOption) + + if apiConfig?.musicType == .mcc { + (mediaPlayer as? AgoraMusicPlayerProtocol)?.openMedia(songCode: self.songCode , startPos: 0) + } else { + mediaPlayer?.open(self.songUrl, startPos: 0) + } + + joinChorus2ndChannel(newRole: role, token: token) + + } else if role == .audience { + agoraPrint("joinChorus fail!") + } + } + + private func joinChorus2ndChannel(newRole: KTVSingRole, token: String) { + let role = newRole + if role == .soloSinger || role == .audience { + agoraPrint("joinChorus2ndChannel with wrong role") + return + } + + agoraPrint("joinChorus2ndChannel role: \(role.rawValue)") + if newRole == .coSinger { + apiConfig?.engine?.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":false}") + apiConfig?.engine?.setParameters("{\"che.audio.neteq.enable_stable_playout\":false}") + apiConfig?.engine?.setParameters("{\"che.audio.custom_bitrate\": 48000}") + apiConfig?.engine?.setAudioScenario(.chorus) + } + + let mediaOption = AgoraRtcChannelMediaOptions() + // main singer do not subscribe 2nd channel + // co singer auto sub + mediaOption.autoSubscribeAudio = role != .leadSinger + // mediaOption.autoSubscribeVideo = false + mediaOption.publishMicrophoneTrack = newRole == .leadSinger + mediaOption.enableAudioRecordingOrPlayout = role != .leadSinger + mediaOption.clientRoleType = .broadcaster + + let rtcConnection = AgoraRtcConnection() + rtcConnection.channelId = apiConfig?.chorusChannelName ?? "" + rtcConnection.localUid = UInt(apiConfig?.localUid ?? 0) + subChorusConnection = rtcConnection + + joinChorusNewRole = role + let ret = apiConfig?.engine?.joinChannelEx(byToken: token, connection: rtcConnection, delegate: self, mediaOptions: mediaOption, joinSuccess: nil) + agoraPrint("joinChannelEx ret: \(ret ?? -999)") + if newRole == .coSinger { + let uid = UInt(songConfig?.mainSingerUid ?? 0) + let ret = + apiConfig?.engine?.muteRemoteAudioStreamEx(uid, mute: false, connection: singChannelConnection ?? AgoraRtcConnection()) + agoraPrint("muteRemoteAudioStream: \(uid), ret: \(ret ?? -1)") + } + apiConfig?.engine?.setParametersEx("{\"rtc.use_audio4\": true}", connection: rtcConnection) + + } + + private func leaveChorus2ndChannel(_ role: KTVSingRole) { + guard let config = songConfig else {return} + guard let subConn = subChorusConnection else {return} + if (role == .leadSinger) { + apiConfig?.engine?.leaveChannelEx(subConn) + } else if (role == .coSinger) { + apiConfig?.engine?.leaveChannelEx(subConn) + apiConfig?.engine?.muteRemoteAudioStreamEx(UInt(config.mainSingerUid), mute: false, connection: singChannelConnection ?? AgoraRtcConnection()) + } + } + + /** + * 离开合唱 + */ + + private func leaveChorus(role: KTVSingRole) { + agoraPrint("leaveChorus role: \(singerRole.rawValue)") + if role == .leadSinger { + mainSingerHasJoinChannelEx = false + leaveChorus2ndChannel(role) + } else if role == .coSinger { + mediaPlayer?.stop() + let mediaOption = AgoraRtcChannelMediaOptions() + // mediaOption.autoSubscribeAudio = true + // mediaOption.autoSubscribeVideo = false + mediaOption.publishMediaPlayerAudioTrack = false + apiConfig?.engine?.updateChannel(with: mediaOption) + leaveChorus2ndChannel(role) + apiConfig?.engine?.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":true}") + apiConfig?.engine?.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}") + apiConfig?.engine?.setParameters("{\"che.audio.custom_bitrate\": 48000}") + apiConfig?.engine?.setAudioScenario(.gameStreaming) + } else if role == .audience { + agoraPrint("joinChorus: KTVSingRoleAudience does not need to leaveChorus!") + } + } + +} + +extension KTVGiantChorusApiImpl { + + private func getEventHander(callBack:((KTVApiEventHandlerDelegate)-> Void)) { + for obj in eventHandlers.allObjects { + if obj is KTVApiEventHandlerDelegate { + callBack(obj as! KTVApiEventHandlerDelegate) + } + } + } + + private func _loadMusic(config: KTVSongConfiguration, mode: KTVLoadMusicMode, onMusicLoadStateListener: IMusicLoadStateListener){ + + songConfig = config + lastReceivedPosition = 0 + localPosition = 0 + + if (config.mode == .loadNone) { + return + } + + if mode == .loadLrcOnly { + loadLyric(with: songCode) { [weak self] url in + guard let self = self else { return } + agoraPrint("loadLrcOnly: songCode:\(self.songCode) ulr:\(String(describing: url))") +// if self.songCode != songCode { +// onMusicLoadStateListener.onMusicLoadFail(songCode: songCode, reason: .cancled) +// return +// } + if let urlPath = url, !urlPath.isEmpty { + self.lyricUrlMap[String(self.songCode)] = urlPath + self.setLyric(with: urlPath) { lyricUrl in + onMusicLoadStateListener.onMusicLoadSuccess(songCode: self.songCode, lyricUrl: urlPath) + } + } else { + onMusicLoadStateListener.onMusicLoadFail(songCode: self.songCode, reason: .noLyricUrl) + } + +// if (config.autoPlay) { +// // 主唱自动播放歌曲 +// if self.singerRole != .leadSinger { +// self.switchSingerRole(newRole: .soloSinger) { _, _ in +// +// } +// } +// self.startSing(songCode: self.songCode, startPos: 0) +// } + } + } else { + loadMusicListeners.setObject(onMusicLoadStateListener, forKey: "\(self.songCode)" as NSString) + onMusicLoadStateListener.onMusicLoadProgress(songCode: self.songCode, percent: 0, state: .preloading, msg: "", lyricUrl: "") + // TODO: 只有未缓存时才显示进度条 + if mcc?.isPreloaded(songCode: songCode) != 0 { + onMusicLoadStateListener.onMusicLoadProgress(songCode: self.songCode, percent: 0, state: .preloading, msg: "", lyricUrl: "") + } + + preloadMusic(with: songCode) { [weak self] status, songCode in + guard let self = self else { return } + if self.songCode != songCode { + onMusicLoadStateListener.onMusicLoadFail(songCode: songCode, reason: .cancled) + return + } + if status == .OK { + if mode == .loadMusicAndLrc { + // 需要加载歌词 + self.loadLyric(with: songCode) { url in + self.agoraPrint("loadMusicAndLrc: songCode:\(songCode) status:\(status.rawValue) ulr:\(String(describing: url))") + if self.songCode != songCode { + onMusicLoadStateListener.onMusicLoadFail(songCode: songCode, reason: .cancled) + return + } + if let urlPath = url, !urlPath.isEmpty { + self.lyricUrlMap[String(songCode)] = urlPath + self.setLyric(with: urlPath) { lyricUrl in + onMusicLoadStateListener.onMusicLoadSuccess(songCode: songCode, lyricUrl: urlPath) + } + } else { + onMusicLoadStateListener.onMusicLoadFail(songCode: songCode, reason: .noLyricUrl) + } +// if config.autoPlay { +// // 主唱自动播放歌曲 +// if self.singerRole != .leadSinger { +// self.switchSingerRole(newRole: .soloSinger) { _, _ in +// +// } +// } +// self.startSing(songCode: self.songCode, startPos: 0) +// } + } + } else if mode == .loadMusicOnly { + agoraPrint("loadMusicOnly: songCode:\(songCode) load success") +// if config.autoPlay { +// // 主唱自动播放歌曲 +// if self.singerRole != .leadSinger { +// self.switchSingerRole(newRole: .soloSinger) { _, _ in +// +// } +// } +// self.startSing(songCode: self.songCode, startPos: 0) +// } + onMusicLoadStateListener.onMusicLoadSuccess(songCode: songCode, lyricUrl: "") + } + } else { + agoraPrint("load music failed songCode:\(songCode)") + onMusicLoadStateListener.onMusicLoadFail(songCode: songCode, reason: .musicPreloadFail) + } + } + } + } + + private func loadLyric(with songCode: NSInteger, callBack:@escaping LyricCallback) { + agoraPrint("loadLyric songCode: \(songCode)") + let requestId: String = self.mcc?.getLyric(songCode: songCode, lyricType: 0) ?? "" + self.lyricCallbacks.updateValue(callBack, forKey: requestId) + } + + private func preloadMusic(with songCode: Int, callback: @escaping LoadMusicCallback) { + agoraPrint("preloadMusic songCode: \(songCode)") + if self.mcc?.isPreloaded(songCode: songCode) == 0 { + musicCallbacks.removeValue(forKey: String(songCode)) + callback(.OK, songCode) + return + } + let err = self.mcc?.preload(songCode: songCode) + if err == nil { + musicCallbacks.removeValue(forKey: String(songCode)) + callback(.error, songCode) + return + } + musicCallbacks.updateValue(callback, forKey: String(songCode)) + } + + private func setLyric(with url: String, callBack: @escaping LyricCallback) { + agoraPrint("setLyric url: (url)") + self.lrcControl?.onDownloadLrcData(url: url) + callBack(url) + } + + func startSing(songCode: Int, startPos: Int) { + let dict: [String: Any] = [ + "songCode": songCode, + "startPos": startPos + ] + sendCustomMessage(with: "startSing", dict: dict) + let role = singerRole + agoraPrint("startSing role: \(role.rawValue)") + if self.songCode != songCode { + agoraPrint("startSing failed: canceled") + return + } + mediaPlayer?.setPlayerOption("enable_multi_audio_track", value: 1) + apiConfig?.engine?.adjustPlaybackSignalVolume(Int(remoteVolume)) + let ret = (mediaPlayer as? AgoraMusicPlayerProtocol)?.openMedia(songCode: songCode, startPos: startPos) + mediaPlayer?.setLoopCount(-1) + agoraPrint("startSing->openMedia(\(songCode) fail: \(ret ?? -1)") + } + + func startSing(url: String, startPos: Int) { + let dict: [String: Any] = [ + "url": url, + "startPos": startPos + ] + sendCustomMessage(with: "startSing", dict: dict) + let role = singerRole + agoraPrint("startSing role: \(role.rawValue)") + if self.songUrl != songUrl { + agoraPrint("startSing failed: canceled") + return + } + apiConfig?.engine?.adjustPlaybackSignalVolume(Int(remoteVolume)) + let ret = mediaPlayer?.open(url, startPos: startPos) + agoraPrint("startSing->openMedia(\(url) fail: \(ret ?? -1)") + } + + /** + * 停止播放歌曲 + */ + @objc public func stopSing() { + agoraPrint("stopSing") + sendCustomMessage(with: "stopSing", dict: [:]) + let mediaOption = AgoraRtcChannelMediaOptions() + // mediaOption.autoSubscribeAudio = true + // mediaOption.autoSubscribeVideo = true + mediaOption.publishMediaPlayerAudioTrack = false + apiConfig?.engine?.updateChannelEx(with: mediaOption, connection: singChannelConnection ?? AgoraRtcConnection()) + + if mediaPlayer?.getPlayerState() != .stopped { + mediaPlayer?.stop() + } + + apiConfig?.engine?.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":true}") + apiConfig?.engine?.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}") + apiConfig?.engine?.setParameters("{\"che.audio.custom_bitrate\": 48000}") + apiConfig?.engine?.setAudioScenario(.gameStreaming) + } + + @objc public func setSingingScore(score: Int) { + self.singingScore = score + } + + @objc func setAudienceStreamMessage(dict: [String: Any]) { + sendStreamMessageWithDict(dict) { _ in + + } + } + + @objc public func setAudioPlayoutDelay(audioPlayoutDelay: Int) { + self.audioPlayoutDelay = audioPlayoutDelay + } + + @objc func enableProfessionalStreamerMode(_ enable: Bool) { + if self.isPublishAudio == false {return} + self.enableProfessional = enable + //专业非专业还需要根据是否佩戴耳机来判断是否开启3A + apiConfig?.engine?.setAudioProfile(enable ? .musicHighQualityStereo : .musicStandardStereo) + apiConfig?.engine?.setParameters("{\"che.audio.aec.enable\":\((enable && isWearingHeadPhones) ? false : true)}") + apiConfig?.engine?.setParameters("{\"che.audio.agc.enable\":\((enable && isWearingHeadPhones) ? false : true)}") + apiConfig?.engine?.setParameters("{\"che.audio.ans.enable\":\((enable && isWearingHeadPhones) ? false : true)}") + apiConfig?.engine?.setParameters("{\"che.audio.md.enable\": false}") + } + + func joinChorus(newRole: KTVSingRole) { + agoraPrint("joinChorus: \(newRole)") + let singChannelMediaOptions = AgoraRtcChannelMediaOptions() + singChannelMediaOptions.autoSubscribeAudio = true + singChannelMediaOptions.publishMicrophoneTrack = true + singChannelMediaOptions.clientRoleType = .broadcaster +// singChannelMediaOptions.parameters = "{\"che.audio.max_mixed_participants\": 8}" + if newRole == .leadSinger { + // 主唱不参加TopN + singChannelMediaOptions.isAudioFilterable = false + apiConfig?.engine?.setParameters("{\"che.audio.filter_streams\":\(apiConfig?.routeSelectionConfig.streamNum ?? 0)}") + } else { + apiConfig?.engine?.setParameters("{\"che.audio.filter_streams\":\((apiConfig?.routeSelectionConfig.streamNum ?? 0) - 1)}") + } + + guard let token = apiConfig?.chorusChannelToken, let singConnection = singChannelConnection else {return} + + + // 加入演唱频道 + let ret = apiConfig?.engine?.joinChannelEx(byToken: token, connection: singConnection, delegate: self, mediaOptions: singChannelMediaOptions) + apiConfig?.engine?.setParametersEx("{\"rtc.use_audio4\": true}", connection: singConnection) + if apiConfig?.routeSelectionConfig.type == .topN || apiConfig?.routeSelectionConfig.type == .byDelayAndTopN { + if newRole == .leadSinger { + apiConfig?.engine?.setParameters("{\"che.audio.filter_streams\":\(apiConfig?.routeSelectionConfig.streamNum)}") + } else { + apiConfig?.engine?.setParameters("{\"che.audio.filter_streams\":\((apiConfig?.routeSelectionConfig.streamNum ?? 0) - 1)}") + } + } else { + apiConfig?.engine?.setParameters("{\"che.audio.filter_streams\": 0}") + } + + let res = apiConfig?.engine?.enableAudioVolumeIndicationEx(50, smooth: 10, reportVad: true, connection: singConnection) + switch newRole { + case .leadSinger: + // 更新音频配置 + apiConfig?.engine?.setAudioScenario(.chorus) + apiConfig?.engine?.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":false}") + apiConfig?.engine?.setParameters("{\"che.audio.neteq.enable_stable_playout\":false}") + apiConfig?.engine?.setParameters("{\"che.audio.custom_bitrate\": 80000}") + + // mpk流加入频道 + let options = AgoraRtcChannelMediaOptions() + options.autoSubscribeAudio = false + options.autoSubscribeVideo = false + options.publishMicrophoneTrack = false + options.publishMediaPlayerAudioTrack = true + options.publishMediaPlayerId = Int(mediaPlayer?.getMediaPlayerId() ?? 0) + options.clientRoleType = .broadcaster + // 防止主唱和合唱听见mpk流的声音 + options.enableAudioRecordingOrPlayout = false + + let rtcConnection = AgoraRtcConnection() + rtcConnection.channelId = apiConfig?.chorusChannelName ?? "" + rtcConnection.localUid = UInt(apiConfig?.musicStreamUid ?? 0) + mpkConnection = rtcConnection + + // 加入演唱频道 + let delegate = NSObject() + let ret = apiConfig?.engine?.joinChannelEx(byToken: apiConfig?.musicChannelToken, connection: mpkConnection ?? AgoraRtcConnection(), delegate: nil, mediaOptions: options) + apiConfig?.engine?.setParametersEx("{\"rtc.use_audio4\": true}", connection: mpkConnection ?? AgoraRtcConnection()) + + + case .coSinger: + // 防止主唱和合唱听见mpk流的声音 + apiConfig?.engine?.muteRemoteAudioStreamEx(UInt(apiConfig?.musicStreamUid ?? 0), mute: true, connection: singChannelConnection ?? AgoraRtcConnection()) + + // 更新音频配置 + apiConfig?.engine?.setAudioScenario(.chorus) + apiConfig?.engine?.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":false}") + apiConfig?.engine?.setParameters("{\"che.audio.neteq.enable_stable_playout\":false}") + apiConfig?.engine?.setParameters("{\"che.audio.custom_bitrate\": 48000}") + + // 预加载歌曲成功 + // 导唱 + mediaPlayer?.setPlayerOption("enable_multi_audio_track", value: 1) + if apiConfig?.musicType == .mcc { + (mediaPlayer as? AgoraMusicPlayerProtocol)?.openMedia(songCode: self.songCode , startPos: 0) // TODO open failed + } else { + mediaPlayer?.open(songUrl, startPos: 0) // TODO open failed + } + default: + agoraPrintError("JoinChorus with Wrong role: \(singerRole)") + } + + + apiConfig?.engine?.muteRemoteAudioStreamEx(UInt(apiConfig?.musicStreamUid ?? 0), mute: true, connection: singChannelConnection ?? AgoraRtcConnection()) + // 加入演唱频道后,创建data stream + renewInnerDataStreamId() + } + + func leaveChorus2(role: KTVSingRole) { + agoraPrint("leaveChorus: \(role)") + switch role { + case .leadSinger: + apiConfig?.engine?.leaveChannelEx(mpkConnection ?? AgoraRtcConnection()) + case .coSinger: + mediaPlayer?.stop() + + // 更新音频配置 + apiConfig?.engine?.setAudioScenario(.gameStreaming) + apiConfig?.engine?.setParameters("{\"rtc.video.enable_sync_render_ntp_broadcast\":true}") + apiConfig?.engine?.setParameters("{\"che.audio.neteq.enable_stable_playout\":true}") + apiConfig?.engine?.setParameters("{\"che.audio.custom_bitrate\": 48000}") + default: + agoraPrint("JoinChorus with wrong role: \(singerRole)") + } + apiConfig?.engine?.leaveChannelEx(singChannelConnection ?? AgoraRtcConnection()) + } + +} + +// rtc的代理回调 +extension KTVGiantChorusApiImpl: AgoraRtcEngineDelegate { + + public func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String, withUid uid: UInt, elapsed: Int) { + agoraPrint("didJoinChannel channel:\(channel) uid: \(uid)") + if joinChorusNewRole == .leadSinger { + mainSingerHasJoinChannelEx = true + onJoinExChannelCallBack?(true, nil) + } + if joinChorusNewRole == .coSinger { + self.onJoinExChannelCallBack?(true, nil) + } + if let subChorusConnection = subChorusConnection { + apiConfig?.engine?.enableAudioVolumeIndicationEx(50, smooth: 10, reportVad: true, connection: subChorusConnection) + } + } + + public func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) { + if errorCode != .joinChannelRejected {return} + agoraPrintError("join ex channel failed") + engine.setAudioScenario(.gameStreaming) + if joinChorusNewRole == .leadSinger { + mainSingerHasJoinChannelEx = false + onJoinExChannelCallBack?(false, .joinChannelFail) + } + + if joinChorusNewRole == .coSinger { + self.onJoinExChannelCallBack?(false, .joinChannelFail) + } + } + + //合唱频道的声音回调 + public func rtcEngine(_ engine: AgoraRtcEngineKit, reportAudioVolumeIndicationOfSpeakers speakers: [AgoraRtcAudioVolumeInfo], totalVolume: Int) { + getEventHander { delegate in + delegate.onChorusChannelAudioVolumeIndication(speakers: speakers, totalVolume: totalVolume) + } + didKTVAPIReceiveAudioVolumeIndication(with: speakers, totalVolume: totalVolume) + } + + public func rtcEngine(_ engine: AgoraRtcEngineKit, tokenPrivilegeWillExpire token: String) { + getEventHander { delegate in + delegate.onTokenPrivilegeWillExpire() + } + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, receiveStreamMessageFromUid uid: UInt, streamId: Int, data: Data) { + didKTVAPIReceiveStreamMessageFrom(uid: NSInteger(uid), streamId: streamId, data: data) + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, audioMetadataReceived uid: UInt, metadata: Data) { + guard let time: LrcTime = try? LrcTime(serializedData: metadata) else {return} + if time.type == .lrcTime && self.singerRole == .audience { + self.setProgress(with: Int(time.ts)) + } + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) { + guard let musicId = apiConfig?.musicStreamUid,let mainSingerId = songConfig?.mainSingerUid else {return} + if uid != musicId && subScribeSingerMap.count < 8 { + apiConfig?.engine?.muteRemoteAudioStreamEx(uid, mute: false, connection: singChannelConnection ?? AgoraRtcConnection()) + if uid != mainSingerId { + subScribeSingerMap[Int(uid)] = 0 + } + } else if uid != musicId && subScribeSingerMap.count == 8 { + apiConfig?.engine?.muteRemoteAudioStreamEx(uid, mute: false, connection: singChannelConnection ?? AgoraRtcConnection()) + } + if uid != musicId && uid != mainSingerId { + singerList.append(Int(uid)) + } + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, didLeaveChannelWith stats: AgoraChannelStats) { + subScribeSingerMap.removeAll() + singerList.removeAll() + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, didOfflineOfUid uid: UInt, reason: AgoraUserOfflineReason) { + subScribeSingerMap.removeValue(forKey: Int(uid)) + if let index = singerList.firstIndex(of: Int(uid)) { + singerList.remove(at: index) + } + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, remoteAudioStats stats: AgoraRtcRemoteAudioStats) { + guard let musicId = apiConfig?.musicStreamUid,let mainSingerId = songConfig?.mainSingerUid else {return} + if apiConfig?.routeSelectionConfig.type == .random || apiConfig?.routeSelectionConfig.type == .topN { return } + let uid = stats.uid + if uid == mainSingerId { + mainSingerDelay = stats.e2eDelay + } + if uid != mainSingerId && uid != musicId && subScribeSingerMap[Int(uid)] != nil { + subScribeSingerMap[Int(uid)] = stats.e2eDelay + } + } +} + +//需要外部转发的方法 主要是dataStream相关的 +extension KTVGiantChorusApiImpl { + + @objc func didAudioPublishStateChange(newState: AgoraStreamPublishState) { + self.isPublishAudio = newState == .published + enableProfessionalStreamerMode(self.enableProfessional) + agoraPrint("PublishStateChange:\(newState)") + } + + @objc func didAudioRouteChanged( routing: AgoraAudioOutputRouting) { + agoraPrint("Route changed:\(routing)") + self.audioRouting = routing.rawValue + let headPhones: [AgoraAudioOutputRouting] = [.headset, .bluetoothDeviceHfp, .bluetoothDeviceA2dp, .headsetNoMic] + let wearHeadPhone: Bool = headPhones.contains(routing) + if wearHeadPhone == self.isWearingHeadPhones { + return + } + self.isWearingHeadPhones = wearHeadPhone + enableProfessionalStreamerMode(self.enableProfessional) + } + + @objc public func didKTVAPIReceiveStreamMessageFrom(uid: NSInteger, streamId: NSInteger, data: Data) { + let role = singerRole + if isRelease {return} + guard let dict = dataToDictionary(data: data), let cmd = dict["cmd"] as? String else { return } + agoraPrint("recv dict:\(dict)") + switch cmd { + case "setLrcTime": + handleSetLrcTimeCommand(dict: dict, role: role) + case "PlayerState": + handlePlayerStateCommand(dict: dict, role: role) + case "setVoicePitch": + handleSetVoicePitchCommand(dict: dict, role: role) + default: + break + } + } + + private func handleSetLrcTimeCommand(dict: [String: Any], role: KTVSingRole) { + guard let position = dict["time"] as? Int64, + let duration = dict["duration"] as? Int64, + let realPosition = dict["realTime"] as? Int64, + // let songCode = dict["songCode"] as? Int64, + let mainSingerState = dict["playerState"] as? Int, + let ntpTime = dict["ntp"] as? Int, + let songId = dict["songIdentifier"] as? String + else { return } + #if DUBUG + print("realTime:\(realPosition) position:\(position) lastNtpTime:\(lastNtpTime) ntpTime:\(ntpTime) ntpGap:\(ntpTime - self.lastNtpTime) ") + #endif + //如果接收到的歌曲和自己本地的歌曲不一致就不更新进度 +// guard songCode == self.songCode else { +// agoraPrint("local songCode[\(songCode)] is not equal to recv songCode[\(self.songCode)] role: \(singerRole.rawValue)") +// return +// } + + self.lastNtpTime = ntpTime + self.remotePlayerDuration = TimeInterval(duration) + + let state = AgoraMediaPlayerState(rawValue: mainSingerState) ?? .stopped +// self.lastMainSingerUpdateTime = Date().milListamp +// self.remotePlayerPosition = TimeInterval(realPosition) + if self.playerState != state { + #if DUBUG + print("[setLrcTime] recv state: \(self.playerState.rawValue)->\(state.rawValue) role: \(singerRole.rawValue) role: \(singerRole.rawValue)") + #endif + if state == .playing, singerRole == .coSinger, playerState == .openCompleted { + //如果是伴唱等待主唱开始播放,seek 到指定位置开始播放保证歌词显示位置准确 + self.localPlayerPosition = self.lastMainSingerUpdateTime - Double(position) + print("localPlayerPosition:playerKit:handleSetLrcTimeCommand \(localPlayerPosition)") + agoraPrint("seek toPosition: \(position)") + mediaPlayer?.seek(toPosition: Int(position)) + } + + syncPlayStateFromRemote(state: state, needDisplay: false) + } + + if role == .coSinger { + self.lastMainSingerUpdateTime = Date().milListamp + self.remotePlayerPosition = TimeInterval(realPosition) + handleCoSingerRole(dict: dict) + } else if role == .audience { + if dict.keys.contains("ver") { + recvFromDataStream = false + } else { + recvFromDataStream = true + if self.songIdentifier == songId { + self.lastMainSingerUpdateTime = Date().milListamp + self.remotePlayerPosition = TimeInterval(realPosition) + } else { + self.lastMainSingerUpdateTime = 0 + self.remotePlayerPosition = 0 + } + handleAudienceRole(dict: dict) + } + } + } + + private func handlePlayerStateCommand(dict: [String: Any], role: KTVSingRole) { + let mainSingerState: Int = dict["state"] as? Int ?? 0 + let state = AgoraMediaPlayerState(rawValue: mainSingerState) ?? .idle + +// if state == .playing, singerRole == .coSinger, playerState == .openCompleted { +// //如果是伴唱等待主唱开始播放,seek 到指定位置开始播放保证歌词显示位置准确 +// self.localPlayerPosition = getPlayerCurrentTime() +// print("localPlayerPosition:playerKit:handlePlayerStateCommand \(localPlayerPosition)") +// agoraPrint("seek toPosition: \(self.localPlayerPosition)") +// mediaPlayer?.seek(toPosition: Int(self.localPlayerPosition)) +// } + + agoraPrint("recv state with MainSinger: \(state.rawValue)") + syncPlayStateFromRemote(state: state, needDisplay: true) + } + + private func handleSetVoicePitchCommand(dict: [String: Any], role: KTVSingRole) { + if role == .audience, let voicePitch = dict["pitch"] as? Double { + self.pitch = voicePitch + } + } + + private func handleCoSingerRole(dict: [String: Any]) { + + if mediaPlayer?.getPlayerState() == .playing { + let localNtpTime = getNtpTimeInMs() + let localPosition = localNtpTime - Int(localPlayerSystemTime) + localPosition + let expectPosition = Int(dict["time"] as? Int64 ?? 0) + localNtpTime - Int(dict["ntp"] as? Int64 ?? 0) + self.audioPlayoutDelay + let threshold = expectPosition - Int(localPosition) + let ntpTime = dict["ntp"] as? Int ?? 0 + let time = dict["time"] as? Int64 ?? 0 + #if DUBUG + agoraPrint("checkNtp, diff:\(threshold), localNtp:\(getNtpTimeInMs()), localPosition:\(localPosition), audioPlayoutDelay:\(audioPlayoutDelay), remoteDiff:\(String(describing: ntpTime - Int(time)))") + #endif + if abs(threshold) > 50 { + agoraPrint("need seek, time:\(threshold)") + mediaPlayer?.seek(toPosition: expectPosition) + } + } + + } + + private func handleAudienceRole(dict: [String: Any]) { + // do something for audience role + guard let position = dict["time"] as? Int64, + let duration = dict["duration"] as? Int64, + let realPosition = dict["realTime"] as? Int64, + let songCode = dict["songCode"] as? Int64, + let mainSingerState = dict["playerState"] as? Int + else { return } + } + + @objc public func didKTVAPIReceiveAudioVolumeIndication(with speakers: [AgoraRtcAudioVolumeInfo], totalVolume: NSInteger) { + if playerState != .playing {return} + if singerRole == .audience {return} + + guard var pitch: Double = speakers.first?.voicePitch else {return} + pitch = isNowMicMuted ? 0 : pitch + //如果mpk不是playing状态 pitch = 0 + if mediaPlayer?.getPlayerState() != .playing {pitch = 0} + self.pitch = pitch + //将主唱的pitch同步到观众 +// if isMainSinger() { +// let dict: [String: Any] = [ "cmd": "setVoicePitch", +// "pitch": pitch, +// ] +// sendStreamMessageWithDict(dict, success: nil) +// } + } + + @objc public func didKTVAPILocalAudioStats(stats: AgoraRtcLocalAudioStats) { + if useCustomAudioSource == true {return} + audioPlayoutDelay = Int(stats.audioPlayoutDelay) + } + + @objc func didAudioMetadataReceived( uid: UInt, metadata: Data) { + guard let time: LrcTime = try? LrcTime(serializedData: metadata) else {return} + if time.type == .lrcTime && self.singerRole == .audience { + self.setProgress(with: Int(time.ts)) + } + } + +} + +//private method +extension KTVGiantChorusApiImpl { + + private func initTimer() { + + guard timer == nil else { return } + + timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true, block: {[weak self] timer in + guard let self = self else { + timer.invalidate() + return + } + + var current = self.getPlayerCurrentTime() + if self.singerRole == .audience && (Date().milListamp - (self.lastMainSingerUpdateTime )) > 1000 { + return + } + + if self.singerRole != .audience && (Date().milListamp - (self.lastReceivedPosition )) > 1000 { + return + } + + if self.oldPitch == self.pitch && (self.oldPitch != 0 && self.pitch != 0) { + self.pitch = -1 + } + + if self.singerRole != .audience { + current = Date().milListamp - self.lastReceivedPosition + Double(self.localPosition) + } + if self.singerRole == .audience && !recvFromDataStream { + + } else { + if self.singerRole != .audience { + current = Date().milListamp - self.lastReceivedPosition + Double(self.localPosition) + if self.singerRole == .leadSinger || self.singerRole == .soloSinger { + var time: LrcTime = LrcTime() + time.forward = true + time.ts = Int64(current) + Int64(self.startHighTime) + time.songID = songIdentifier + time.type = .lrcTime + //大合唱的uid是musicuid + time.uid = Int32(Int(apiConfig?.musicStreamUid ?? 0)) + sendMetaMsg(with: time) + } + } + self.setProgress(with: Int(current) + Int(self.startHighTime)) + } + self.oldPitch = self.pitch + }) + } + + private func setPlayerState(with state: AgoraMediaPlayerState) { + playerState = state + updateRemotePlayBackVolumeIfNeed() + updateTimer(with: state) + } + + private func updateRemotePlayBackVolumeIfNeed() { + let role = singerRole + if role == .audience { + apiConfig?.engine?.adjustPlaybackSignalVolume(100) + return + } + + let vol = self.playerState == .playing ? remoteVolume : 100 + apiConfig?.engine?.adjustPlaybackSignalVolume(Int(vol)) + } + + private func updateTimer(with state: AgoraMediaPlayerState) { + DispatchQueue.main.async { + if state == .paused || state == .stopped { + self.pauseTimer() + } else if state == .playing { + self.startTimer() + } + } + } + + //timer method + private func startTimer() { + guard let timer = self.timer else {return} + if isPause == false { + RunLoop.current.add(timer, forMode: .common) + self.timer?.fire() + } else { + resumeTimer() + } + } + + private func resumeTimer() { + if isPause == false {return} + isPause = false + timer?.fireDate = Date() + } + + private func pauseTimer() { + if isPause == true {return} + isPause = true + timer?.fireDate = Date.distantFuture + } + + private func freeTimer() { + guard let _ = self.timer else {return} + self.timer?.invalidate() + self.timer = nil + } + + private func getPlayerCurrentTime() -> TimeInterval { + let role = singerRole + if role == .soloSinger || role == .leadSinger{ + let time = Date().milListamp - localPlayerPosition + return time + } else if role == .coSinger { + if playerState == .playing || playerState == .paused { + let time = Date().milListamp - localPlayerPosition + return time + } + } + + var position = Date().milListamp - self.lastMainSingerUpdateTime + remotePlayerPosition + if playerState != .playing { + position = remotePlayerPosition + } + return position + } + + private func syncPlayStateFromRemote(state: AgoraMediaPlayerState, needDisplay: Bool) { + let role = singerRole + if role == .coSinger { + if state == .stopped { + // stopSing() + } else if state == .paused { + pausePlay() + } else if state == .playing { + resumeSing() + } else if (state == .playBackAllLoopsCompleted && needDisplay == true) { + getEventHander { delegate in + delegate.onMusicPlayerStateChanged(state: state, reason: .none, isLocal: true) + } + } + } else { + self.playerState = state + getEventHander { delegate in + delegate.onMusicPlayerStateChanged(state: self.playerState, reason: .none, isLocal: false) + } + } + } + + private func pausePlay() { + mediaPlayer?.pause() + } + + private func dataToDictionary(data: Data) -> [String: Any]? { + do { + let json = try JSONSerialization.jsonObject(with: data, options: []) + return json as? [String: Any] + } catch { + print("Error decoding data: (error.localizedDescription)") + return nil + } + } + + private func compactDictionaryToData(_ dict: [String: Any]) -> Data? { + do { + let jsonData = try JSONSerialization.data(withJSONObject: dict, options: []) + return jsonData + } catch { + print("Error encoding data: (error.localizedDescription)") + return nil + } + } + + private func getNtpTimeInMs() -> Int { + var localNtpTime: Int = Int(apiConfig?.engine?.getNtpWallTimeInMs() ?? 0) + + if localNtpTime != 0 { + localNtpTime = localNtpTime + 2208988800 * 1000 + } + + return localNtpTime + } + + private func syncPlayState(state: AgoraMediaPlayerState, reason: AgoraMediaPlayerReason) { + let dict: [String: Any] = ["cmd": "PlayerState", "userId": apiConfig?.localUid as Any, "state": state.rawValue, "reason": "\(reason.rawValue)"] + sendStreamMessageWithDict(dict, success: nil) + } + +// private func sendCustomMessage(with event: String, label: String) { +// apiConfig?.engine?.sendCustomReportMessage(messageId, category: version, event: event, label: label, value: 0) +// apiRepoter?.reportFuncEvent(name: event, value: <#T##[String : Any]#>, ext: <#T##[String : Any]#>) +// } + + private func sendCustomMessage(with event: String, dict: [String: Any]) { + apiRepoter?.reportFuncEvent(name: event, value: dict, ext: [:]) + } + + private func sendStreamMessageWithDict(_ dict: [String: Any], success: ((_ success: Bool) -> Void)?) { + let messageData = compactDictionaryToData(dict as [String: Any]) + let code = apiConfig?.engine?.sendStreamMessageEx(dataStreamId, data: messageData ?? Data(), connection: singChannelConnection ?? AgoraRtcConnection()) + if code == 0 && success != nil { success!(true) } + if code != 0 { + print("sendStreamMessage fail: \(String(describing: code))") + } + } + + private func syncPlayState(_ state: AgoraMediaPlayerState) { + let dict: [String: Any] = [ "cmd": "PlayerState", "userId": apiConfig?.localUid as Any, "state": "\(state.rawValue)" ] + sendStreamMessageWithDict(dict, success: nil) + } + + private func setProgress(with pos: Int) { + lrcControl?.onUpdatePitch(pitch: Float(self.pitch)) + lrcControl?.onUpdateProgress(progress: pos > 200 ? pos - 200 : pos) + } + + private func sendMetaMsg(with time: LrcTime) { + let data: Data? = try? time.serializedData() + let code = apiConfig?.engine?.sendAudioMetadataEx(mpkConnection ?? AgoraRtcConnection(), metadata: data ?? Data()) + if code != 0 { + // agoraPrint("sendStreamMessage fail: \(String(describing: code))") + } + } +} + +//主要是MPK的回调 +extension KTVGiantChorusApiImpl: AgoraRtcMediaPlayerDelegate { + + func AgoraRtcMediaPlayer(_ playerKit: AgoraRtcMediaPlayerProtocol, didChangedTo position_ms: Int, atTimestamp timestamp_ms: TimeInterval) { + self.lastReceivedPosition = Date().milListamp + self.localPosition = Int(position_ms) + self.localPlayerSystemTime = timestamp_ms + self.localPlayerPosition = Date().milListamp - Double(position_ms) + if isMainSinger() && getPlayerCurrentTime() > TimeInterval(self.audioPlayoutDelay) { + let dict: [String: Any] = [ "cmd": "setLrcTime", + "duration": self.playerDuration, + "time": position_ms - audioPlayoutDelay, + "realTime":position_ms, + "ntp": timestamp_ms, + "playerState": self.playerState.rawValue, + "songIdentifier": songIdentifier, + "forward": true, + "ver":2, + ] + #if DEBUG + print("position_ms:\(position_ms), ntp:\(getNtpTimeInMs()), delta:\(self.getNtpTimeInMs() - position_ms), autoPlayoutDelay:\(self.audioPlayoutDelay), state:\(self.playerState.rawValue)") + #endif + sendStreamMessageWithDict(dict, success: nil) + } + } + + func AgoraRtcMediaPlayer(_ playerKit: any AgoraRtcMediaPlayerProtocol, didChangedTo state: AgoraMediaPlayerState, reason: AgoraMediaPlayerReason) { + agoraPrint("agoraRtcMediaPlayer didChangedToState: \(state.rawValue) \(self.songCode)") + if isRelease {return} + self.playerState = state + if state == .openCompleted { + self.localPlayerPosition = Date().milListamp + self.playerDuration = TimeInterval(mediaPlayer?.getDuration() ?? 0) + playerKit.selectMultiAudioTrack(1, publishTrackIndex: 1) + if isMainSinger() { //主唱播放,通过同步消息“setLrcTime”通知伴唱play + playerKit.play() + } + self.startProcessDelay() + } else if state == .stopped { + apiConfig?.engine?.adjustPlaybackSignalVolume(100) + self.localPlayerPosition = Date().milListamp + self.playerDuration = 0 + } + else if state == .paused { + apiConfig?.engine?.adjustPlaybackSignalVolume(100) + } else if state == .playing { + apiConfig?.engine?.adjustPlaybackSignalVolume(Int(remoteVolume)) + self.localPlayerPosition = Date().milListamp - Double(mediaPlayer?.getPosition() ?? 0) + } else if state == .stopped { + self.stopProcessDelay() + } + + if isMainSinger() { + syncPlayState(state: state, reason: reason) + } + agoraPrint("recv state with player callback : \(state.rawValue)") + if state == .playBackAllLoopsCompleted && singerRole == .coSinger {//可能存在伴唱不返回allloopbackComplete状态 这个状态通过主唱的playerState来同步 + return + } + getEventHander { delegate in + delegate.onMusicPlayerStateChanged(state: state, reason: .none, isLocal: true) + } + } + + private func isMainSinger() -> Bool { + return singerRole == .soloSinger || singerRole == .leadSinger + } +} + +//主要是MCC的回调 +extension KTVGiantChorusApiImpl: AgoraMusicContentCenterEventDelegate { + + func onSongSimpleInfoResult(_ requestId: String, songCode: Int, simpleInfo: String?, reason: AgoraMusicContentCenterStateReason) { + if let jsonData = simpleInfo?.data(using: .utf8) { + do { + let jsonMsg = try JSONSerialization.jsonObject(with: jsonData, options: []) as! [String: Any] + let format = jsonMsg["format"] as! [String: Any] + let highPart = format["highPart"] as! [[String: Any]] + let highStartTime = highPart[0]["highStartTime"] as! Int + let highEndTime = highPart[0]["highEndTime"] as! Int + let time = highStartTime + startHighTime = time + self.lrcControl?.onHighPartTime(highStartTime: highStartTime, highEndTime: highEndTime) + } catch { + agoraPrintError("Error while parsing JSON: \(error.localizedDescription)") + } + } + if (reason == .errorGateway) { + getEventHander { delegate in + delegate.onTokenPrivilegeWillExpire() + } + } + } + + func onMusicChartsResult(_ requestId: String, result: [AgoraMusicChartInfo], reason: AgoraMusicContentCenterStateReason) { + guard let callback = musicChartDict[requestId] else {return} + callback(requestId, reason, result) + musicChartDict.removeValue(forKey: requestId) + if (reason == .errorGateway) { + getEventHander { delegate in + delegate.onTokenPrivilegeWillExpire() + } + } + } + + func onMusicCollectionResult(_ requestId: String, result: AgoraMusicCollection, reason: AgoraMusicContentCenterStateReason) { + guard let callback = musicSearchDict[requestId] else {return} + callback(requestId, reason, result) + musicSearchDict.removeValue(forKey: requestId) + if (reason == .errorGateway) { + getEventHander { delegate in + delegate.onTokenPrivilegeWillExpire() + } + } + } + + func onLyricResult(_ requestId: String, songCode: Int, lyricUrl: String?, reason: AgoraMusicContentCenterStateReason) { + guard let lrcUrl = lyricUrl else {return} + let callback = self.lyricCallbacks[requestId] + guard let lyricCallback = callback else { return } + self.lyricCallbacks.removeValue(forKey: requestId) + if (reason == .errorGateway) { + getEventHander { delegate in + delegate.onTokenPrivilegeWillExpire() + } + } + if lrcUrl.isEmpty { + lyricCallback(nil) + return + } + lyricCallback(lrcUrl) + } + + func onPreLoadEvent(_ requestId: String, songCode: Int, percent: Int, lyricUrl: String?, state: AgoraMusicContentCenterPreloadState, reason: AgoraMusicContentCenterStateReason) { + if let listener = self.loadMusicListeners.object(forKey: "\(songCode)" as NSString) as? IMusicLoadStateListener { + listener.onMusicLoadProgress(songCode: songCode, percent: percent, state: state, msg: String(reason.rawValue), lyricUrl: lyricUrl) + } + if (state == .preloading) { return } + let SongCode = "\(songCode)" + guard let block = self.musicCallbacks[SongCode] else { return } + self.musicCallbacks.removeValue(forKey: SongCode) + if (reason == .errorGateway) { + getEventHander { delegate in + delegate.onTokenPrivilegeWillExpire() + } + } + block(state, songCode) + } + +} + +extension KTVGiantChorusApiImpl { + + private func sendSyncPitch(_ pitch: Double) { + var msg: [String:Any] = [:] + msg["cmd"] = "setVoicePitch" + msg["pitch"] = pitch + sendStreamMessageWithDict(msg) { _ in + + } + } + + private func startSyncPitch() { + print("startSyncPitch") + mStopSyncPitch = false + let queue = DispatchQueue(label: "com.example.syncpitch") + mSyncPitchTimer = DispatchSource.makeTimerSource(queue: queue) + mSyncPitchTimer?.schedule(deadline: .now(), repeating: .milliseconds(50)) + mSyncPitchTimer?.setEventHandler { [weak self] in + guard let self = self else { return } + if !self.mStopSyncPitch && + playerState == .playing && + (singerRole == .leadSinger || singerRole == .soloSinger) { + self.sendSyncPitch(pitch) + } + } + mSyncPitchTimer?.resume() + } + + private func stopSyncPitch() { + print("stopSyncPitch") + mStopSyncPitch = true + pitch = 0.0 + + mSyncPitchTimer?.cancel() + mSyncPitchTimer = nil + } + + private func sendSyncScore() { + print("sendSyncScore") + var dictionary: [String: Any] = [:] + dictionary["service"] = "audio_smart_mixer" + dictionary["version"] = "V1" + var payload: [String: Any] = [:] + payload["cname"] = apiConfig?.chorusChannelName + payload["uid"] = String(apiConfig?.localUid ?? 0) + payload["uLv"] = -1 + payload["specialLabel"] = 0 + payload["audioRoute"] = audioRouting + payload["vocalScore"] = singingScore + dictionary["payload"] = payload + sendStreamMessageWithDict(dictionary) { _ in + + } + } + + private func startSyncScore() { + print("startSyncScore") + mStopSyncScore = false + let queue = DispatchQueue(label: "com.example.syncscore") + mSyncScoreTimer = DispatchSource.makeTimerSource(queue: queue) + mSyncScoreTimer?.schedule(deadline: .now(), repeating: .milliseconds(3000)) + mSyncScoreTimer?.setEventHandler { [weak self] in + guard let self = self else { return } + if !self.mStopSyncScore && + playerState == .playing && + (singerRole == .leadSinger || singerRole == .coSinger) { + self.sendSyncScore() + } + } + mSyncScoreTimer?.resume() + } + + private func stopSyncScore() { + print("stopSyncScore") + mStopSyncScore = true + singingScore = 0 + + mSyncScoreTimer?.cancel() + mSyncScoreTimer = nil + } + + // -1: unknown,0:非K歌状态,1:K歌播放状态,2:K歌暂停状态) + private func getCloudConvergenceStatus() -> Int { + var status = -1 + switch playerState { + case .playing: + status = 1 + case .paused: + status = 2 + default: + break + } + return status + } + + private func sendSyncCloudConvergenceStatus() { + print("sendSyncCloudConvergenceStatus") + var dictionary: [String: Any] = [:] + dictionary["service"] = "audio_smart_mixer_status" + dictionary["version"] = "V1" + var payload: [String: Any] = [:] + payload["Ts"] = getNtpTimeInMs() + payload["cname"] = apiConfig?.chorusChannelName + payload["status"] = getCloudConvergenceStatus() + payload["bgmUID"] = mpkConnection?.localUid + payload["leadsingerUID"] = String(songConfig?.mainSingerUid ?? 0) + dictionary["payload"] = payload + sendStreamMessageWithDict(dictionary) { _ in + + } + } + + private func startSyncCloudConvergenceStatus() { + print("startSyncCloudConvergenceStatus") + mStopSyncCloudConvergenceStatus = false + let queue = DispatchQueue(label: "com.example.synccloudconvergencestatus") + mSyncCloudConvergenceStatusTimer = DispatchSource.makeTimerSource(queue: queue) + mSyncCloudConvergenceStatusTimer?.schedule(deadline: .now(), repeating: .milliseconds(200)) + mSyncCloudConvergenceStatusTimer?.setEventHandler { [weak self] in + guard let self = self else { return } + if !self.mStopSyncCloudConvergenceStatus && + singerRole == .leadSinger { + self.sendSyncCloudConvergenceStatus() + } + } + mSyncCloudConvergenceStatusTimer?.resume() + } + + private func stopSyncCloudConvergenceStatus() { + print("stopSyncCloudConvergenceStatus") + mStopSyncCloudConvergenceStatus = true + + mSyncCloudConvergenceStatusTimer?.cancel() + mSyncCloudConvergenceStatusTimer = nil + } + +} + +extension KTVGiantChorusApiImpl { + + private func processDelayTask() { + if !mStopProcessDelay && singerRole != .audience { + let n = singerRole == .leadSinger ? apiConfig?.routeSelectionConfig.streamNum : (apiConfig?.routeSelectionConfig.streamNum ?? 1) - 1 + let sortedEntries = subScribeSingerMap.sorted(by: { $0.value < $1.value }) + let other = Array(sortedEntries.dropFirst(3)) + var drop = [Int]() + + if n ?? 3 > 3 { + for (uid, _) in other.dropLast(n! - 3) { + drop.append(uid) + apiConfig?.engine?.muteRemoteAudioStreamEx(UInt(uid), mute: true, connection: singChannelConnection ?? AgoraRtcConnection()) + subScribeSingerMap.removeValue(forKey: uid) + } + } + + agoraPrint("选路重新订阅, drop:\(drop)") + + let filteredList = singerList.filter { !subScribeSingerMap.keys.contains($0) } + let filteredList2 = filteredList.filter { !drop.contains($0) } + let shuffledList = filteredList2.shuffled() + + if subScribeSingerMap.count < 8 { + let randomSingers = Array(shuffledList.prefix(8 - subScribeSingerMap.count)) + agoraPrintError("选路重新订阅, newSingers:\(randomSingers)") + + for singer in randomSingers { + subScribeSingerMap[singer] = 0 + apiConfig?.engine?.muteRemoteAudioStreamEx(UInt(singer), mute: false, connection: singChannelConnection ?? AgoraRtcConnection()) + } + } + + agoraPrint("选路重新订阅, newSubScribeSingerMap:\(subScribeSingerMap)") + } + } + + private func processSubscribeTask() { + if !mStopProcessDelay && singerRole != .audience { + let n = singerRole == .leadSinger ? apiConfig?.routeSelectionConfig.streamNum : (apiConfig?.routeSelectionConfig.streamNum ?? 0) - 1 + let sortedEntries = subScribeSingerMap.sorted(by: { $0.value < $1.value }) + let mustToHave = Array(sortedEntries.prefix(3)) + + for (uid, _) in mustToHave { + apiConfig?.engine?.adjustUserPlaybackSignalVolumeEx(UInt(uid), volume: 100, connection: singChannelConnection ?? AgoraRtcConnection()) + } + + let other = Array(sortedEntries.dropFirst(3)) + + if n ?? 3 > 3 { + for (uid, delay) in Array(other.prefix(n! - 3)) { + if delay > 300 { + apiConfig?.engine?.adjustUserPlaybackSignalVolumeEx(UInt(uid), volume: 0, connection: singChannelConnection ?? AgoraRtcConnection()) + } else { + apiConfig?.engine?.adjustUserPlaybackSignalVolumeEx(UInt(uid), volume: 100, connection: singChannelConnection ?? AgoraRtcConnection()) + } + } + + for (uid, _) in Array(other.dropFirst(n! - 3)) { + apiConfig?.engine?.adjustUserPlaybackSignalVolumeEx(UInt(uid), volume: 0, connection: singChannelConnection ?? AgoraRtcConnection()) + } + } + + agoraPrint("选路排序+调整播放音量, mustToHave:\(mustToHave), other:\(other)") + } + } + + private func startProcessDelay() { + guard apiConfig?.routeSelectionConfig.type != .topN && apiConfig?.routeSelectionConfig.type != .random else { return } + + mStopProcessDelay = false + + // 创建并配置 processDelayTimer + processDelayFuture = DispatchSource.makeTimerSource() + processDelayFuture?.schedule(deadline: .now() + .seconds(10), repeating: .seconds(20)) + processDelayFuture?.setEventHandler { [weak self] in + // 执行 mProcessDelayTask + self?.processDelayTask() + } + processDelayFuture?.resume() + + // 创建并配置 processSubscribeTimer + processSubscribeFuture = DispatchSource.makeTimerSource() + processSubscribeFuture?.schedule(deadline: .now() + .seconds(15), repeating: .seconds(20)) + processSubscribeFuture?.setEventHandler { [weak self] in + // 执行 mProcessSubscribeTask + self?.processSubscribeTask() + } + processSubscribeFuture?.resume() + } + + private func stopProcessDelay() { + mStopProcessDelay = true + + processDelayFuture?.cancel() + processDelayFuture = nil + processSubscribeFuture?.cancel() + processSubscribeFuture = nil + } +} + + +extension Date { + /// 获取当前 秒级 时间戳 - 10位 + /// + var timeStamp : TimeInterval { + let timeInterval: TimeInterval = self.timeIntervalSince1970 + return timeInterval + } + /// 获取当前 毫秒级 时间戳 - 13位 + var milListamp : TimeInterval { + let timeInterval: TimeInterval = self.timeIntervalSince1970 + let millisecond = CLongLong(round(timeInterval*1000)) + return TimeInterval(millisecond) + } +} + diff --git a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/LrcTime.pb.swift b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/LrcTime.pb.swift new file mode 100644 index 0000000..b790fb2 --- /dev/null +++ b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/LrcTime.pb.swift @@ -0,0 +1,140 @@ +// DO NOT EDIT. +// swift-format-ignore-file +// +// Generated by the Swift generator plugin for the protocol buffer compiler. +// Source: LrcTime.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/apple/swift-protobuf/ + +import Foundation +import SwiftProtobuf + +// If the compiler emits an error on this type, it is because this file +// was generated by a version of the `protoc` Swift plug-in that is +// incompatible with the version of SwiftProtobuf to which you are linking. +// Please ensure that you are building against the same version of the API +// that was used to generate this file. +fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { + struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} + typealias Version = _2 +} + +enum MsgType: SwiftProtobuf.Enum { + typealias RawValue = Int + case unknownType // = 0 + case lrcTime // = 1001 + case UNRECOGNIZED(Int) + + init() { + self = .unknownType + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .unknownType + case 1001: self = .lrcTime + default: self = .UNRECOGNIZED(rawValue) + } + } + + var rawValue: Int { + switch self { + case .unknownType: return 0 + case .lrcTime: return 1001 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + static let allCases: [MsgType] = [ + .unknownType, + .lrcTime, + ] + +} + +struct LrcTime: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var type: MsgType = .unknownType + + var forward: Bool = false + + var ts: Int64 = 0 + + var songID: String = String() + + var uid: Int32 = 0 + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +extension MsgType: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 0: .same(proto: "UNKNOWN_TYPE"), + 1001: .same(proto: "LRC_TIME"), + ] +} + +extension LrcTime: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = "LrcTime" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "type"), + 2: .same(proto: "forward"), + 3: .same(proto: "ts"), + 4: .same(proto: "songId"), + 5: .same(proto: "uid"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.type) }() + case 2: try { try decoder.decodeSingularBoolField(value: &self.forward) }() + case 3: try { try decoder.decodeSingularInt64Field(value: &self.ts) }() + case 4: try { try decoder.decodeSingularStringField(value: &self.songID) }() + case 5: try { try decoder.decodeSingularInt32Field(value: &self.uid) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.type != .unknownType { + try visitor.visitSingularEnumField(value: self.type, fieldNumber: 1) + } + if self.forward != false { + try visitor.visitSingularBoolField(value: self.forward, fieldNumber: 2) + } + if self.ts != 0 { + try visitor.visitSingularInt64Field(value: self.ts, fieldNumber: 3) + } + if !self.songID.isEmpty { + try visitor.visitSingularStringField(value: self.songID, fieldNumber: 4) + } + if self.uid != 0 { + try visitor.visitSingularInt32Field(value: self.uid, fieldNumber: 5) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: LrcTime, rhs: LrcTime) -> Bool { + if lhs.type != rhs.type {return false} + if lhs.forward != rhs.forward {return false} + if lhs.ts != rhs.ts {return false} + if lhs.songID != rhs.songID {return false} + if lhs.uid != rhs.uid {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/LrcTime.proto b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/LrcTime.proto new file mode 100644 index 0000000..084175b --- /dev/null +++ b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVAPI/LrcTime.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +enum MsgType { + UNKNOWN_TYPE = 0; + LRC_TIME = 1001; +} + +message LrcTime { + MsgType type = 1; + bool forward = 2; + int64 ts = 3; + string songId = 4; + int32 uid = 5; +} diff --git a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVLyricView.swift b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVLyricView.swift index 3bb1bdd..e62e43c 100644 --- a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVLyricView.swift +++ b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVLyricView.swift @@ -8,7 +8,7 @@ import UIKit import AgoraLyricsScore class KTVLyricView: UIView { - var downloadManager = AgoraDownLoadManager() + var downloadManager = LyricsFileDownloader() var lrcView: KaraokeView! override init(frame: CGRect) { super.init(frame: frame) @@ -25,7 +25,7 @@ class KTVLyricView: UIView { lrcView.scoringView.viewHeight = 60 lrcView.scoringView.topSpaces = 5 lrcView.backgroundColor = .lightGray - lrcView.lyricsView.textNormalColor = UIColor(red: 1, green: 1, blue: 1, alpha: 0.5) + lrcView.lyricsView.inactiveLineTextColor = UIColor(red: 1, green: 1, blue: 1, alpha: 0.5) // lrcView.lyricsView.textHighlightedColor = UIColor(hex: "#EEFF25") lrcView.lyricsView.lyricLineSpacing = 6 lrcView.lyricsView.draggable = false @@ -46,34 +46,7 @@ extension KTVLyricView: KTVLrcViewDelegate, KaraokeDelegate { func onDownloadLrcData(url: String) { //开始歌词下载 - startDownloadLrc(with: url) {[weak self] url in - guard let self = self, let url = url else {return} - self.resetLrcData(with: url) - } - } - - func startDownloadLrc(with url: String, callBack: @escaping LyricCallback) { - var path: String? = nil - downloadManager.downloadLrcFile(urlString: url) { lrcurl in - defer { - callBack(path) - } - guard let lrcurl = lrcurl else { - print("downloadLrcFile fail, lrcurl is nil") - return - } - - let curSong = URL(string: url)?.lastPathComponent.components(separatedBy: ".").first - let loadSong = URL(string: lrcurl)?.lastPathComponent.components(separatedBy: ".").first - guard curSong == loadSong else { - print("downloadLrcFile fail, missmatch, cur:\(curSong ?? "") load:\(loadSong ?? "")") - return - } - path = lrcurl - } failure: { - callBack(nil) - print("歌词解析失败") - } + let _ = downloadManager.download(urlString: url) } func resetLrcData(with url: String) { @@ -90,12 +63,15 @@ extension KTVLyricView: KTVLrcViewDelegate, KaraokeDelegate { } } -extension KTVLyricView: AgoraLrcDownloadDelegate { - public func downloadLrcFinished(url: String) { - print("download lrc finished \(url)") +extension KTVLyricView: LyricsFileDownloaderDelegate { + func onLyricsFileDownloadProgress(requestId: Int, progress: Float) { + } - public func downloadLrcError(url: String, error: Error?) { - print("download lrc fail \(url): \(String(describing: error))") + func onLyricsFileDownloadCompleted(requestId: Int, fileData: Data?, error: DownloadError?) { + guard let data = fileData, let model = KaraokeView.parseLyricData(data: data) else { + return + } + lrcView?.setLyricData(data: model) } } diff --git a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVViewController.swift b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVViewController.swift index 9da8895..7807233 100644 --- a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVViewController.swift +++ b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KTVViewController.swift @@ -21,17 +21,18 @@ class KTVViewController: UIViewController { var type: LoadMusicType = .mcc var rtcKit: AgoraRtcEngineKit! var rtcDataStreamId: Int = 0 - var ktvApi: KTVApiImpl! + var ktvApi: KTVApiDelegate! var rtcToken: String? var rtmToken: String? var rtcPlayerToken: String? var userId: Int = 0 + var isCantata: Bool = false let mainSingerId = 1000 let coSingerId = 2000 let audienceId = 3000 - let mccSongCode = 6654550232746660 + let mccSongCode = 7162848697922600 var lyricView: KTVLyricView! @@ -56,7 +57,7 @@ class KTVViewController: UIViewController { self.view.backgroundColor = .white self.title = "KTV online" - if role == .leadSinger { + if role == .leadSinger || role == .leadSinger { userId = mainSingerId } else if role == .coSinger { userId = coSingerId @@ -73,8 +74,21 @@ class KTVViewController: UIViewController { layoutUI() joinRTCChannel() - loadKTVApi() - + + if isCantata && role == .leadSinger{ + getCloudMixerToken(with: "232425") {[weak self] inputToken, outputToken in + guard let self = self else {return} + ApiManager.shared.fetchStartCloud(mainChannel: self.channelName, cloudRtcUid: 232425, inputToken:inputToken, outputToken:outputToken) {[weak self] flag in + if flag == false {//云端合流失败 + SVProgressHUD.show(withStatus: "云端合流失败") + } else { + self?.loadKTVApi() + } + } + } + } else { + loadKTVApi() + } } override func viewWillDisappear(_ animated: Bool) { @@ -162,20 +176,46 @@ class KTVViewController: UIViewController { } private func loadKTVApi() { - getMccData(with: "\(userId)") {[weak self] rtcToken, rtmToken, rtcPlayerToken in - guard let self = self else {return} - self.rtcToken = rtcToken - self.rtmToken = rtmToken - self.rtcPlayerToken = rtcPlayerToken - - let apiConfig = KTVApiConfig(appId: KeyCenter.AppId, rtmToken: self.type == .mcc ? (self.rtmToken ?? "") : "", engine: self.rtcKit, channelName: self.channelName, localUid: self.userId, chorusChannelName: "\(self.channelName)_ex", chorusChannelToken: self.rtcPlayerToken ?? "", type: .normal, maxCacheSize: 10, musicType: self.type == .mcc ? .mcc : .local, isDebugMode: false) - self.ktvApi = KTVApiImpl(config: apiConfig) - self.ktvApi.renewInnerDataStreamId() - self.ktvApi.setLrcView(view: self.lyricView) - self.ktvApi.addEventHandler(ktvApiEventHandler: self) - - self.rtcKit.joinChannel(byToken: KeyCenter.Token, channelId: self.channelName, uid: UInt(self.userId), mediaOptions: self.mediaOptions()) - self.loadMusic() + if isCantata { + getCantataMccData(with: "\(userId)") {[weak self] rtcToken, rtmToken, audienceToken, rtcPlayerToken in + guard let self = self else {return} + self.rtcToken = rtcToken + self.rtmToken = rtmToken + self.rtcPlayerToken = rtcPlayerToken + + let giantConfig = GiantChorusConfiguration(appId: KeyCenter.AppId, rtmToken: rtmToken ?? "", engine: rtcKit, localUid: self.userId, audienceChannelName: "\(channelName)_ad", audienceChannelToken: audienceToken ?? "", chorusChannelName: "\(channelName)", chorusChannelToken: rtcToken ?? "", musicStreamUid: 2023, musicChannelToken: rtcPlayerToken ?? "", maxCacheSize: 10, musicType: self.type == .mcc ? .mcc : .local, routeSelectionConfig: GiantChorusRouteSelectionConfig(type: .byDelay, streamNum: 6), mccDomain: nil) + + self.ktvApi = KTVGiantChorusApiImpl() + self.ktvApi.createKTVGiantChorusApi?(config: giantConfig) + self.ktvApi.renewInnerDataStreamId() + self.ktvApi.setLrcView(view: self.lyricView) + + self.ktvApi.addEventHandler(ktvApiEventHandler: self) + + let connection = AgoraRtcConnection(channelId: "\(channelName)_ad", localUid: self.userId) + let _ = self.rtcKit.joinChannelEx(byToken: audienceToken, connection: connection, delegate: self, mediaOptions: self.mediaOptions()) + self.rtcKit.setParametersEx("{\"rtc.use_audio4\": true}", connection: connection) + + self.loadMusic() + } + } else { + getMccData(with: "\(userId)") {[weak self] rtcToken, rtmToken, rtcPlayerToken in + guard let self = self else {return} + self.rtcToken = rtcToken + self.rtmToken = rtmToken + self.rtcPlayerToken = rtcPlayerToken + + let apiConfig = KTVApiConfig(appId: KeyCenter.AppId, rtmToken: self.type == .mcc ? (self.rtmToken ?? "") : "", engine: self.rtcKit, channelName: self.channelName, localUid: self.userId, chorusChannelName: "\(self.channelName)_ex", chorusChannelToken: self.rtcPlayerToken ?? "", type: .normal, musicType: self.type == .mcc ? .mcc : .local, maxCacheSize: 10, mccDomain: nil) + + self.ktvApi = KTVApiImpl() + self.ktvApi.createKtvApi?(config: apiConfig) + self.ktvApi.renewInnerDataStreamId() + self.ktvApi.setLrcView(view: self.lyricView) + self.ktvApi.addEventHandler(ktvApiEventHandler: self) + + self.rtcKit.joinChannel(byToken: KeyCenter.Token, channelId: self.channelName, uid: UInt(self.userId), mediaOptions: self.mediaOptions()) + self.loadMusic() + } } } @@ -197,16 +237,22 @@ class KTVViewController: UIViewController { private func switchRole() { ktvApi.switchSingerRole(newRole: role) { state, failReason in - + if self.role == .soloSinger || self.role == .leadSinger { + if self.type == .mcc { + self.ktvApi.startSing(songCode: self.mccSongCode, startPos: 0) + } else { + let mUrl = Bundle.main.path(forResource: "不如跳舞", ofType: "mp4")! + self.ktvApi.startSing(url: mUrl, startPos: 0) + } + } } } private func loadMusic() { if type == .local { - let mUrl = Bundle.main.path(forResource: "成都", ofType: "mp3")! - let lUrl = Bundle.main.path(forResource: "成都", ofType: "xml")! + let mUrl = Bundle.main.path(forResource: "不如跳舞", ofType: "mp4")! + let lUrl = Bundle.main.path(forResource: "不如跳舞", ofType: "xml")! let songConfig = KTVSongConfiguration() - songConfig.autoPlay = (role == .leadSinger || role == .soloSinger) ? true : false songConfig.mode = role == .audience ? .loadNone : .loadMusicOnly songConfig.mainSingerUid = mainSingerId songConfig.songIdentifier = "chengdu" @@ -215,8 +261,13 @@ class KTVViewController: UIViewController { switchRole() } else { let songConfig = KTVSongConfiguration() - songConfig.autoPlay = (role == .leadSinger || role == .soloSinger) ? true : false - songConfig.mode = role == .audience ? .loadLrcOnly : .loadMusicAndLrc + if role == .audience { + songConfig.mode = .loadLrcOnly + } else if role == .coSinger { + songConfig.mode = .loadMusicOnly + } else { + songConfig.mode = .loadMusicAndLrc + } songConfig.mainSingerUid = mainSingerId songConfig.songIdentifier = "\(mccSongCode)" ktvApi.loadMusic(songCode: mccSongCode, config: songConfig, onMusicLoadStateListener: self) @@ -229,7 +280,10 @@ class KTVViewController: UIViewController { } private func leaveChannel() { - ktvApi.cleanCache() + ApiManager.shared.fetchStopCloud() + if ktvApi != nil { + ktvApi.cleanCache() + } rtcKit.leaveChannel() } @@ -266,6 +320,82 @@ class KTVViewController: UIViewController { } } + private func getCantataMccData(with userId: String, completion:@escaping ((String?, String?, String?, String?)->Void)) { + var tokenMap1:[Int: String] = [:], tokenMap2:[Int: String] = [:], tokenMap3:[Int: String] = [:] + + let dispatchGroup = DispatchGroup() + dispatchGroup.enter() + NetworkManager.shared.generateTokens(channelName: channelName , + uid: "\(userId)", + tokenGeneratorType: .token006, + tokenTypes: [.rtc, .rtm]) { tokenMap in + tokenMap1 = tokenMap + dispatchGroup.leave() + } + + dispatchGroup.enter() + NetworkManager.shared.generateTokens(channelName: "\(channelName )_ad", + uid: "\(userId)", + tokenGeneratorType: .token006, + tokenTypes: [.rtc]) { tokenMap in + tokenMap2 = tokenMap + dispatchGroup.leave() + } + + dispatchGroup.enter() + NetworkManager.shared.generateTokens(channelName: "\(channelName )", + uid: "2023", + tokenGeneratorType: .token006, + tokenTypes: [.rtc]) { tokenMap in + tokenMap3 = tokenMap + dispatchGroup.leave() + } + + dispatchGroup.notify(queue: .main){ + guard let rtcToken = tokenMap1[NetworkManager.AgoraTokenType.rtc.rawValue], + let rtmToken = tokenMap1[NetworkManager.AgoraTokenType.rtm.rawValue], + let audienceToken = tokenMap2[NetworkManager.AgoraTokenType.rtc.rawValue], + let rtcPlayerToken = tokenMap3[NetworkManager.AgoraTokenType.rtc.rawValue] + else { + completion(nil, nil, nil, nil) + return + } + completion(rtcToken, rtmToken, audienceToken, rtcPlayerToken) + } + } + + private func getCloudMixerToken(with userId: String, completion:@escaping ((String, String)->Void)) { + var tokenMap1:[Int: String] = [:], tokenMap2:[Int: String] = [:] + + let dispatchGroup = DispatchGroup() + dispatchGroup.enter() + NetworkManager.shared.generateTokens(channelName: channelName, + uid: "0", + tokenGeneratorType: .token007, + tokenTypes: [.rtc]) { tokenMap in + tokenMap1 = tokenMap + dispatchGroup.leave() + } + + dispatchGroup.enter() + NetworkManager.shared.generateTokens(channelName: "\(channelName)_ad", + uid: userId, + tokenGeneratorType: .token007, + tokenTypes: [.rtc]) { tokenMap in + tokenMap2 = tokenMap + dispatchGroup.leave() + } + + dispatchGroup.notify(queue: .main){ + if let inputToken = tokenMap1[NetworkManager.AgoraTokenType.rtc.rawValue], + let outputToken = tokenMap2[NetworkManager.AgoraTokenType.rtc.rawValue] { + completion(inputToken, outputToken) + } else { + print("获取合流Token失败") + } + } + } + } extension KTVViewController { @@ -312,6 +442,12 @@ extension KTVViewController: AgoraRtcEngineDelegate { } + func rtcEngine(_ engine: AgoraRtcEngineKit, audioMetadataReceived uid: UInt, metadata: Data) { + if isCantata { + self.ktvApi.didAudioMetadataReceived(uid: uid, metadata: metadata) + } + } + @objc private func leaveChorus() { if role == .coSinger { //合唱者才能离开合唱 @@ -352,7 +488,7 @@ extension KTVViewController: AgoraRtcEngineDelegate { } extension KTVViewController: IMusicLoadStateListener { - func onMusicLoadProgress(songCode: Int, percent: Int, status: AgoraMusicContentCenterPreloadStatus, msg: String?, lyricUrl: String?) { + func onMusicLoadProgress(songCode: Int, percent: Int, state: AgoraMusicContentCenterPreloadState, msg: String?, lyricUrl: String?) { //歌曲加载进度 print("歌曲加载进度:\(percent)%") } @@ -374,7 +510,7 @@ extension KTVViewController: IMusicLoadStateListener { } extension KTVViewController: KTVApiEventHandlerDelegate { - func onMusicPlayerStateChanged(state: AgoraMediaPlayerState, error: AgoraMediaPlayerError, isLocal: Bool) { + func onMusicPlayerStateChanged(state: AgoraMediaPlayerState, reason: AgoraMediaPlayerReason, isLocal: Bool) { } diff --git a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KeyCenter.swift b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KeyCenter.swift new file mode 100644 index 0000000..f1f9140 --- /dev/null +++ b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/KeyCenter.swift @@ -0,0 +1,51 @@ +// +// KeyCenter.swift +// OpenLive +// +// Created by GongYuhua on 6/25/16. +// Copyright © 2016 Agora. All rights reserved. +// +import Foundation + +@objcMembers +class KeyCenter: NSObject { + + /** + Agora APP ID. + Agora assigns App IDs to app developers to identify projects and organizations. + If you have multiple completely separate apps in your organization, for example built by different teams, + you should use different App IDs. + If applications need to communicate with each other, they should use the same App ID. + In order to get the APP ID, you can open the agora console (https://console.shengwang.cn/) to create a project, + then the APP ID can be found in the project detail page. + 声网APP ID + Agora 给应用程序开发人员分配 App ID,以识别项目和组织。如果组织中有多个完全分开的应用程序,例如由不同的团队构建, + 则应使用不同的 App ID。如果应用程序需要相互通信,则应使用同一个App ID。 + 进入声网控制台(https://console.shengwang.cn/),创建一个项目,进入项目配置页,即可看到APP ID。 + */ + + static let AppId: String = "" + + /** + Certificate. + Agora provides App certificate to generate Token. You can deploy and generate a token on your server, + or use the console to generate a temporary token. + In order to get the APP ID, you can open the agora console (https://console.shengwang.cn/) to create a project with the App Certificate enabled, + then the APP Certificate can be found in the project detail page. + PS: If the project does not have certificates enabled, leave this field blank. + 声网APP证书 + Agora 提供 App certificate 用以生成 Token。您可以在您的服务器部署并生成,或者使用控制台生成临时的 Token。 + 进入声网控制台(https://console.shengwang.cn/),创建一个带证书鉴权的项目,进入项目配置页,即可看到APP证书。 + 注意:如果项目没有开启证书鉴权,这个字段留空。 + */ + + static let Certificate: String? = nil + + // cantata cloud server key + static let RestfulApiKey: String? = "" + // cantata cloud server secret + static let RestfulApiSecret: String? = "" + + static var baseServerUrl: String? = "https://service.shengwang.cn/" + static var Token: String? = "" +} diff --git a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/NetworkManager.swift b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/NetworkManager.swift index 9b6a03a..8a863df 100644 --- a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/NetworkManager.swift +++ b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/NetworkManager.swift @@ -55,7 +55,7 @@ class NetworkManager:NSObject { @objc static let shared = NetworkManager() private let baseUrl = "https://agoraktv.xyz/1.1/functions/" - private let baseServerUrl: String = "https://toolbox.bj2.agoralab.co/v1/" + private let baseServerUrl: String = "https://service.shengwang.cn/toolbox/v2/" private func basicAuth(key: String, password: String) -> String { let loginString = String(format: "%@:%@", key, password) diff --git a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/ViewController.swift b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/ViewController.swift index 8abe846..9cfb4d8 100644 --- a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/ViewController.swift +++ b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/ViewController.swift @@ -15,6 +15,8 @@ class ViewController: UIViewController { var rtmToken: String? var rtcPlayerToken: String? var userId: Int = 0 + var selBtn: UIButton? + var isCantata: Bool = false @IBOutlet weak var tf: UITextField! override func viewDidLoad() { super.viewDidLoad() @@ -24,21 +26,30 @@ class ViewController: UIViewController { @IBAction func leadSet(_ sender: UIButton) { role = .leadSinger + if self.selBtn != nil { + self.selBtn?.setTitleColor(.white, for: .normal) + } + sender.setTitleColor(.red, for: .normal) + self.selBtn = sender } - - @IBAction func coSet(_ sender: Any) { - role = .coSinger - } - - - @IBAction func auSet(_ sender: Any) { + + @IBAction func auSet(_ sender: UIButton) { role = .audience + if self.selBtn != nil { + self.selBtn?.setTitleColor(.white, for: .normal) + } + sender.setTitleColor(.red, for: .normal) + self.selBtn = sender } @IBAction func valueChange(_ sender: UISegmentedControl) { type = sender.selectedSegmentIndex == 0 ? .mcc : .local } + @IBAction func ktvTypeChange(_ sender: UISegmentedControl) { + isCantata = sender.selectedSegmentIndex == 0 ? false : true + } + @IBAction func startSing(_ sender: UIButton) { if tf.text?.count == 0 { return @@ -48,6 +59,7 @@ class ViewController: UIViewController { let vc = KTVViewController() vc.role = role vc.type = type + vc.isCantata = isCantata vc.channelName = channelName self.navigationController?.pushViewController(vc, animated: true) } diff --git "a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/\344\270\215\345\246\202\350\267\263\350\210\236.mp4" "b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/\344\270\215\345\246\202\350\267\263\350\210\236.mp4" new file mode 100644 index 0000000..94d0846 Binary files /dev/null and "b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/\344\270\215\345\246\202\350\267\263\350\210\236.mp4" differ diff --git "a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/\344\270\215\345\246\202\350\267\263\350\210\236.xml" "b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/\344\270\215\345\246\202\350\267\263\350\210\236.xml" new file mode 100644 index 0000000..5e8e027 --- /dev/null +++ "b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/\344\270\215\345\246\202\350\267\263\350\210\236.xml" @@ -0,0 +1,1440 @@ + + + + 不如跳舞 + 陈慧琳 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git "a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/\346\210\220\351\203\275.mp3" "b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/\346\210\220\351\203\275.mp3" deleted file mode 100644 index da4336d..0000000 Binary files "a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/\346\210\220\351\203\275.mp3" and /dev/null differ diff --git "a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/\346\210\220\351\203\275.xml" "b/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/\346\210\220\351\203\275.xml" deleted file mode 100644 index 1328c0f..0000000 --- "a/KTVAPI/iOS/Example/KTVApiDemo/KTVApiDemo/\346\210\220\351\203\275.xml" +++ /dev/null @@ -1,1225 +0,0 @@ - - - - 成都 - 赵雷 - 1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 绿 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 绿 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/KTVAPI/iOS/Example/KTVApiDemo/PodFile b/KTVAPI/iOS/Example/KTVApiDemo/PodFile index ec09ea2..dda56e0 100644 --- a/KTVAPI/iOS/Example/KTVApiDemo/PodFile +++ b/KTVAPI/iOS/Example/KTVApiDemo/PodFile @@ -3,8 +3,9 @@ use_frameworks! platform :ios, '13.0' target 'KTVApiDemo' do - pod 'AgoraRtcEngine_Special_iOS', '4.1.1.23' - pod 'AgoraLyricsScore', '1.1.1-beta-3' + pod 'AgoraRtcEngine_Special_iOS', '4.3.2.2' + pod 'AgoraLyricsScore', '1.1.6' pod 'Zip' pod 'SVProgressHUD' + pod 'SwiftProtobuf' end diff --git a/KTVAPI/iOS/Example/KTVApiDemo/Podfile.lock b/KTVAPI/iOS/Example/KTVApiDemo/Podfile.lock index 3870da4..9fa3f7b 100644 --- a/KTVAPI/iOS/Example/KTVApiDemo/Podfile.lock +++ b/KTVAPI/iOS/Example/KTVApiDemo/Podfile.lock @@ -1,30 +1,39 @@ PODS: - - AgoraLyricsScore (1.1.1-beta-3) - - AgoraRtcEngine_Special_iOS (4.1.1.23) + - AgoraComponetLog (0.0.1) + - AgoraLyricsScore (1.1.6): + - AgoraComponetLog + - Zip + - AgoraRtcEngine_Special_iOS (4.3.2.2) - SVProgressHUD (2.3.1): - SVProgressHUD/Core (= 2.3.1) - SVProgressHUD/Core (2.3.1) + - SwiftProtobuf (1.25.2) - Zip (2.1.2) DEPENDENCIES: - - AgoraLyricsScore (= 1.1.1-beta-3) - - AgoraRtcEngine_Special_iOS (= 4.1.1.23) + - AgoraLyricsScore (= 1.1.6) + - AgoraRtcEngine_Special_iOS (= 4.3.2.2) - SVProgressHUD + - SwiftProtobuf - Zip SPEC REPOS: trunk: + - AgoraComponetLog - AgoraLyricsScore - AgoraRtcEngine_Special_iOS - SVProgressHUD + - SwiftProtobuf - Zip SPEC CHECKSUMS: - AgoraLyricsScore: 7e25860ccecd3c6ed73f2b503f1d73d8849079e1 - AgoraRtcEngine_Special_iOS: 6a3f7814058f819b374cec3037cbbf7c4de519a5 + AgoraComponetLog: c52aec8db3ea38c5693fd4f1beee05ba7715e68b + AgoraLyricsScore: 7576387b199cdc3b2752a113688abd0a807a2ca3 + AgoraRtcEngine_Special_iOS: 4f1a1d2f2e7b564735fd74ced733c76e33b5dc04 SVProgressHUD: 4837c74bdfe2e51e8821c397825996a8d7de6e22 + SwiftProtobuf: 407a385e97fd206c4fbe880cc84123989167e0d1 Zip: b3fef584b147b6e582b2256a9815c897d60ddc67 -PODFILE CHECKSUM: d1c78c95a57910e01b805128afe6e86b075e3b0e +PODFILE CHECKSUM: db850d4294833f17b507bcd17de203fd23084ce3 -COCOAPODS: 1.12.1 +COCOAPODS: 1.15.2