添加AI服务,支持麦克风、摄像头输入

This commit is contained in:
2026-03-18 12:54:00 +08:00
commit d09d6f1cc0
759 changed files with 240072 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+90
View File
@@ -0,0 +1,90 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
static def getCurrentTime() {
return new Date().format("yyyy-MM-dd-HH-mm", TimeZone.getTimeZone("GMT+08:00"))
}
android {
namespace 'ai.guiji.duix.test'
compileSdk 34
defaultConfig {
applicationId "ai.guiji.duix.test"
minSdk 24
targetSdk 34
versionCode 13
versionName '4.1.1'
}
signingConfigs {
release {
storeFile file('../demo.jks')
storePassword '123456'
keyAlias 'demo'
keyPassword '123456'
}
}
buildTypes {
debug {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
applicationVariants.all { variant ->
variant.outputs.all {
outputFileName = "duix_mobile_test_${buildType.name}_${getCurrentTime()}_${defaultConfig.versionName}.apk"
}
}
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
viewBinding true
}
composeOptions {
kotlinCompilerExtensionVersion '1.4.3'
}
packaging {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
lint {
baseline = file("lint-baseline.xml")
}
lintOptions{
checkReleaseBuilds false
}
}
dependencies {
implementation fileTree(include: ['*.jar', '*.aar'], dir: 'libs')
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
implementation "androidx.activity:activity:1.3.0"
implementation "androidx.fragment:fragment:1.3.0"
implementation 'com.github.bumptech.glide:glide:4.12.0'
implementation project(":duix-sdk")
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
}
+561
View File
@@ -0,0 +1,561 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.1.2" type="baseline" client="gradle" dependencies="false" name="AGP (8.1.2)" variant="all" version="8.1.2">
<issue
id="CanvasSize"
message="Calling `Canvas.getHeight()` is usually wrong; you should be calling `getHeight()` instead"
errorLine1=" canvas.getWidth(), canvas.getHeight(), Bitmap.Config.ARGB_8888);"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/ai/guiji/duix/test/ui/view/VisualizerView.java"
line="98"
column="40"/>
</issue>
<issue
id="CanvasSize"
message="Calling `Canvas.getWidth()` is usually wrong; you should be calling `getWidth()` instead"
errorLine1=" canvas.getWidth(), canvas.getHeight(), Bitmap.Config.ARGB_8888);"
errorLine2=" ~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/ai/guiji/duix/test/ui/view/VisualizerView.java"
line="98"
column="21"/>
</issue>
<issue
id="CustomViewStyleable"
message="By convention, the custom view (`VisualizerView`) and the declare-styleable (`visualizerView`) should have the same name (various editor features rely on this convention)"
errorLine1=" TypedArray args = context.obtainStyledAttributes(attrs, R.styleable.visualizerView);"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/ai/guiji/duix/test/ui/view/VisualizerView.java"
line="73"
column="65"/>
</issue>
<issue
id="NotificationPermission"
message="When targeting Android 13 or higher, posting a permission requires holding the `POST_NOTIFICATIONS` permission (usage from com.bumptech.glide.request.target.NotificationTarget)">
<location
file="src/main/AndroidManifest.xml"/>
</issue>
<issue
id="SimpleDateFormat"
message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
errorLine1=" SimpleDateFormat fmt = new SimpleDateFormat(&quot;yyyyMMddHHmmssSSS&quot;);"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/ai/guiji/duix/test/util/StringUtils.java"
line="170"
column="32"/>
</issue>
<issue
id="SimpleDateFormat"
message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
errorLine1=" SimpleDateFormat format = new SimpleDateFormat(&quot;yyyyMMddHHmmssSSS&quot;);"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/ai/guiji/duix/test/util/StringUtils.java"
line="177"
column="35"/>
</issue>
<issue
id="SimpleDateFormat"
message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
errorLine1=" SimpleDateFormat format = new SimpleDateFormat(&quot;yyyy-MM&quot;);"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/ai/guiji/duix/test/util/StringUtils.java"
line="183"
column="35"/>
</issue>
<issue
id="SimpleDateFormat"
message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
errorLine1=" SimpleDateFormat format = new SimpleDateFormat(&quot;MM-dd HH:mm&quot;);"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/ai/guiji/duix/test/util/StringUtils.java"
line="189"
column="35"/>
</issue>
<issue
id="SimpleDateFormat"
message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
errorLine1=" SimpleDateFormat sdf = new SimpleDateFormat(&quot;yyyy-MM-dd&quot;);"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/ai/guiji/duix/test/util/StringUtils.java"
line="365"
column="32"/>
</issue>
<issue
id="UseSwitchCompatOrMaterialXml"
message="Use `SwitchCompat` from AppCompat or `SwitchMaterial` from Material library"
errorLine1=" &lt;Switch"
errorLine2=" ^">
<location
file="src/main/res/layout/activity_main.xml"
line="157"
column="13"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.core:core-ktx than 1.12.0 is available: 1.13.0"
errorLine1=""
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="build.gradle"
line="47"
column="20"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.appcompat:appcompat than 1.2.0 is available: 1.6.1"
errorLine1="dependencies {"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="build.gradle"
line="48"
column="20"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of com.google.android.material:material than 1.4.0 is available: 1.11.0"
errorLine1=""
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="build.gradle"
line="49"
column="20"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.constraintlayout:constraintlayout than 2.1.1 is available: 2.1.4"
errorLine1=" implementation fileTree(include: [&apos;*.jar&apos;, &apos;*.aar&apos;], dir: &apos;libs&apos;)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="build.gradle"
line="50"
column="20"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.activity:activity than 1.3.0 is available: 1.8.2"
errorLine1=" implementation &apos;androidx.core:core-ktx:1.12.0&apos;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="build.gradle"
line="51"
column="20"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.fragment:fragment than 1.3.0 is available: 1.5.7"
errorLine1=" implementation &apos;androidx.appcompat:appcompat:1.2.0&apos;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="build.gradle"
line="52"
column="20"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of com.google.android.exoplayer:exoplayer than 2.14.2 is available: 2.18.5"
errorLine1=""
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="build.gradle"
line="59"
column="20"/>
</issue>
<issue
id="SpUsage"
message="Should use &quot;`sp`&quot; instead of &quot;`dp`&quot; for text sizes"
errorLine1=" android:textSize=&quot;18dp&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="15"
column="9"/>
</issue>
<issue
id="DrawAllocation"
message="Avoid object allocations during draw/layout operations (preallocate and reuse instead)"
errorLine1=" canvas.drawBitmap(mCanvasBitmap, new Matrix(), null);"
errorLine2=" ~~~~~~~~~~~~">
<location
file="src/main/java/ai/guiji/duix/test/ui/view/VisualizerView.java"
line="116"
column="42"/>
</issue>
<issue
id="ObsoleteSdkInt"
message="Unnecessary; SDK_INT is never &lt; 24"
errorLine1=" if (Build.VERSION.SDK_INT &lt; Build.VERSION_CODES.M) {"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/ai/guiji/duix/test/ui/activity/BaseActivity.java"
line="89"
column="13"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.purple_200` appears to be unused"
errorLine1=" &lt;color name=&quot;purple_200&quot;>#FFBB86FC&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="3"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.purple_500` appears to be unused"
errorLine1=" &lt;color name=&quot;purple_500&quot;>#FF6200EE&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="4"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.purple_700` appears to be unused"
errorLine1=" &lt;color name=&quot;purple_700&quot;>#FF3700B3&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="5"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.teal_200` appears to be unused"
errorLine1=" &lt;color name=&quot;teal_200&quot;>#FF03DAC5&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="6"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.teal_700` appears to be unused"
errorLine1=" &lt;color name=&quot;teal_700&quot;>#FF018786&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="7"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.ic_launcher_background` appears to be unused"
errorLine1="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/ic_launcher_background.xml"
line="2"
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.ic_launcher_foreground` appears to be unused"
errorLine1="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/ic_launcher_foreground.xml"
line="1"
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.need_record_permission` appears to be unused"
errorLine1=" &lt;string name=&quot;need_record_permission&quot;>需要授予语音权限以继续操作&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="15"
column="13"/>
</issue>
<issue
id="TextFields"
message="This text field does not specify an `inputType`"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="47"
column="14"/>
</issue>
<issue
id="TextFields"
message="This text field does not specify an `inputType`"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="70"
column="14"/>
</issue>
<issue
id="TextFields"
message="This text field does not specify an `inputType`"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="93"
column="14"/>
</issue>
<issue
id="TextFields"
message="This text field does not specify an `inputType`"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="115"
column="14"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="47"
column="14"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="70"
column="14"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="93"
column="14"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="115"
column="14"/>
</issue>
<issue
id="ContentDescription"
message="Missing `contentDescription` attribute on image"
errorLine1=" &lt;ImageView"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/res/layout/activity_call.xml"
line="7"
column="6"/>
</issue>
<issue
id="ContentDescription"
message="Missing `contentDescription` attribute on image"
errorLine1=" &lt;ImageView"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/res/layout/activity_call.xml"
line="33"
column="14"/>
</issue>
<issue
id="ContentDescription"
message="Missing `contentDescription` attribute on image"
errorLine1=" &lt;ImageView"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/res/layout/activity_call.xml"
line="43"
column="14"/>
</issue>
<issue
id="ContentDescription"
message="Missing `contentDescription` attribute on image"
errorLine1=" &lt;ImageView"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/res/layout/activity_call.xml"
line="93"
column="14"/>
</issue>
<issue
id="ContentDescription"
message="Missing `contentDescription` attribute on image"
errorLine1=" &lt;ImageView"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/res/layout/activity_call.xml"
line="103"
column="14"/>
</issue>
<issue
id="LabelFor"
message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="47"
column="14"/>
</issue>
<issue
id="LabelFor"
message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="70"
column="14"/>
</issue>
<issue
id="LabelFor"
message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="93"
column="14"/>
</issue>
<issue
id="LabelFor"
message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="115"
column="14"/>
</issue>
<issue
id="HardcodedText"
message="Hardcoded string &quot;1214228509954805760&quot;, should use `@string` resource"
errorLine1=" android:text=&quot;1214228509954805760&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="51"
column="17"/>
</issue>
<issue
id="HardcodedText"
message="Hardcoded string &quot;5b8b91f1-fb2d-4cdf-a8f0-5f6dd178687b&quot;, should use `@string` resource"
errorLine1=" android:text=&quot;5b8b91f1-fb2d-4cdf-a8f0-5f6dd178687b&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="74"
column="17"/>
</issue>
<issue
id="HardcodedText"
message="Hardcoded string &quot;1764548680285933569&quot;, should use `@string` resource"
errorLine1=" android:text=&quot;1764548680285933569&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="97"
column="17"/>
</issue>
<issue
id="HardcodedText"
message="Hardcoded string &quot;300&quot;, should use `@string` resource"
errorLine1=" android:text=&quot;300&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/activity_main.xml"
line="119"
column="17"/>
</issue>
<issue
id="HardcodedText"
message="Hardcoded string &quot;启动ASR&quot;, should use `@string` resource"
errorLine1=" android:text=&quot;启动ASR&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/activity_test_asr.xml"
line="19"
column="9"/>
</issue>
<issue
id="HardcodedText"
message="Hardcoded string &quot;关闭ASR&quot;, should use `@string` resource"
errorLine1=" android:text=&quot;关闭ASR&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/activity_test_asr.xml"
line="27"
column="9"/>
</issue>
</issues>
+26
View File
@@ -0,0 +1,26 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-dontwarn com.squareup.okhttp3.**
-keep class com.squareup.okhttp3.** { *;}
-keep class ai.guiji.duix.DuixNcnn{*; }
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/>
<uses-feature android:name="android.hardware.camera.front" android:required="false"/>
<application
android:name=".App"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.DUIX.Test">
<activity
android:name=".ui.activity.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.activity.CallActivity"
android:exported="false">
</activity>
</application>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 998 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,31 @@
package ai.guiji.duix.test;
import android.app.Application;
import android.text.TextUtils;
import java.util.concurrent.TimeUnit;
import okhttp3.OkHttpClient;
public class App extends Application {
public static App mApp;
private static OkHttpClient mOkHttpClient;
@Override
public void onCreate() {
super.onCreate();
mApp = this;
}
public static OkHttpClient getOkHttpClient() {
if (mOkHttpClient == null) {
mOkHttpClient = new OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build();
}
return mOkHttpClient;
}
}
@@ -0,0 +1,110 @@
package ai.guiji.duix.test.audio;
import android.annotation.SuppressLint;
import android.content.Context;
import android.media.AudioFormat;
import android.media.AudioRecord;
import android.media.MediaRecorder;
import android.util.Log;
import java.io.File;
import java.io.FileOutputStream;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import ai.guiji.duix.test.util.StringUtils;
public class AudioRecorder {
private final String TAG = getClass().getSimpleName();
private final Context mContext;
private AudioRecord mAudioRecorder; //录音器
private final Executor mExecutor = Executors.newSingleThreadExecutor();
private final RecorderCallback callback;
public AudioRecorder(Context context, RecorderCallback callback){
this.mContext = context;
this.callback = callback;
}
@SuppressLint("MissingPermission")
public void start(){
int sampleRateInHz = 16000;
int channelConfig = AudioFormat.CHANNEL_IN_MONO;
int audioFormat = AudioFormat.ENCODING_PCM_16BIT;
//20ms audio for 16k/16bit/mono
// int WAVE_FRAM_SIZE = 20 * 2 * 1 * SAMPLE_RATE / 1000;
int minBufferSize = AudioRecord.getMinBufferSize(
sampleRateInHz,
channelConfig,
audioFormat
);
Log.d(TAG, "minBufferSize: " + minBufferSize);
mAudioRecorder = new AudioRecord(MediaRecorder.AudioSource.DEFAULT,
sampleRateInHz, channelConfig,
audioFormat,
minBufferSize);
if (mAudioRecorder.getState() != AudioRecord.STATE_UNINITIALIZED){
mAudioRecorder.startRecording();
mExecutor.execute(() -> {
long startTime = System.currentTimeMillis();
File cacheDir = mContext.getExternalCacheDir();
if (!cacheDir.exists()){
if (!cacheDir.mkdirs()) Log.e(TAG, "mkdirs fail path: " + cacheDir.getAbsolutePath());
}
String pcmName = StringUtils.createFileName("record_", ".pcm");
File pcmFile = new File(cacheDir, pcmName);
try (FileOutputStream outputStream = new FileOutputStream(pcmFile)) {
byte[] data = new byte[minBufferSize];
while (mAudioRecorder.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING){
int length = mAudioRecorder.read(data, 0, minBufferSize);
if (length > 0){
outputStream.write(data, 0, length);
if (callback != null){
callback.onReadData(data, 0, length);
}
}
}
Log.d(TAG, "Record done.");
long diff = System.currentTimeMillis() - startTime;
if (callback != null){
if (diff > 200){
callback.onFinish(pcmFile.getAbsolutePath());
} else {
callback.onRecordError(-2, "too short!");
}
}
} catch (Exception e) {
Log.e(TAG, "Record error: " + e);
if (callback != null){
callback.onRecordError(-1, "Record error: " + e);
}
}
});
}
}
public void stop(){
if (mAudioRecorder != null){
if (mAudioRecorder.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING){
mAudioRecorder.stop();
}
}
}
public void release(){
if (mAudioRecorder != null){
mAudioRecorder.release();
mAudioRecorder = null;
}
}
public interface RecorderCallback{
void onReadData(byte[] data, int offsetInBytes, int length);
void onRecordError(int code, String message);
void onFinish(String path);
}
}
@@ -0,0 +1,52 @@
package ai.guiji.duix.test.audio;
/**
* 简单线性插值重采样:24kHz 16bit mono -> 16kHz 16bit mono
* 用于将 Qwen-Omni-Realtime 输出的 24kHz 音频转换为 DUIX 所需的 16kHz
*/
public class AudioResampler {
private static final int INPUT_RATE = 24000;
private static final int OUTPUT_RATE = 16000;
private static final double RATIO = (double) OUTPUT_RATE / INPUT_RATE; // 2/3
/**
* 将 24kHz 16bit 单声道 PCM 重采样为 16kHz
*
* @param input 24kHz 16bit mono PCM
* @return 16kHz 16bit mono PCM
*/
public static byte[] resample24kTo16k(byte[] input) {
if (input == null || input.length < 2) return new byte[0];
int inputSamples = input.length / 2; // 16bit = 2 bytes per sample
int outputSamples = (int) Math.ceil(inputSamples * RATIO);
byte[] output = new byte[outputSamples * 2];
for (int i = 0; i < outputSamples; i++) {
double srcPos = i / RATIO;
int idx0 = (int) Math.floor(srcPos);
int idx1 = Math.min(idx0 + 1, inputSamples - 1);
double frac = srcPos - idx0;
short s0 = getShortLE(input, idx0 * 2);
short s1 = getShortLE(input, idx1 * 2);
short interpolated = (short) (s0 + (s1 - s0) * frac);
putShortLE(output, i * 2, interpolated);
}
return output;
}
private static short getShortLE(byte[] data, int offset) {
if (offset + 1 >= data.length) return 0;
return (short) ((data[offset] & 0xFF) | (data[offset + 1] << 8));
}
private static void putShortLE(byte[] data, int offset, short value) {
if (offset + 1 < data.length) {
data[offset] = (byte) (value & 0xFF);
data[offset + 1] = (byte) ((value >> 8) & 0xFF);
}
}
}
@@ -0,0 +1,106 @@
package ai.guiji.duix.test.audio;
import android.annotation.SuppressLint;
import android.content.Context;
import android.media.AudioFormat;
import android.media.AudioRecord;
import android.media.MediaRecorder;
import android.util.Log;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 流式麦克风录音器,用于实时将音频发送到 OmniRealtime API
* 16kHz, 16bit, mono - 符合 API 输入格式要求
*/
public class StreamingAudioRecorder {
private static final String TAG = "StreamingAudioRecorder";
private static final int SAMPLE_RATE = 16000;
private static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO;
private static final int AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT;
// 20ms 一帧: 16000 * 2 * 0.02 = 640 bytes, 使用 3200 与文档示例一致(100ms)
private static final int CHUNK_SIZE = 3200;
private final Context mContext;
private AudioRecord mAudioRecord;
private final Executor mExecutor = Executors.newSingleThreadExecutor();
private final StreamingCallback callback;
private final AtomicBoolean isRecording = new AtomicBoolean(false);
public StreamingAudioRecorder(Context context, StreamingCallback callback) {
this.mContext = context;
this.callback = callback;
}
@SuppressLint("MissingPermission")
public void start() {
if (isRecording.get()) {
Log.w(TAG, "Already recording");
return;
}
int minBufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT);
int bufferSize = Math.max(minBufferSize, CHUNK_SIZE * 2);
mAudioRecord = new AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE,
CHANNEL_CONFIG,
AUDIO_FORMAT,
bufferSize
);
if (mAudioRecord.getState() != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "AudioRecord init failed");
if (callback != null) {
callback.onError("麦克风初始化失败");
}
return;
}
isRecording.set(true);
mAudioRecord.startRecording();
mExecutor.execute(() -> {
byte[] buffer = new byte[CHUNK_SIZE];
while (isRecording.get() && mAudioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) {
int read = mAudioRecord.read(buffer, 0, buffer.length);
if (read > 0 && callback != null) {
byte[] data = new byte[read];
System.arraycopy(buffer, 0, data, 0, read);
callback.onAudioData(data);
}
}
Log.d(TAG, "Recording thread ended");
});
}
public void stop() {
isRecording.set(false);
if (mAudioRecord != null) {
if (mAudioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) {
mAudioRecord.stop();
}
}
}
public void release() {
stop();
if (mAudioRecord != null) {
mAudioRecord.release();
mAudioRecord = null;
}
}
public boolean isRecording() {
return isRecording.get();
}
public interface StreamingCallback {
void onAudioData(byte[] pcmData);
void onError(String message);
}
}
@@ -0,0 +1,299 @@
package ai.guiji.duix.test.camera;
import android.content.Context;
import android.content.pm.PackageManager;
import android.graphics.ImageFormat;
import android.hardware.camera2.CameraAccessException;
import android.hardware.camera2.CameraCaptureSession;
import android.hardware.camera2.CameraCharacteristics;
import android.hardware.camera2.CameraDevice;
import android.hardware.camera2.CameraManager;
import android.hardware.camera2.CaptureRequest;
import android.media.Image;
import android.media.ImageReader;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;
import android.view.Surface;
import android.view.TextureView;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 前置摄像头帧采集,按约 1 张/秒 输出 JPEG 供 OmniRealtime 使用
* 推荐 480P/720P,单张不超过 500KB
*/
public class CameraFrameCapture {
private static final String TAG = "CameraFrameCapture";
private static final int TARGET_WIDTH = 640; // 480P 左右
private static final int TARGET_HEIGHT = 480;
private static final int JPEG_QUALITY = 85;
private static final long CAPTURE_INTERVAL_MS = 1000; // 1 张/秒
private final Context context;
private final FrameCallback callback;
private final TextureView previewView;
private Surface previewSurface;
private CameraManager cameraManager;
private String frontCameraId;
private CameraDevice cameraDevice;
private CameraCaptureSession captureSession;
private ImageReader imageReader;
private HandlerThread backgroundThread;
private Handler backgroundHandler;
private final AtomicBoolean isCapturing = new AtomicBoolean(false);
private final AtomicBoolean isRunning = new AtomicBoolean(false);
public CameraFrameCapture(Context context, FrameCallback callback) {
this(context, callback, null);
}
public CameraFrameCapture(Context context, FrameCallback callback, TextureView previewView) {
this.context = context.getApplicationContext();
this.callback = callback;
this.previewView = previewView;
}
public void start() {
if (isRunning.get()) {
Log.w(TAG, "Already running");
return;
}
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
if (callback != null) callback.onError("需要相机权限");
return;
}
cameraManager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
if (cameraManager == null) {
if (callback != null) callback.onError("相机不可用");
return;
}
try {
for (String id : cameraManager.getCameraIdList()) {
CameraCharacteristics chars = cameraManager.getCameraCharacteristics(id);
Integer facing = chars.get(CameraCharacteristics.LENS_FACING);
if (facing != null && facing == CameraCharacteristics.LENS_FACING_FRONT) {
frontCameraId = id;
break;
}
}
if (frontCameraId == null) {
if (callback != null) callback.onError("未找到前置摄像头");
return;
}
} catch (CameraAccessException e) {
Log.e(TAG, "Camera access error: " + e.getMessage());
if (callback != null) callback.onError("相机访问失败: " + e.getMessage());
return;
}
backgroundThread = new HandlerThread("CameraFrameCapture");
backgroundThread.start();
backgroundHandler = new Handler(backgroundThread.getLooper());
imageReader = ImageReader.newInstance(TARGET_WIDTH, TARGET_HEIGHT, ImageFormat.JPEG, 2);
imageReader.setOnImageAvailableListener(reader -> {
try (Image image = reader.acquireLatestImage()) {
if (image != null && callback != null) {
byte[] jpeg = imageToJpeg(image);
if (jpeg != null && jpeg.length > 0 && jpeg.length < 500 * 1024) {
callback.onFrame(jpeg);
}
}
} catch (Exception e) {
Log.e(TAG, "Image process error: " + e.getMessage());
}
}, backgroundHandler);
isRunning.set(true);
if (previewView != null) {
if (previewView.isAvailable()) {
setupPreviewSurface();
openCamera();
} else {
previewView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
@Override
public void onSurfaceTextureAvailable(android.graphics.SurfaceTexture surface, int width, int height) {
setupPreviewSurface();
openCamera();
}
@Override
public void onSurfaceTextureSizeChanged(android.graphics.SurfaceTexture surface, int width, int height) {}
@Override
public boolean onSurfaceTextureDestroyed(android.graphics.SurfaceTexture surface) {
return true;
}
@Override
public void onSurfaceTextureUpdated(android.graphics.SurfaceTexture surface) {}
});
}
} else {
openCamera();
}
}
private void setupPreviewSurface() {
if (previewView != null && previewView.getSurfaceTexture() != null) {
android.graphics.SurfaceTexture st = previewView.getSurfaceTexture();
st.setDefaultBufferSize(TARGET_WIDTH, TARGET_HEIGHT);
previewSurface = new Surface(st);
}
}
private void openCamera() {
try {
cameraManager.openCamera(frontCameraId, new CameraDevice.StateCallback() {
@Override
public void onOpened(@NonNull CameraDevice camera) {
cameraDevice = camera;
createCaptureSession();
}
@Override
public void onDisconnected(@NonNull CameraDevice camera) {
camera.close();
cameraDevice = null;
}
@Override
public void onError(@NonNull CameraDevice camera, int error) {
Log.e(TAG, "Camera error: " + error);
camera.close();
cameraDevice = null;
if (callback != null) callback.onError("相机打开失败");
}
}, backgroundHandler);
} catch (CameraAccessException e) {
Log.e(TAG, "Open camera error: " + e.getMessage());
if (callback != null) callback.onError("相机打开失败: " + e.getMessage());
} catch (SecurityException e) {
Log.e(TAG, "Camera permission denied: " + e.getMessage());
if (callback != null) callback.onError("需要相机权限");
}
}
private void createCaptureSession() {
if (cameraDevice == null || imageReader == null || !isRunning.get()) return;
try {
java.util.List<Surface> surfaces = new java.util.ArrayList<>();
surfaces.add(imageReader.getSurface());
if (previewSurface != null) {
surfaces.add(previewSurface);
}
cameraDevice.createCaptureSession(
surfaces,
new CameraCaptureSession.StateCallback() {
@Override
public void onConfigured(@NonNull CameraCaptureSession session) {
captureSession = session;
isCapturing.set(true);
startPreview();
scheduleNextCapture();
}
@Override
public void onConfigureFailed(@NonNull CameraCaptureSession session) {
Log.e(TAG, "Capture session configure failed");
if (callback != null) callback.onError("相机配置失败");
}
},
backgroundHandler
);
} catch (CameraAccessException e) {
Log.e(TAG, "Create session error: " + e.getMessage());
}
}
private void startPreview() {
if (captureSession == null || cameraDevice == null || previewSurface == null) return;
try {
CaptureRequest.Builder builder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
builder.addTarget(previewSurface);
builder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE);
captureSession.setRepeatingRequest(builder.build(), null, backgroundHandler);
} catch (CameraAccessException e) {
Log.e(TAG, "Start preview error: " + e.getMessage());
}
}
private void scheduleNextCapture() {
if (!isCapturing.get() || !isRunning.get() || captureSession == null || cameraDevice == null) return;
try {
CaptureRequest.Builder builder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
builder.addTarget(imageReader.getSurface());
builder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE);
CaptureRequest request = builder.build();
captureSession.capture(request, new CameraCaptureSession.CaptureCallback() {
@Override
public void onCaptureCompleted(@NonNull CameraCaptureSession session,
@NonNull android.hardware.camera2.CaptureRequest request,
@NonNull android.hardware.camera2.TotalCaptureResult result) {
if (isRunning.get()) {
backgroundHandler.postDelayed(CameraFrameCapture.this::scheduleNextCapture, CAPTURE_INTERVAL_MS);
}
}
}, backgroundHandler);
} catch (CameraAccessException e) {
Log.e(TAG, "Capture error: " + e.getMessage());
}
}
private byte[] imageToJpeg(Image image) {
Image.Plane[] planes = image.getPlanes();
if (planes.length == 0) return null;
ByteBuffer buffer = planes[0].getBuffer();
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
return data;
}
public void stop() {
isRunning.set(false);
isCapturing.set(false);
try {
if (captureSession != null) {
captureSession.close();
captureSession = null;
}
if (cameraDevice != null) {
cameraDevice.close();
cameraDevice = null;
}
if (imageReader != null) {
imageReader.close();
imageReader = null;
}
if (previewSurface != null) {
previewSurface.release();
previewSurface = null;
}
} catch (Exception e) {
Log.e(TAG, "Stop error: " + e.getMessage());
}
if (backgroundThread != null && backgroundThread.isAlive()) {
backgroundThread.quitSafely();
try {
backgroundThread.join(500);
} catch (InterruptedException ignored) {
}
backgroundThread = null;
}
backgroundHandler = null;
}
public boolean isRunning() {
return isRunning.get();
}
public interface FrameCallback {
void onFrame(byte[] jpegData);
void onError(String message);
}
}
@@ -0,0 +1,83 @@
package ai.guiji.duix.test.net;
import android.util.Log;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import ai.guiji.duix.test.App;
import okhttp3.Call;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
/**
* 同步下载小文件,不要再UI线程里面直接调用
*/
public class SyncDownloadFile {
private String url;
private String path;
private Callback callback;
public SyncDownloadFile(String url, String path, Callback callback) {
this.url = url;
this.path = path;
this.callback = callback;
}
public boolean download() {
try {
Request request = new Request.Builder()
.url(url)
.build();
Call call = App.getOkHttpClient().newCall(request);
Response response = call.execute();
if (response.code() == 200) {
ResponseBody body = response.body();
if (body != null) {
long contentLength = body.contentLength();
InputStream is = body.byteStream();
File tmpFile = new File(path + ".tmp");
File parent = tmpFile.getParentFile();
if (parent != null && !parent.exists()) {
if (!parent.mkdirs()) {
return false;
}
}
if (tmpFile.exists()) {
tmpFile.delete();
}
FileOutputStream fileOutputStream = new FileOutputStream(tmpFile);
long downloadLength = 0;
int len;
byte[] data = new byte[1024];
while ((len = is.read(data)) != -1) {
fileOutputStream.write(data, 0, len);
downloadLength += len;
if (callback != null){
callback.onProgress((int) (downloadLength * 100 / contentLength));
}
}
fileOutputStream.flush();
is.close();
fileOutputStream.close();
File target = new File(path);
if (tmpFile.renameTo(target)) {
return true;
}
}
}
} catch (Exception e) {
Log.e("123", "SyncDownloadFile error: " + e.getMessage());
}
return false;
}
public interface Callback {
void onProgress(int progress);
}
}
@@ -0,0 +1,311 @@
package ai.guiji.duix.test.realtime;
import android.util.Base64;
import android.util.Log;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
/**
* Qwen-Omni-Realtime WebSocket 客户端
* 接入 qwen3-omni-flash-realtime-2025-12-01 模型
*/
public class OmniRealtimeClient {
private static final String TAG = "OmniRealtimeClient";
// Demo 配置(硬编码)
private static final String API_KEY = "sk-7c32083ace99472e97209e611b9ecbb1";
private static final String WS_URL = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime?model=qwen3-omni-flash-realtime-2025-12-01";
private final OkHttpClient okHttpClient;
private WebSocket webSocket;
private final Callback callback;
private final AtomicBoolean isConnected = new AtomicBoolean(false);
private final AtomicBoolean userSpeaking = new AtomicBoolean(false);
public OmniRealtimeClient(Callback callback) {
this.callback = callback;
this.okHttpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.MINUTES)
.writeTimeout(30, TimeUnit.SECONDS)
.pingInterval(30, TimeUnit.SECONDS)
.build();
}
public void connect() {
if (isConnected.get()) {
Log.w(TAG, "Already connected");
return;
}
Request request = new Request.Builder()
.url(WS_URL)
.addHeader("Authorization", "Bearer " + API_KEY)
.build();
webSocket = okHttpClient.newWebSocket(request, new WebSocketListener() {
@Override
public void onOpen(WebSocket webSocket, Response response) {
Log.i(TAG, "WebSocket connected");
isConnected.set(true);
sendSessionUpdate();
if (callback != null) {
callback.onConnected();
}
}
@Override
public void onMessage(WebSocket webSocket, String text) {
handleMessage(text);
}
@Override
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
Log.e(TAG, "WebSocket error: " + t.getMessage());
isConnected.set(false);
if (callback != null) {
callback.onError("连接失败: " + t.getMessage());
}
}
@Override
public void onClosing(WebSocket webSocket, int code, String reason) {
Log.i(TAG, "WebSocket closing: " + code + " " + reason);
}
@Override
public void onClosed(WebSocket webSocket, int code, String reason) {
Log.i(TAG, "WebSocket closed: " + code + " " + reason);
isConnected.set(false);
if (callback != null) {
callback.onDisconnected();
}
}
});
}
private void sendSessionUpdate() {
try {
JSONObject session = new JSONObject();
session.put("modalities", new org.json.JSONArray().put("text").put("audio"));
session.put("voice", "Ethan");
session.put("input_audio_format", "pcm");
session.put("output_audio_format", "pcm");
session.put("instructions", "你是一名面向中国老年群体的心理陪伴 AI。你的职责是为老人提供温和、耐心、尊重、稳定的情感陪伴、情绪支持、回忆唤起、生活关怀和风险识别服务。你不是医生,也不是心理治疗师,不能替代专业诊疗,但应在必要时建议联系家属、社区、医生或急救资源。\n" +
"\n" +
"请始终遵循以下原则:\n" +
"\n" +
"先共情,再回应问题;\n" +
"\n" +
"先接住情绪,再给建议;\n" +
"\n" +
"使用简短、朴素、生活化的中文,始终用“您”称呼;\n" +
"\n" +
"不说教、不训诫、不否定、不敷衍;\n" +
"\n" +
"不把老人当小孩,不制造虚假亲密;\n" +
"\n" +
"一次不提供超过两个建议;\n" +
"\n" +
"鼓励老人表达感受,可适度引导回忆人生经历、兴趣、家庭和过往成就;\n" +
"\n" +
"对孤独、丧偶、被忽视、无价值感、失眠、焦虑、怕拖累家人等议题保持高度敏感;\n" +
"\n" +
"若出现自杀、自伤、重度绝望、幻觉妄想、意识混乱、虐待、严重医疗异常等风险,必须立即建议联系真人帮助和专业机构;\n" +
"\n" +
"不编造医疗知识、政策信息、机构电话,不确定时明确说明。\n" +
"\n" +
"你的回复风格应温和、平稳、尊重、有耐心。每次回答尽量包含:一句共情、一句理解或复述、一句温和追问或一个微小建议。核心目标是让老人感到:我被听见了,我被尊重了,有人愿意陪我。");
// 服务端 VAD 模式
JSONObject turnDetection = new JSONObject();
turnDetection.put("type", "server_vad");
turnDetection.put("threshold", 0.5);
turnDetection.put("silence_duration_ms", 800);
session.put("turn_detection", turnDetection);
JSONObject event = new JSONObject();
event.put("event_id", "event_" + System.currentTimeMillis());
event.put("type", "session.update");
event.put("session", session);
sendEvent(event);
} catch (JSONException e) {
Log.e(TAG, "Failed to build session.update: " + e.getMessage());
}
}
private void handleMessage(String text) {
try {
JSONObject event = new JSONObject(text);
String type = event.optString("type", "");
switch (type) {
case "session.created":
case "session.updated":
Log.d(TAG, "Session: " + type);
break;
case "input_audio_buffer.speech_started":
Log.d(TAG, "User started speaking");
userSpeaking.set(true);
if (callback != null) {
callback.onUserSpeechStarted();
}
break;
case "input_audio_buffer.speech_stopped":
Log.d(TAG, "User stopped speaking");
userSpeaking.set(false);
if (callback != null) {
callback.onUserSpeechStopped();
}
break;
case "conversation.item.input_audio_transcription.completed":
String userTranscript = event.optString("transcript", "");
Log.d(TAG, "User said: " + userTranscript);
if (callback != null) {
callback.onUserTranscript(userTranscript);
}
break;
case "response.audio_transcript.delta":
String textDelta = event.optString("delta", "");
if (callback != null && !textDelta.isEmpty()) {
callback.onAssistantTextDelta(textDelta);
}
break;
case "response.audio_transcript.done":
String fullTranscript = event.optString("transcript", "");
Log.d(TAG, "Assistant said: " + fullTranscript);
if (callback != null) {
callback.onAssistantTranscriptDone(fullTranscript);
}
break;
case "response.audio.delta":
String audioB64 = event.optString("delta", "");
if (!audioB64.isEmpty() && callback != null && !userSpeaking.get()) {
byte[] audioData = Base64.decode(audioB64, Base64.NO_WRAP);
callback.onAssistantAudioDelta(audioData);
}
break;
case "response.audio.done":
Log.d(TAG, "Audio generation done");
if (callback != null) {
callback.onAssistantAudioDone();
}
break;
case "response.done":
Log.d(TAG, "Response done");
if (callback != null) {
callback.onResponseDone();
}
break;
case "error":
String errMsg = event.optJSONObject("error") != null
? event.optJSONObject("error").optString("message", "Unknown error")
: "Unknown error";
Log.e(TAG, "Server error: " + errMsg);
if (callback != null) {
callback.onError(errMsg);
}
break;
default:
Log.v(TAG, "Unhandled event: " + type);
}
} catch (JSONException e) {
Log.e(TAG, "Parse message error: " + e.getMessage());
}
}
public void appendAudio(byte[] pcmData) {
if (!isConnected.get() || webSocket == null) return;
try {
String audioB64 = Base64.encodeToString(pcmData, Base64.NO_WRAP);
JSONObject event = new JSONObject();
event.put("event_id", "event_" + System.currentTimeMillis());
event.put("type", "input_audio_buffer.append");
event.put("audio", audioB64);
sendEvent(event);
} catch (JSONException e) {
Log.e(TAG, "Failed to append audio: " + e.getMessage());
}
}
/**
* 向图像缓冲区追加图像数据(用于视频通话)
* 图片格式: JPG/JPEG,推荐 480P 或 720P,单张不超过 500KB
*/
public void appendImage(byte[] jpegData) {
if (!isConnected.get() || webSocket == null) return;
try {
String imageB64 = Base64.encodeToString(jpegData, Base64.NO_WRAP);
JSONObject event = new JSONObject();
event.put("event_id", "event_" + System.currentTimeMillis());
event.put("type", "input_image_buffer.append");
event.put("image", imageB64);
sendEvent(event);
} catch (JSONException e) {
Log.e(TAG, "Failed to append image: " + e.getMessage());
}
}
private void sendEvent(JSONObject event) {
if (webSocket != null && isConnected.get()) {
webSocket.send(event.toString());
}
}
public void disconnect() {
if (webSocket != null) {
webSocket.close(1000, "bye");
webSocket = null;
}
isConnected.set(false);
}
public boolean isConnected() {
return isConnected.get();
}
public interface Callback {
void onConnected();
void onDisconnected();
void onError(String message);
void onUserSpeechStarted();
void onUserSpeechStopped();
void onUserTranscript(String transcript);
void onAssistantTextDelta(String delta);
void onAssistantTranscriptDone(String transcript);
void onAssistantAudioDelta(byte[] pcmData);
void onAssistantAudioDone();
void onResponseDone();
}
}
@@ -0,0 +1,24 @@
package ai.guiji.duix.test.render;
import ai.guiji.duix.sdk.client.bean.ImageFrame;
import ai.guiji.duix.sdk.client.render.RenderSink;
public class DebugSink implements RenderSink {
VideoFrameCallback callback;
public DebugSink(VideoFrameCallback callback){
this.callback = callback;
}
@Override
public void onVideoFrame(ImageFrame imageFrame) {
if (callback != null){
callback.onVideoFrame(imageFrame);
}
}
public interface VideoFrameCallback{
void onVideoFrame(ImageFrame imageFrame);
}
}
@@ -0,0 +1,216 @@
package ai.guiji.duix.test.service;
import android.content.Context;
import android.content.res.AssetManager;
import android.os.Environment;
import android.util.Log;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import ai.guiji.duix.sdk.client.util.MD5Util;
import ai.guiji.duix.test.net.SyncDownloadFile;
import ai.guiji.duix.test.util.StringUtils;
import ai.guiji.duix.test.util.ZipUtil;
public class StorageService {
public interface Callback {
void onDownloadProgress(int progress);
void onUnzipProgress(int progress);
void onComplete(String path);
void onError(String msg);
}
/**
* 下载zip文件并解压
*
* @param context
* @param url
* @param targetPath
* @param uuid
* @param callback
*/
public static void downloadAndUnzip(Context context, String url, String targetPath, String uuid, Callback callback, boolean deleteZip) {
Executor executor = Executors.newSingleThreadExecutor();
executor.execute(() -> {
File cacheDir = context.getExternalCacheDir();
if (!cacheDir.exists()) {
cacheDir.mkdirs();
}
File zipFile = new File(cacheDir, MD5Util.string2MD5(url));
boolean result = true;
if (!zipFile.exists()) {
Log.d("123", "zip not found, try download.");
result = new SyncDownloadFile(url, zipFile.getAbsolutePath(), callback::onDownloadProgress).download();
Log.d("123", "download file success.");
} else {
Log.d("123", "found cache zip file.");
}
if (result) {
Log.e("123", "try unzip file.");
File targetDirFile = new File(targetPath);
if (targetDirFile.exists()) {
Log.e("123", "delete old files.");
deleteContents(targetDirFile);
}
// 拿到目标路径的父级
File targetParentDir = new File(targetPath).getParentFile();
if (!targetParentDir.exists()) {
targetParentDir.mkdirs();
}
result = ZipUtil.unzip(zipFile.getAbsolutePath(), targetParentDir.getAbsolutePath(), callback::onUnzipProgress);
if (result) {
Log.d("123", "unzip file complete.");
// 这里时候targetDirFile应该是存在的
if (targetDirFile.exists()) {
File uuidFile = new File(targetDirFile, "uuid");
try {
OutputStream out = new FileOutputStream(uuidFile);
byte[] uuidBytes = uuid.getBytes();
out.write(uuidBytes, 0, uuidBytes.length);
out.flush();
out.close();
if (deleteZip && zipFile.exists()){
zipFile.delete();
}
callback.onComplete(targetPath);
} catch (Exception e) {
callback.onError("touch uuid file error!");
}
} else {
callback.onError("unzip dir not found!");
}
} else {
if (zipFile.exists()){
zipFile.delete();
}
callback.onError("unzip file error!");
}
} else {
callback.onError("zip file download error");
}
});
}
/**
* 从assets拷贝文件到sdcard
*
* @param context
* @param sourcePath 在assets目录的路径
* @param targetPath 在sd卡的路径
*/
public static void unpack(Context context, String sourcePath, final String targetPath, final Callback callback) {
Executor executor = Executors.newSingleThreadExecutor();
executor.execute(() -> {
try {
final String outputPath = sync(context, sourcePath, targetPath);
callback.onComplete(outputPath);
} catch (final IOException e) {
callback.onError("拷贝文件异常: " + e);
}
});
}
public static boolean deleteContents(File dir) {
File[] files = dir.listFiles();
boolean success = true;
if (files != null) {
for (File file : files) {
if (file.isDirectory()) {
success &= deleteContents(file);
}
if (!file.delete()) {
success = false;
}
}
}
return success;
}
private static String sync(Context context, String sourcePath, String targetPath) throws IOException {
AssetManager assetManager = context.getAssets();
File externalFilesDir = context.getExternalFilesDir(null);
if (externalFilesDir == null) {
throw new IOException("cannot get external files dir, "
+ "external storage state is " + Environment.getExternalStorageState());
}
File targetDir = new File(externalFilesDir, targetPath);
String resultPath = new File(targetDir, sourcePath).getAbsolutePath();
String sourceUUID = readLine(assetManager.open(sourcePath + "/uuid"));
try {
String targetUUID = readLine(new FileInputStream(new File(targetDir, sourcePath + "/uuid")));
if (targetUUID.equals(sourceUUID)) return resultPath;
} catch (FileNotFoundException e) {
// ignore
}
deleteContents(targetDir);
copyAssets(assetManager, sourcePath, targetDir);
// Copy uuid
copyFile(assetManager, sourcePath + "/uuid", targetDir);
return resultPath;
}
private static String readLine(InputStream is) throws IOException {
return new BufferedReader(new InputStreamReader(is)).readLine();
}
private static void copyAssets(AssetManager assetManager, String path, File outPath) throws IOException {
String[] assets = assetManager.list(path);
if (assets == null) {
return;
}
if (assets.length == 0) {
if (!path.endsWith("uuid"))
copyFile(assetManager, path, outPath);
} else {
File dir = new File(outPath, path);
if (!dir.exists()) {
Log.d("123", "Making directory " + dir.getAbsolutePath());
if (!dir.mkdirs()) {
Log.d("123", "Failed to create directory " + dir.getAbsolutePath());
}
}
for (String asset : assets) {
copyAssets(assetManager, path + "/" + asset, outPath);
}
}
}
private static void copyFile(AssetManager assetManager, String fileName, File outPath) throws IOException {
InputStream in;
Log.d("123", "Copy " + fileName + " to " + outPath);
in = assetManager.open(fileName);
OutputStream out = new FileOutputStream(outPath + "/" + fileName);
byte[] buffer = new byte[4000];
int read;
while ((read = in.read(buffer)) != -1) {
out.write(buffer, 0, read);
}
in.close();
out.close();
}
}
@@ -0,0 +1,119 @@
package ai.guiji.duix.test.ui.activity;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Message;
import android.util.Log;
import android.view.WindowManager;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import java.util.ArrayList;
import java.util.List;
public abstract class BaseActivity extends AppCompatActivity implements Handler.Callback {
public final String TAG = getClass().getName();
protected BaseActivity mContext;
protected Handler mHandler;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mContext = this;
HandlerThread mHandlerThread = new HandlerThread(TAG);
mHandlerThread.start();
mHandler = new Handler(mHandlerThread.getLooper(), this);
}
@Override
protected void onDestroy() {
super.onDestroy();
Log.i(TAG, "onDestroy");
if (mHandler != null && mHandler.getLooper() != null) {
mHandler.getLooper().quit();
}
}
@Override
public boolean handleMessage(@NonNull Message msg) {
onMessage(msg);
return false;
}
// try abstract
protected void onMessage(@NonNull Message msg) {
}
protected void keepScreenOn() {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
private String[] mRequestPermissions;
private int mRequestPermissionCode;
ActivityResultLauncher<String[]> permissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(),
result -> {
boolean hasDeny = false;
for (String permission : mRequestPermissions) {
if (null == permission) {
continue;
}
if (ContextCompat.checkSelfPermission(mContext, permission) !=
PackageManager.PERMISSION_GRANTED) {
hasDeny = true;
}
}
if (hasDeny) {
permissionsGet(false, mRequestPermissionCode);
} else {
permissionsGet(true, mRequestPermissionCode);
}
});
//申请权限
public void requestPermission(String[] permissions, int code) {
if (null == permissions) {
permissionsGet(true, code);
return;
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
permissionsGet(true, code);
return;
}
mRequestPermissions = permissions;
mRequestPermissionCode = code;
List<String> requestPermissions = new ArrayList<>();
for (String permission : permissions) {
if (ContextCompat.checkSelfPermission(mContext, permission) !=
PackageManager.PERMISSION_GRANTED) {
requestPermissions.add(permission);
}
}
if (0 != requestPermissions.size()) {
String[] permissionArray = new String[requestPermissions.size()];
for (int i = 0; i < requestPermissions.size(); i++) {
permissionArray[i] = requestPermissions.get(i);
}
permissionLauncher.launch(permissionArray);
} else {
permissionsGet(true, mRequestPermissionCode);
}
}
//申请权限回调
public void permissionsGet(boolean get, int code) {
}
}
@@ -0,0 +1,490 @@
package ai.guiji.duix.test.ui.activity
import ai.guiji.duix.sdk.client.Constant
import ai.guiji.duix.sdk.client.DUIX
import ai.guiji.duix.sdk.client.loader.ModelInfo
import ai.guiji.duix.sdk.client.render.DUIXRenderer
import ai.guiji.duix.sdk.client.thread.RenderThread
import ai.guiji.duix.test.R
import ai.guiji.duix.test.audio.AudioResampler
import ai.guiji.duix.test.audio.StreamingAudioRecorder
import ai.guiji.duix.test.camera.CameraFrameCapture
import ai.guiji.duix.test.databinding.ActivityCallBinding
import ai.guiji.duix.test.realtime.OmniRealtimeClient
import ai.guiji.duix.test.ui.adapter.MotionAdapter
import ai.guiji.duix.test.ui.dialog.AudioRecordDialog
import ai.guiji.duix.test.util.StringUtils
import android.Manifest
import android.annotation.SuppressLint
import android.opengl.GLSurfaceView
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.TextUtils
import android.util.Log
import android.view.View
import android.widget.CompoundButton
import android.widget.Toast
import androidx.core.content.ContextCompat
import com.bumptech.glide.Glide
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
class CallActivity : BaseActivity() {
companion object {
const val GL_CONTEXT_VERSION = 2
}
private var modelUrl = ""
private var debug = false
private var mMessage = ""
@SuppressLint("SetTextI18n")
private fun applyMessage(msg: String){
if (debug){
runOnUiThread {
binding.tvDebug.visibility = View.VISIBLE
if (mMessage.length > 10000){
mMessage = ""
}
mMessage = "${StringUtils.dateToStringMS4()} $msg\n$mMessage"
binding.tvDebug.text = mMessage
}
}
}
private lateinit var binding: ActivityCallBinding
private var duix: DUIX? = null
private var mDUIXRender: DUIXRenderer? = null
private var mModelInfo: ModelInfo?=null // 加载的模型信息
// AI 实时对话
private var omniClient: OmniRealtimeClient? = null
private var streamingRecorder: StreamingAudioRecorder? = null
private var cameraFrameCapture: CameraFrameCapture? = null
private var isAIConversationActive = false
private var isCameraEnabled = false
private val mainHandler = Handler(Looper.getMainLooper())
private var assistantTextBuffer = StringBuilder()
private var hasStartedPushForResponse = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
keepScreenOn()
// val audioManager = mContext.getSystemService(AUDIO_SERVICE) as AudioManager
// audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
// audioManager.isSpeakerphoneOn = true
binding = ActivityCallBinding.inflate(layoutInflater)
setContentView(binding.root)
modelUrl = intent.getStringExtra("modelUrl") ?: ""
debug = intent.getBooleanExtra("debug", false)
Glide.with(mContext).load("file:///android_asset/bg/bg1.png").into(binding.ivBg)
binding.glTextureView.setEGLContextClientVersion(GL_CONTEXT_VERSION)
binding.glTextureView.setEGLConfigChooser(8, 8, 8, 8, 16, 0)
// binding.glTextureView.preserveEGLContextOnPause = true
binding.glTextureView.isOpaque = false
binding.switchMute.setOnCheckedChangeListener(object : CompoundButton.OnCheckedChangeListener {
override fun onCheckedChanged(
buttonView: CompoundButton?,
isChecked: Boolean,
) {
if (isChecked) {
duix?.setVolume(0.0F)
} else {
duix?.setVolume(1.0F)
}
}
})
binding.btnRecord.setOnClickListener {
requestPermission(arrayOf(Manifest.permission.RECORD_AUDIO), 1)
}
binding.btnPlayPCM.setOnClickListener {
applyMessage("start play pcm")
playPCMStream()
}
binding.btnPlayWAV.setOnClickListener {
applyMessage("start play wav")
playWAVFile()
}
binding.btnRandomMotion.setOnClickListener {
applyMessage("start random motion")
duix?.startRandomMotion(true)
}
binding.btnAIConversation.setOnClickListener {
requestPermission(arrayOf(Manifest.permission.RECORD_AUDIO), 2)
}
binding.btnCameraToggle.setOnClickListener {
if (isCameraEnabled) {
toggleCamera()
} else {
requestPermission(arrayOf(Manifest.permission.CAMERA), 3)
}
}
mDUIXRender =
DUIXRenderer(
mContext,
binding.glTextureView
)
binding.glTextureView.setRenderer(mDUIXRender)
binding.glTextureView.renderMode =
GLSurfaceView.RENDERMODE_WHEN_DIRTY // 一定要在设置完Render之后再调用
duix = DUIX(mContext, modelUrl, mDUIXRender) { event, msg, info ->
when (event) {
Constant.CALLBACK_EVENT_INIT_READY -> {
mModelInfo = info as ModelInfo
Log.i(TAG, "CALLBACK_EVENT_INIT_READY: $mModelInfo")
initOk()
}
Constant.CALLBACK_EVENT_INIT_ERROR -> {
runOnUiThread {
applyMessage("init error: $msg")
Log.e(TAG, "CALLBACK_EVENT_INIT_ERROR: $msg")
Toast.makeText(mContext, "Initialization exception: $msg", Toast.LENGTH_SHORT).show()
}
}
Constant.CALLBACK_EVENT_AUDIO_PLAY_START -> {
applyMessage("callback audio play start")
Log.i(TAG, "CALLBACK_EVENT_AUDIO_PLAY_START")
}
Constant.CALLBACK_EVENT_AUDIO_PLAY_END -> {
applyMessage("callback audio play end")
Log.i(TAG, "CALLBACK_EVENT_PLAY_END")
}
Constant.CALLBACK_EVENT_AUDIO_PLAY_ERROR -> {
applyMessage("callback audio play error: $msg")
Log.e(TAG, "CALLBACK_EVENT_PLAY_ERROR: $msg")
}
Constant.CALLBACK_EVENT_MOTION_START -> {
applyMessage("callback motion play start")
Log.e(TAG, "CALLBACK_EVENT_MOTION_START")
}
Constant.CALLBACK_EVENT_MOTION_END -> {
applyMessage("callback motion play end")
Log.e(TAG, "CALLBACK_EVENT_MOTION_END")
}
}
}
// Rendering status callback
// duix?.setReporter(object : RenderThread.Reporter {
// override fun onRenderStat(
// resultCode: Int,
// isLip: Boolean,
// useTime: Long,
// ) {
//
// }
// })
applyMessage("start init")
duix?.init()
}
private fun initOk() {
Log.i(TAG, "init ok")
applyMessage("init ok")
runOnUiThread {
binding.btnRecord.isEnabled = true
binding.btnPlayPCM.isEnabled = true
binding.btnPlayWAV.isEnabled = true
binding.switchMute.isEnabled = true
binding.btnAIConversation.isEnabled = true
if (debug) {
binding.btnPlayPCM.visibility = View.VISIBLE
binding.btnPlayWAV.visibility = View.VISIBLE
}
mModelInfo?.let { modelInfo ->
if (modelInfo.motionRegions.isNotEmpty()) {
val names = ArrayList<String>()
for (motion in modelInfo.motionRegions){
if (!TextUtils.isEmpty(motion.name) && "unknown" != motion.name){
names.add(motion.name)
}
}
// Named action regions
if (names.isNotEmpty()){
val motionAdapter = MotionAdapter(names, object : MotionAdapter.Callback{
override fun onClick(name: String, now: Boolean) {
applyMessage("start [${name}] motion")
duix?.startMotion(name, now)
}
})
binding.rvMotion.adapter = motionAdapter
}
binding.btnRandomMotion.visibility = View.VISIBLE
binding.tvMotionTips.visibility = View.VISIBLE
}
}
}
}
override fun onDestroy() {
super.onDestroy()
stopAIConversation()
duix?.release()
}
private fun playPCMStream(){
val thread = Thread {
duix?.startPush()
val inputStream = assets.open("pcm/2.pcm")
val buffer = ByteArray(320)
var length: Int
while (inputStream.read(buffer).also { length = it } > 0){
val data = buffer.copyOfRange(0, length)
duix?.pushPcm(data)
}
duix?.stopPush()
inputStream.close()
}
thread.start()
}
private fun playWAVFile(){
val thread = Thread {
val wavName = "1.wav"
val wavFile = File(mContext.externalCacheDir, wavName)
if (!wavFile.exists()){
// copy assets -> sd card
val inputStream = assets.open("wav/$wavName")
if (!mContext.externalCacheDir!!.exists()){
mContext.externalCacheDir!!.mkdirs()
}
val out = FileOutputStream(wavFile)
val buffer = ByteArray(1024)
var length: Int
while ((inputStream.read(buffer).also { length = it }) > 0) {
out.write(buffer, 0, length)
}
out.close()
inputStream.close()
}
duix?.playAudio(wavFile.absolutePath)
}
thread.start()
}
override fun permissionsGet(get: Boolean, code: Int) {
super.permissionsGet(get, code)
when (code) {
1 -> if (get) showRecordDialog() else Toast.makeText(mContext, R.string.need_permission_continue, Toast.LENGTH_SHORT).show()
2 -> if (get) toggleAIConversation() else Toast.makeText(mContext, R.string.need_permission_continue, Toast.LENGTH_SHORT).show()
3 -> if (get) toggleCamera() else Toast.makeText(mContext, R.string.need_permission_continue, Toast.LENGTH_SHORT).show()
}
}
private fun toggleAIConversation() {
if (isAIConversationActive) {
stopAIConversation()
} else {
startAIConversation()
}
}
private fun startAIConversation() {
if (duix?.isReady() != true) {
Toast.makeText(mContext, "数字人未就绪", Toast.LENGTH_SHORT).show()
return
}
applyMessage("AI对话: 连接中...")
binding.tvSubtitle.visibility = View.VISIBLE
binding.tvSubtitle.text = "正在连接..."
binding.btnAIConversation.text = getString(R.string.ai_fab_stop)
omniClient = OmniRealtimeClient(object : OmniRealtimeClient.Callback {
override fun onConnected() {
mainHandler.post {
binding.tvSubtitle.text = "已连接,请对着麦克风说话"
binding.btnCameraToggle.visibility = View.VISIBLE
binding.btnCameraToggle.background = ContextCompat.getDrawable(mContext, R.drawable.shape_circle_camera_off)
binding.btnCameraToggle.contentDescription = getString(R.string.camera_off)
isCameraEnabled = false
applyMessage("AI对话: 已连接")
streamingRecorder = StreamingAudioRecorder(mContext, object : StreamingAudioRecorder.StreamingCallback {
override fun onAudioData(pcmData: ByteArray) {
omniClient?.appendAudio(pcmData)
}
override fun onError(message: String) {
mainHandler.post {
Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show()
}
}
})
streamingRecorder?.start()
}
}
override fun onDisconnected() {
mainHandler.post { stopAIConversation() }
}
override fun onError(message: String) {
mainHandler.post {
Toast.makeText(mContext, "AI错误: $message", Toast.LENGTH_SHORT).show()
binding.tvSubtitle.text = "错误: $message"
}
}
override fun onUserSpeechStarted() {
mainHandler.post {
binding.tvSubtitle.text = "正在聆听..."
}
}
override fun onUserSpeechStopped() {
mainHandler.post {
binding.tvSubtitle.text = "思考中..."
}
}
override fun onUserTranscript(transcript: String) {
mainHandler.post {
binding.tvSubtitle.text = "您: $transcript"
}
}
override fun onAssistantTextDelta(delta: String) {
mainHandler.post {
assistantTextBuffer.append(delta)
binding.tvSubtitle.text = assistantTextBuffer.toString()
}
}
override fun onAssistantTranscriptDone(transcript: String) {
mainHandler.post {
assistantTextBuffer.clear()
assistantTextBuffer.append(transcript)
binding.tvSubtitle.text = transcript
}
}
override fun onAssistantAudioDelta(pcmData: ByteArray) {
if (!hasStartedPushForResponse) {
hasStartedPushForResponse = true
duix?.startPush()
}
val resampled = AudioResampler.resample24kTo16k(pcmData)
if (resampled.isNotEmpty()) {
duix?.pushPcm(resampled)
}
}
override fun onAssistantAudioDone() {
if (hasStartedPushForResponse) {
duix?.stopPush()
hasStartedPushForResponse = false
}
}
override fun onResponseDone() {
mainHandler.post {
if (hasStartedPushForResponse) {
duix?.stopPush()
hasStartedPushForResponse = false
}
assistantTextBuffer.clear()
}
}
})
isAIConversationActive = true
omniClient?.connect()
}
private fun stopAIConversation() {
isAIConversationActive = false
stopCamera()
streamingRecorder?.release()
streamingRecorder = null
omniClient?.disconnect()
omniClient = null
duix?.stopAudio()
assistantTextBuffer.clear()
mainHandler.post {
if (!isFinishing) {
binding.btnAIConversation.text = getString(R.string.ai_fab_start)
binding.btnCameraToggle.visibility = View.GONE
binding.tvSubtitle.visibility = View.GONE
binding.tvSubtitle.text = ""
applyMessage("AI对话: 已结束")
}
}
}
private fun toggleCamera() {
if (!isAIConversationActive || omniClient == null) return
isCameraEnabled = !isCameraEnabled
if (isCameraEnabled) {
binding.cameraPreview.visibility = View.VISIBLE
cameraFrameCapture = CameraFrameCapture(mContext, object : CameraFrameCapture.FrameCallback {
override fun onFrame(jpegData: ByteArray) {
omniClient?.appendImage(jpegData)
}
override fun onError(message: String) {
mainHandler.post {
Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show()
stopCamera()
}
}
}, binding.cameraPreview)
cameraFrameCapture?.start()
binding.btnCameraToggle.background = ContextCompat.getDrawable(mContext, R.drawable.shape_circle_camera_on)
binding.btnCameraToggle.contentDescription = getString(R.string.camera_on)
} else {
stopCamera()
binding.btnCameraToggle.background = ContextCompat.getDrawable(mContext, R.drawable.shape_circle_camera_off)
binding.btnCameraToggle.contentDescription = getString(R.string.camera_off)
}
}
private fun stopCamera() {
cameraFrameCapture?.stop()
cameraFrameCapture = null
isCameraEnabled = false
binding.cameraPreview.visibility = View.GONE
}
private fun showRecordDialog(){
val audioRecordDialog = AudioRecordDialog(mContext, object : AudioRecordDialog.Listener{
override fun onFinish(path: String) {
val thread = Thread {
duix?.startPush()
val inputStream = FileInputStream(path)
val buffer = ByteArray(320)
var length: Int
while (inputStream.read(buffer).also { length = it } > 0){
val data = buffer.copyOfRange(0, length)
duix?.pushPcm(data)
}
duix?.stopPush()
inputStream.close()
}
thread.start()
}
})
audioRecordDialog.show()
}
}
@@ -0,0 +1,203 @@
package ai.guiji.duix.test.ui.activity
import ai.guiji.duix.sdk.client.BuildConfig
import ai.guiji.duix.sdk.client.VirtualModelUtil
import ai.guiji.duix.test.R
import ai.guiji.duix.test.databinding.ActivityMainBinding
import ai.guiji.duix.test.ui.dialog.LoadingDialog
import ai.guiji.duix.test.ui.dialog.ModelSelectorDialog
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.text.TextUtils
import android.widget.Toast
import java.io.File
class MainActivity : BaseActivity() {
private lateinit var binding: ActivityMainBinding
private var mLoadingDialog: LoadingDialog?=null
private var mLastProgress = 0
val models = arrayListOf(
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/bendi3_20240518.zip",
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/airuike_20240409.zip",
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/675429759852613_7f8d9388a4213080b1820b83dd057cfb_optim_m80.zip",
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/674402003804229_f6e86fb375c4f1f1b82b24f7ee4e7cb4_optim_m80.zip",
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/674400178376773_3925e756433c5a9caa9b9d54147ae4ab_optim_m80.zip",
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/674397294927941_6e297e18a4bdbe35c07a6ae48a1f021f_optim_m80.zip",
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/674393494597701_f49fcf68f5afdb241d516db8a7d88a7b_optim_m80.zip",
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/651705983152197_ccf3256b2449c76e77f94276dffcb293_optim_m80.zip",
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/627306542239813_1871244b5e6912efc636ba31ea4c5c6d_optim_m80.zip",
)
private var mBaseConfigUrl = ""
private var mModelUrl = ""
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.tvSdkVersion.text = "SDK Version: ${BuildConfig.VERSION_NAME}"
binding.btnMoreModel.setOnClickListener {
val modelSelectorDialog = ModelSelectorDialog(mContext, models, object : ModelSelectorDialog.Listener{
override fun onSelect(url: String) {
binding.etUrl.setText(url)
}
})
modelSelectorDialog.show()
}
binding.btnPlay.setOnClickListener {
play()
}
}
private fun play(){
mBaseConfigUrl = binding.etBaseConfig.text.toString()
mModelUrl = binding.etUrl.text.toString()
if (TextUtils.isEmpty(mBaseConfigUrl)){
Toast.makeText(mContext, R.string.base_config_cannot_be_empty, Toast.LENGTH_SHORT).show()
return
}
if (TextUtils.isEmpty(mModelUrl)){
Toast.makeText(mContext, R.string.model_url_cannot_be_empty, Toast.LENGTH_SHORT).show()
return
}
checkBaseConfig()
}
private fun checkBaseConfig(){
if (VirtualModelUtil.checkBaseConfig(mContext)){
checkModel()
} else {
baseConfigDownload()
}
}
private fun checkModel(){
if (VirtualModelUtil.checkModel(mContext, mModelUrl)){
jumpPlayPage()
} else {
modelDownload()
}
}
private fun jumpPlayPage(){
val intent = Intent(mContext, CallActivity::class.java)
intent.putExtra("modelUrl", mModelUrl)
val debug = binding.switchDebug.isChecked
intent.putExtra("debug", debug)
startActivity(intent)
}
private fun baseConfigDownload(){
mLoadingDialog?.dismiss()
mLoadingDialog = LoadingDialog(mContext, "Start downloading")
mLoadingDialog?.show()
VirtualModelUtil.baseConfigDownload(mContext, mBaseConfigUrl, object :
VirtualModelUtil.ModelDownloadCallback {
override fun onDownloadProgress(url: String?, current: Long, total: Long) {
val progress = (current * 100 / total).toInt()
if (progress != mLastProgress){
mLastProgress = progress
runOnUiThread {
if (mLoadingDialog?.isShowing == true){
mLoadingDialog?.setContent("Config download(${progress}%)")
}
}
}
}
override fun onUnzipProgress(url: String?, current: Long, total: Long) {
val progress = (current * 100 / total).toInt()
if (progress != mLastProgress){
mLastProgress = progress
runOnUiThread {
if (mLoadingDialog?.isShowing == true){
mLoadingDialog?.setContent("Config unzip(${progress}%)")
}
}
}
}
override fun onDownloadComplete(url: String?, dir: File?) {
runOnUiThread {
mLoadingDialog?.dismiss()
checkModel()
}
}
override fun onDownloadFail(url: String?, code: Int, msg: String?) {
runOnUiThread {
mLoadingDialog?.dismiss()
Toast.makeText(mContext, "BaseConfig download error: $msg", Toast.LENGTH_SHORT).show()
}
}
})
}
private fun modelDownload(){
mLoadingDialog?.dismiss()
mLoadingDialog = LoadingDialog(mContext, "Start downloading")
mLoadingDialog?.show()
VirtualModelUtil.modelDownload(mContext, mModelUrl, object : VirtualModelUtil.ModelDownloadCallback{
override fun onDownloadProgress(
url: String?,
current: Long,
total: Long,
) {
val progress = (current * 100 / total).toInt()
if (progress != mLastProgress){
mLastProgress = progress
runOnUiThread {
if (mLoadingDialog?.isShowing == true){
mLoadingDialog?.setContent("Model download(${progress}%)")
}
}
}
}
override fun onUnzipProgress(
url: String?,
current: Long,
total: Long,
) {
val progress = (current * 100 / total).toInt()
if (progress != mLastProgress){
mLastProgress = progress
runOnUiThread {
if (mLoadingDialog?.isShowing == true){
mLoadingDialog?.setContent("Model unzip(${progress}%)")
}
}
}
}
override fun onDownloadComplete(url: String?, dir: File?) {
runOnUiThread {
mLoadingDialog?.dismiss()
jumpPlayPage()
}
}
override fun onDownloadFail(
url: String?,
code: Int,
msg: String?,
) {
runOnUiThread {
mLoadingDialog?.dismiss()
Toast.makeText(mContext, "Model download error: $msg", Toast.LENGTH_SHORT).show()
}
}
})
}
}
@@ -0,0 +1,39 @@
package ai.guiji.duix.test.ui.adapter
import ai.guiji.duix.test.databinding.ItemModelSelectorBinding
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
class ModelSelectorAdapter(
private val mList: ArrayList<String>,
private val callback: Callback
) : RecyclerView.Adapter<ModelSelectorAdapter.ItemHolder>() {
class ItemHolder(val itemBinding: ItemModelSelectorBinding) :
RecyclerView.ViewHolder(itemBinding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemHolder {
val itemBinding =
ItemModelSelectorBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ItemHolder(itemBinding)
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: ItemHolder, position: Int) {
holder.itemBinding.tvModelUrl.text = mList[position]
holder.itemBinding.tvModelUrl.setOnClickListener {
callback.onClick(mList[position])
}
}
override fun getItemCount(): Int {
return mList.size
}
interface Callback {
fun onClick(url: String)
}
}
@@ -0,0 +1,39 @@
package ai.guiji.duix.test.ui.adapter
import ai.guiji.duix.test.databinding.ItemMotionButtonBinding
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
class MotionAdapter(
private val mList: ArrayList<String>,
private val callback: Callback
) : RecyclerView.Adapter<MotionAdapter.ItemHolder>() {
class ItemHolder(val itemBinding: ItemMotionButtonBinding) :
RecyclerView.ViewHolder(itemBinding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemHolder {
val itemBinding =
ItemMotionButtonBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ItemHolder(itemBinding)
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: ItemHolder, position: Int) {
holder.itemBinding.btnMotion.text = mList[position]
holder.itemBinding.btnMotion.setOnClickListener {
callback.onClick(mList[position], true)
}
}
override fun getItemCount(): Int {
return mList.size
}
interface Callback {
fun onClick(name: String, now: Boolean)
}
}
@@ -0,0 +1,96 @@
package ai.guiji.duix.test.ui.dialog
import ai.guiji.duix.test.R
import ai.guiji.duix.test.audio.AudioRecorder
import ai.guiji.duix.test.databinding.DialogAudioRecordBinding
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.MotionEvent
import android.view.ViewGroup
import android.view.Window
import android.widget.Toast
class AudioRecordDialog(
private val mContext: Context,
private val listener: Listener
) : Dialog(mContext, R.style.dialog_center) {
private var binding: DialogAudioRecordBinding
private var audioRecorder: AudioRecorder?=null
init {
requestWindowFeature(Window.FEATURE_NO_TITLE)
binding = DialogAudioRecordBinding.inflate(layoutInflater)
setContentView(binding.root)
}
@SuppressLint("ClickableViewAccessibility")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window?.let {
it.setGravity(Gravity.BOTTOM)
it.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
it.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
binding.tvTouch.setOnTouchListener { _, event ->
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
startRecord()
}
MotionEvent.ACTION_UP -> {
stopRecord()
}
}
false
}
setCancelable(true)
setCanceledOnTouchOutside(true)
}
private fun startRecord(){
audioRecorder = AudioRecorder(mContext, object : AudioRecorder.RecorderCallback{
override fun onReadData(data: ByteArray, offsetInBytes: Int, length: Int) {
}
override fun onRecordError(code: Int, message: String) {
binding.layoutFrame.post {
Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show()
audioRecorder?.release()
}
}
override fun onFinish(path: String) {
binding.layoutFrame.post {
audioRecorder?.release()
listener.onFinish(path)
dismiss()
}
}
})
audioRecorder?.start()
}
private fun stopRecord(){
audioRecorder?.stop()
}
override fun dismiss() {
super.dismiss()
audioRecorder?.release()
}
interface Listener {
fun onFinish(path: String)
}
}
@@ -0,0 +1,50 @@
package ai.guiji.duix.test.ui.dialog
import ai.guiji.duix.test.R
import ai.guiji.duix.test.databinding.DialogLoadingBinding
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.text.TextUtils
import android.view.Window
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.view.animation.LinearInterpolator
class LoadingDialog(private var mContext: Context, private val content: String = "") :
Dialog(mContext, R.style.dialog_center) {
private lateinit var binding: DialogLoadingBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestWindowFeature(Window.FEATURE_NO_TITLE)
binding = DialogLoadingBinding.inflate(layoutInflater)
super.setContentView(binding.root)
if (!TextUtils.isEmpty(content)){
binding.tvContent.text = content
}
setCancelable(false)
setCanceledOnTouchOutside(false)
}
fun setContent(content: String){
binding.tvContent.text = content
}
override fun show() {
super.show()
val animation: Animation = AnimationUtils.loadAnimation(mContext, R.anim.rotate)
val lin = LinearInterpolator()
animation.interpolator = lin
binding.ivProgress.startAnimation(animation)
}
override fun dismiss() {
super.dismiss()
binding.ivProgress.clearAnimation()
}
}
@@ -0,0 +1,39 @@
package ai.guiji.duix.test.ui.dialog
import ai.guiji.duix.test.R
import ai.guiji.duix.test.databinding.DialogModelSelectorBinding
import ai.guiji.duix.test.ui.adapter.ModelSelectorAdapter
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.view.Window
class ModelSelectorDialog(mContext: Context, val models: ArrayList<String>, private val listener: Listener) :
Dialog(mContext, R.style.dialog_center) {
private lateinit var binding: DialogModelSelectorBinding
private var mAdapter: ModelSelectorAdapter?=null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestWindowFeature(Window.FEATURE_NO_TITLE)
binding = DialogModelSelectorBinding.inflate(layoutInflater)
super.setContentView(binding.root)
mAdapter = ModelSelectorAdapter(models, object : ModelSelectorAdapter.Callback{
override fun onClick(url: String) {
dismiss()
listener.onSelect(url)
}
})
binding.rvModels.adapter = mAdapter
setCancelable(true)
setCanceledOnTouchOutside(true)
}
interface Listener {
fun onSelect(url: String)
}
}
@@ -0,0 +1,396 @@
package ai.guiji.duix.test.util;
import static java.lang.Character.UnicodeBlock.CJK_COMPATIBILITY_FORMS;
import static java.lang.Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS;
import static java.lang.Character.UnicodeBlock.CJK_RADICALS_SUPPLEMENT;
import static java.lang.Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS;
import static java.lang.Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A;
import static java.lang.Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B;
import java.math.BigDecimal;
import java.net.URLEncoder;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class StringUtils {
/*
* 秒数转时间
* */
public static String secondsToHMS(int seconds) {
StringBuilder time = new StringBuilder();
int min = 0;
int hour = 0;
// 将毫秒转换成秒
// seconds = seconds / 1000;
if (seconds > 60) {
min = seconds / 60;
seconds = seconds % 60;
}
if (min > 60) {
hour = min / 60;
min = min % 60;
}
//拼接
if (hour < 10)
time.append("0");
time.append(hour);
time.append(":");
if (min < 10)
time.append("0");
time.append(min);
time.append(":");
if (seconds < 10)
time.append("0");
time.append(seconds);
return time.toString();
}
/*
* 秒数转时间
* */
public static String secondsToMS(int seconds) {
StringBuilder time = new StringBuilder();
int min = 0;
// 将毫秒转换成秒
// seconds = seconds / 1000;
if (seconds > 60) {
min = seconds / 60;
seconds = seconds % 60;
}
/*if (min > 60) {
min = min % 60;
}*/
if (min < 10)
time.append("0");
time.append(min);
time.append(":");
if (seconds < 10)
time.append("0");
time.append(seconds);
return time.toString();
}
public static String formatDateTime(long mss) {
String DateTimes;
long days = mss / (60 * 60 * 24);
long hours = (mss % (60 * 60 * 24)) / (60 * 60);
long minutes = (mss % (60 * 60)) / 60;
long seconds = mss % 60;
if (days > 0) {
DateTimes = days + "天" + hours + "时";
} else if (hours > 0) {
DateTimes = hours + "时" + minutes + "分钟";
} else if (minutes > 0) {
DateTimes = minutes + "分钟"
+ seconds + "秒";
} else {
DateTimes = seconds + "秒";
}
return DateTimes;
}
//只展示秒,分钟
public static String formatDateTime2(long mss) {
long min = mss / 60;
long second = mss % 60;
String result = "";
if (mss < 60) {
result = mss + "秒";
} else {
if (0 == second) {
result = min + "分钟";
} else {
result = min + "分钟" + second + "秒";
}
}
return result;
}
//只展示秒或者分钟
public static String formatDateTime3(long mss) {
return mss < 60 ? (mss + "秒") : ((mss / 60) + "分钟");
}
//只展示秒,分
public static String formatDateTime4(long mss) {
return mss < 60 ? (mss + "秒") : ((mss / 60) + "分" + ((mss % 60) == 0 ? "" : (mss % 60) + "秒"));
}
public static String formatVideoDateTime(long mss) {
String DateTimes;
long hours = mss / (60 * 60);
long minutes = (mss % (60 * 60)) / 60;
long seconds = mss % 60;
String hourStr = (hours < 10 ? "0" : "") + hours;
String minuteStr = (minutes < 10 ? "0" : "") + minutes;
String secondStr = (seconds < 10 ? "0" : "") + seconds;
if (hours > 0) {
DateTimes = hourStr + ":" + minuteStr + ":" + secondStr;
} else {
DateTimes = minuteStr + ":" + secondStr;
}
return DateTimes;
}
/**
* 格式化文件大小
*
* @param size
* @return
*/
public static String formatSize(long size) {
String sizeStr = "";
String unit[] = {"byte", "kb", "mb", "g" };
long lastValue = size;
for (int i = 0; i < unit.length; i++) {
size = size / 1024;
if (0 == size) {
sizeStr = lastValue + unit[i];
break;
} else {
if (i == unit.length - 1) {
sizeStr = lastValue + unit[i];
} else {
lastValue = size;
}
}
}
return sizeStr;
}
public static String createFileName(String prefix, String suffix) {
Date dt = new Date(System.currentTimeMillis());
SimpleDateFormat fmt = new SimpleDateFormat("yyyyMMddHHmmssSSS");
String fileName = fmt.format(dt);
fileName = prefix + fileName + suffix; //extension, you can change it.
return fileName;
}
public static String dateToStringMS(long date) {
SimpleDateFormat format = new SimpleDateFormat("yyyyMMddHHmmssSSS");
Date dt = new Date(date);
return format.format(dt);
}
public static String dateToStringMS2() {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM");
Date dt = new Date();
return format.format(dt);
}
public static String dateToStringMS3() {
SimpleDateFormat format = new SimpleDateFormat("MM-dd HH:mm");
Date dt = new Date();
return format.format(dt);
}
public static String dateToStringMS4() {
SimpleDateFormat format = new SimpleDateFormat("HH:mm:ss.SSS");
Date dt = new Date();
return format.format(dt);
}
//去除数字结尾无用的0
public static String filterUnUselessZero(String str) {
String value = "";
if (null != str) {
String regEx = "\\.(0+)$";
Pattern pattern = Pattern.compile(regEx);
Matcher matcher = pattern.matcher(str);
boolean rs = matcher.find();
if (rs && null != matcher.group(0)) {
value = str.replace(matcher.group(0), "");
} else {
value = str;
}
}
return value;
}
//判断数字是否为0
public static boolean isNullOrZero(String str) {
boolean result = false;
if (null == str || "".equals(str.trim())) {
result = true;
} else {
try {
double d = Double.parseDouble(str);
result = d == 0;
} catch (Exception e) {
}
}
return result;
}
//判断是否为空
public static boolean isEmpty(String str) {
boolean result = false;
if (null == str || "".equals(str.trim())) {
result = true;
}
return result;
}
// encode
public String urlEncoded(String paramString) {
if (paramString == null || paramString == "") {
return "";
}
try {
String str = URLEncoder.encode(paramString, "UTF-8");
return str;
} catch (Exception e) {
e.printStackTrace();
}
return "";
}
//判断大小
public static Boolean isEnough(String v1, String v2) {
Boolean result = null;
try {
Double d1 = Double.parseDouble(v1);
Double d2 = Double.parseDouble(v2);
result = d1 >= d2;
} catch (Exception e) {
}
return result;
}
/**
* 比较两个字符串不相等处,两个字符串去除标点之后,长度必须一致才会比对
*
* @param str1
* @param str2
* @return
*/
public static List<Integer> compareTwoStr(String str1, String str2) {
List<Integer> mark = new ArrayList<>();
String str1WithoutSymbol = removeSymbol(str1);
String str2WithoutSymbol = removeSymbol(str2);
int symbolCount = 0;
char[] str1Char = str1.toCharArray();
char[] str2Char = str2WithoutSymbol.toCharArray();
for (int i = 0; i < str1Char.length; i++) {
if ("".equals(removeSymbol(String.valueOf(str1Char[i])))) {
symbolCount++;
continue;
}
if (str1Char[i] != str2Char[i - symbolCount]) {
if (0 == mark.size() % 2) {
mark.add(i);
}
} else {
if (0 != mark.size() % 2) {
mark.add(i);
}
}
//提前结束
if (i - symbolCount == str2Char.length - 1) {
if (0 == mark.size() % 2 && i + 1 < str1Char.length) {
//存在非符号,才把后面标出来
if (!"".equals(removeSymbol(str1.substring(i + 1)))) {
mark.add(i + 1);
}
}
break;
}
}
if (0 != mark.size() % 2) {
mark.add(str1Char.length);
}
if (0 == mark.size() && str1WithoutSymbol.length() < str2WithoutSymbol.length()) {
mark.add(0);
mark.add(str1Char.length);
}
return mark;
}
/**
* 去除字符串中的符号标点
*
* @param input
* @return
*/
public static String removeSymbol(String input) {
return input.replaceAll("\\p{P}|\\p{S}", "");
}
public static double formatDouble(double value, int accuracy) {
BigDecimal b = new BigDecimal(value);
//保留2位小数
double out = b.setScale(accuracy, BigDecimal.ROUND_HALF_UP).doubleValue();
return out;
}
/**
* 字符串中是否只包含汉字字母
*
* @return
*/
public static boolean isCharOrLetter(String str) {
String ruleString = "[\\u4e00-\\u9fa5a-zA-Z0-9]*";
Pattern p = Pattern.compile(ruleString);
char[] c = str.toCharArray();
for (int i = 0; i < c.length; i++) {
Matcher matcher = p.matcher("" + c[i]);
boolean b = matcher.matches();
if (b) {
continue;
} else {
return false;
}
}
return true;
}
/**
* 判断是否含有中文
*
* @param checkChar
* @return
*/
public static boolean checkCharContainChinese(char checkChar) {
Character.UnicodeBlock ub = Character.UnicodeBlock.of(checkChar);
if (CJK_UNIFIED_IDEOGRAPHS == ub || CJK_COMPATIBILITY_IDEOGRAPHS == ub || CJK_COMPATIBILITY_FORMS == ub ||
CJK_RADICALS_SUPPLEMENT == ub || CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A == ub || CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B == ub) {
return true;
}
return false;
}
//格式化时间为年月日
public static String formatDateString(String time) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
String formatString = "";
try {
Date date = sdf.parse(time);
formatString = sdf.format(date);
} catch (Exception e) {
e.printStackTrace();
}
return formatString;
}
public static String subZeroAndDot(String s) {
if (s.indexOf(".") > 0) {
s = s.replaceAll("0+?$", "");//去掉多余的0
s = s.replaceAll("[.]$", "");//如最后一位是.则去掉
}
return s;
}
// 是否包含特殊字符
public static boolean isSpecialChar(String str) {
String regEx = "[ _`~!@#$%^&*()+=|{}':;',\\[\\].<>/?~!@#¥%……&*()——+|{}【】‘;:”“’。,、?]|\n|\r|\t";
Pattern p = Pattern.compile(regEx);
Matcher m = p.matcher(str);
return m.find();
}
}
@@ -0,0 +1,71 @@
package ai.guiji.duix.test.util;
import android.content.Context;
import android.content.pm.PackageManager;
public class SystemUtils {
public static int getVersionCode(Context context) {
try {
return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionCode;
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
return -1;
}
}
public static String getVersionName(Context context) {
try {
return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName;
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
return "";
}
}
/**
* 版本号比较
*
* @param version1
* @param version2
* @return
*/
public static int compareVersion(String version1, String version2) {
try {
if (version1.equals(version2)) {
return 0;
}
String[] version1Array = version1.split("\\.");
String[] version2Array = version2.split("\\.");
int index = 0;
// 获取最小长度值
int minLen = Math.min(version1Array.length, version2Array.length);
int diff = 0;
while (index < minLen
&& (diff = Integer.parseInt(version1Array[index])
- Integer.parseInt(version2Array[index])) == 0) {
index++;
}
if (diff == 0) {
// 如果位数不一致,比较多余位数
for (int i = index; i < version1Array.length; i++) {
if (Integer.parseInt(version1Array[i]) > 0) {
return 1;
}
}
for (int i = index; i < version2Array.length; i++) {
if (Integer.parseInt(version2Array[i]) > 0) {
return -1;
}
}
return 0;
} else {
return diff > 0 ? 1 : -1;
}
} catch (Exception e) {
e.printStackTrace();
}
return -1;
}
}
@@ -0,0 +1,101 @@
package ai.guiji.duix.test.util;
import android.util.Log;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Enumeration;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;
public class ZipUtil {
//解压缩文件
// 这里unzip需要使用canonicalPath做校验,但是该方法取的路径可能和getAbsolutePath()方式获取的不一致
// getCanonicalPath() /data/data/.......
// getAbsolutePath /data/user/0/......
public static boolean unzip(String zipFilePath, String outOutPath, Callback callback) {
try (FileInputStream fis = new FileInputStream(zipFilePath)) {
long total = 0;
if (callback != null){
total = getZipSize(zipFilePath);
}
ZipInputStream inZip = new ZipInputStream(fis);
long currentSize = 0;
ZipEntry zipEntry;
String szName = "";
while ((zipEntry = inZip.getNextEntry()) != null) {
szName = zipEntry.getName();
if (zipEntry.isDirectory()) {
szName = szName.substring(0, szName.length() - 1);
File folder = new File(outOutPath + File.separator + szName);
String canonicalPath = folder.getCanonicalPath();
if (!canonicalPath.startsWith(outOutPath)) {
Log.e("123", "绝对值路径比较异常忽略该地址: " + folder.getAbsolutePath());
} else {
if (!folder.exists()) {
if (!folder.mkdirs()) {
return false;
}
}
}
} else {
File file = new File(outOutPath + File.separator + szName);
String canonicalPath = file.getCanonicalPath();
if (!canonicalPath.startsWith(outOutPath)) {
Log.e("123", "绝对值路径比较异常忽略该地址: " + file.getAbsolutePath());
} else {
if (!file.exists()) {
if (!file.getParentFile().exists()) {
file.getParentFile().mkdirs();
}
if (!file.createNewFile()) {
return false;
}
FileOutputStream out = new FileOutputStream(file);
int len;
byte[] buffer = new byte[2048];
while ((len = inZip.read(buffer)) != -1) {
out.write(buffer, 0, len);
out.flush();
if (callback != null) {
currentSize += len;
int progress = (int)(currentSize * 100.0f / total);
callback.onProgress(progress); // 通过回调函数更新进度
}
}
out.close();
}
}
}
}
inZip.close();
return true;
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
private static long getZipSize(String filePath){
long size = 0;
ZipFile f;
try {
f = new ZipFile(filePath);
Enumeration<? extends ZipEntry> en = f.entries();
while (en.hasMoreElements()) {
size += en.nextElement().getSize();
}
} catch (IOException e) {
size = 0;
}
return size;
}
public interface Callback {
void onProgress(int progress);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<rotate
android:duration="1000"
android:fromDegrees="0"
android:toDegrees="+359"
android:repeatCount="-1"
android:repeatMode="restart"
android:pivotX="50%"
android:pivotY="50%"
android:interpolator="@android:anim/overshoot_interpolator"
/>
</set>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/shape_corners_60_primary" android:state_pressed="false" />
<item android:drawable="@drawable/shape_corners_60_99_primary" android:state_pressed="true" />
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#66444444" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#CC147BF7" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#CC147BF7" />
</shape>
@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="7dp" />
<solid android:color="#661a1e1c" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="60dp" />
<solid android:color="@color/primary_99" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="60dp" />
<solid android:color="@color/primary" />
</shape>
@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="7dp" />
<solid android:color="#b3000000" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners
android:topLeftRadius="32dp"
android:topRightRadius="32dp" />
<solid android:color="#ffffffff" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:topLeftRadius="20dp" android:topRightRadius="20dp"
android:bottomLeftRadius="20dp"/>
<solid android:color="#ff147bf7" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners
android:bottomRightRadius="20dp"
android:topLeftRadius="20dp"
android:topRightRadius="20dp" />
<solid android:color="#ffd9eaff" />
</shape>
+180
View File
@@ -0,0 +1,180 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:fitsSystemWindows="true"
android:layout_height="match_parent">
<ImageView
android:id="@+id/iv_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop" />
<ai.guiji.duix.sdk.client.render.DUIXTextureView
android:id="@+id/glTextureView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
android:id="@+id/tvDebug"
android:textSize="12sp"
android:visibility="gone"
android:textColor="@color/white"
android:background="#66000000"
android:padding="6dp"
app:layout_constraintTop_toTopOf="parent"
android:layout_width="match_parent"
android:layout_height="200dp"/>
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switchMute"
android:text="@string/mute"
android:layout_margin="12dp"
android:enabled="false"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/tvMotionTips"
android:visibility="invisible"
android:layout_margin="12dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:text="@string/support_actions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMotion"
app:layout_constraintTop_toBottomOf="@+id/tvMotionTips"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="12dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:orientation="vertical"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<Button
android:id="@+id/btnRandomMotion"
android:text="@string/random_play_action"
android:textSize="13sp"
android:visibility="invisible"
android:textColor="@color/white"
app:layout_constraintTop_toBottomOf="@+id/rvMotion"
app:layout_constraintStart_toStartOf="parent"
android:background="@drawable/shape_common_btn"
android:layout_margin="6dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/tvSubtitle"
android:text=""
android:textSize="16sp"
android:textColor="@color/white"
android:shadowColor="#1a1e1f"
android:shadowRadius="4"
android:gravity="center"
android:visibility="gone"
android:background="#99000000"
android:padding="12dp"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@+id/btnAIConversation"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="12dp"
/>
<Button
android:id="@+id/btnAIConversation"
android:text="@string/ai_fab_start"
android:textSize="14sp"
android:textColor="@color/white"
android:background="@drawable/shape_circle_fab"
android:elevation="8dp"
android:layout_width="56dp"
android:layout_height="56dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginBottom="32dp"
android:contentDescription="@string/ai_conversation"
/>
<Button
android:id="@+id/btnCameraToggle"
android:text="📷"
android:textSize="18sp"
android:textColor="@color/white"
android:background="@drawable/shape_circle_camera_off"
android:elevation="8dp"
android:layout_width="48dp"
android:layout_height="48dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/btnAIConversation"
app:layout_constraintStart_toEndOf="@+id/btnAIConversation"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginStart="16dp"
android:layout_marginEnd="24dp"
android:contentDescription="@string/camera_off"
/>
<TextureView
android:id="@+id/cameraPreview"
android:layout_width="120dp"
android:layout_height="160dp"
android:visibility="gone"
android:scaleX="-1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginStart="16dp"
android:layout_marginTop="48dp"
/>
<Button
android:id="@+id/btnRecord"
android:text="@string/record"
android:enabled="false"
android:visibility="gone"
android:textSize="13sp"
android:textColor="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginBottom="48dp"
android:background="@drawable/shape_common_btn"
android:layout_margin="6dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<Button
android:id="@+id/btnPlayPCM"
android:text="@string/play_pcm"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/btnAIConversation"
android:background="@drawable/shape_common_btn"
android:layout_margin="6dp"
android:textSize="13sp"
android:textColor="@color/white"
android:enabled="false"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<Button
android:id="@+id/btnPlayWAV"
android:text="@string/play_wav"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/btnPlayPCM"
android:background="@drawable/shape_common_btn"
android:layout_margin="6dp"
android:textSize="13sp"
android:textColor="@color/white"
android:enabled="false"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</androidx.constraintlayout.widget.ConstraintLayout>
+121
View File
@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="12dp">
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="120dp"
android:gravity="center"
android:text="@string/app_name"
android:textColor="@color/black"
android:textSize="18dp"
android:textStyle="italic|bold"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_sdk_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tv_title" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tv_sdk_version">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tvDownloadTips"
app:layout_constraintTop_toTopOf="parent"
android:text="@string/main_download_tips"
android:textSize="13sp"
android:textColor="@color/black"
android:textStyle="bold"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/tvBaseConfig"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/base_config_url"
android:textSize="13sp"
app:layout_constraintTop_toBottomOf="@+id/tvDownloadTips"
app:layout_constraintStart_toStartOf="parent"/>
<EditText
android:id="@+id/etBaseConfig"
android:layout_marginTop="12dp"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/gj_dh_res.zip"
android:textSize="13sp"
app:layout_constraintTop_toBottomOf="@+id/tvBaseConfig"
/>
<TextView
android:id="@+id/tvModelUrl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="@string/model_url"
android:textSize="13sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/etBaseConfig" />
<Button
android:id="@+id/btnMoreModel"
android:text="@string/more"
android:layout_marginTop="12dp"
android:layout_width="wrap_content"
android:layout_height="48dp"
app:layout_constraintTop_toBottomOf="@+id/tvModelUrl"
app:layout_constraintEnd_toEndOf="parent"
/>
<EditText
android:id="@+id/etUrl"
android:layout_width="0dp"
android:layout_height="48dp"
android:text="https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/bendi3_20240518.zip"
android:textSize="13sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/btnMoreModel"
app:layout_constraintEnd_toStartOf="@+id/btnMoreModel"
/>
<Button
android:id="@+id/btnPlay"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="20dp"
android:text="@string/play"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/etUrl" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switchDebug"
android:text="@string/debug_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="13sp"
android:layout_margin="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btnPlay"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
android:paddingHorizontal="16dp"
android:paddingVertical="16dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layoutFrame"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent">
<TextView
android:id="@+id/tvTouch"
android:layout_width="120dp"
android:layout_height="120dp"
android:paddingVertical="12dp"
android:clickable="true"
android:background="@drawable/selector_60_primary"
android:gravity="center"
android:text="@string/touch_me_record"
android:textColor="@color/white"
android:textSize="12sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="138dp"
android:background="@drawable/shape_solid_b3000000_radius_7"
android:gravity="center"
android:minWidth="138dp"
android:orientation="vertical"
android:paddingBottom="2dp">
<ImageView
android:id="@+id/iv_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@mipmap/iv_progress" />
<TextView
android:id="@+id/tv_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center"
android:singleLine="true"
android:text="@string/loading"
android:layout_marginHorizontal="12dp"
android:textColor="@color/white"
android:textSize="12dp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:gravity="center">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvModels"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="6dp"
xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView
android:id="@+id/tvModelUrl"
app:layout_constraintTop_toTopOf="parent"
android:textSize="13sp"
android:padding="6dp"
android:background="@drawable/shape_common_btn"
android:textColor="@color/white"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto">
<Button
android:id="@+id/btnMotion"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:background="@drawable/shape_common_btn"
android:textSize="13sp"
android:textColor="@color/white"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</androidx.constraintlayout.widget.ConstraintLayout>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -0,0 +1,29 @@
<resources>
<string name="app_name">DUIX Demo</string>
<string name="play">播放</string>
<string name="base_config_url">基础配置Url:</string>
<string name="model_url">模型Url:</string>
<string name="more">更多</string>
<string name="base_config_cannot_be_empty">基础配置url不能为空</string>
<string name="model_url_cannot_be_empty">模型url不能为空</string>
<string name="loading">加载中...</string>
<string name="mute">静音</string>
<string name="ai_tips">本内容由AI生成,仅供参考</string>
<string name="support_actions">支持的动作:</string>
<string name="record">录音</string>
<string name="play_pcm">播放PCM流</string>
<string name="play_wav">播放WAV文件</string>
<string name="random_play_action">随机播放动作</string>
<string name="touch_me_record">按住我收音</string>
<string name="need_permission_continue">需要授权以继续使用</string>
<string name="play_stop">停止播放</string>
<string name="ai_conversation">AI 实时对话</string>
<string name="ai_conversation_stop">结束 AI 对话</string>
<string name="ai_fab_start">AI</string>
<string name="ai_fab_stop">停</string>
<string name="camera_on">镜头开</string>
<string name="camera_off">镜头关</string>
<string name="main_download_tips">Github地址可能下载失败,您可以考虑使用代理或者将文件缓存到自己的存储服务</string>
<string name="debug_message">调试信息</string>
</resources>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="visualizerView">
<attr name="numColumns" format="integer" />
<attr name="renderColor" format="color" />
<attr name="renderRange">
<enum name="top" value="0" />
<enum name="bottom" value="1" />
<enum name="both" value="2" />
</attr>
<attr name="renderType">
<flag name="bar" value="0x1" />
<flag name="pixel" value="0x2" />
<flag name="fade" value="0x4" />
</attr>
</declare-styleable>
</resources>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="primary">#5a7bbe</color>
<color name="primary_99">#995a7bbe</color>
</resources>
+28
View File
@@ -0,0 +1,28 @@
<resources>
<string name="app_name">DUIX Demo</string>
<string name="play">Play</string>
<string name="base_config_url">BaseConfig Url:</string>
<string name="model_url">Model Url:</string>
<string name="more">More</string>
<string name="base_config_cannot_be_empty">BaseConfig url cannot be empty</string>
<string name="model_url_cannot_be_empty">Model url cannot be empty</string>
<string name="loading">Loading...</string>
<string name="mute">Mute</string>
<string name="ai_tips">This content is generated by AI and is for reference only</string>
<string name="support_actions">Supported actions:</string>
<string name="record">Record</string>
<string name="play_pcm">Play PCM stream</string>
<string name="play_wav">Play WAV file</string>
<string name="random_play_action">Random play action</string>
<string name="touch_me_record">Press and hold</string>
<string name="need_permission_continue">Permission is required to continue using</string>
<string name="play_stop">Stop playing</string>
<string name="ai_conversation">AI 实时对话</string>
<string name="ai_conversation_stop">结束 AI 对话</string>
<string name="ai_fab_start">AI</string>
<string name="ai_fab_stop">停</string>
<string name="camera_on">镜头开</string>
<string name="camera_off">镜头关</string>
<string name="main_download_tips">The GitHub url may fail to download, you can consider using a proxy or caching the file to your own storage service</string>
<string name="debug_message">Debug message</string>
</resources>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="dialog_center" parent="Theme.AppCompat.Light.Dialog">
<item name="android:windowFrame">@null</item>
<item name="android:windowIsFloating">true</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowNoTitle">true</item>
<item name="android:background">@android:color/transparent</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:backgroundDimAmount">0.5</item>
<item name="android:backgroundDimEnabled">true</item>
</style>
</resources>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.DUIX.Test" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowBackground">@android:color/white</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsTranslucent">false</item>
<item name="android:windowIsFloating">false</item>
<item name="colorPrimary">#fff</item>
<item name="colorPrimaryDark">#4349a9</item>
<item name="colorAccent">#4349a9</item>
<item name="android:windowTranslucentStatus">true</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>