加入频谱图

This commit is contained in:
feie9456 2026-03-20 19:37:10 +08:00
parent a44ddb6dff
commit 2015286595
3 changed files with 240 additions and 1 deletions

View File

@ -13,6 +13,7 @@ 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.ui.widget.AudioSpectrumView
import ai.guiji.duix.test.util.StringUtils
import android.Manifest
import android.annotation.SuppressLint
@ -163,11 +164,17 @@ class CallActivity : BaseActivity() {
Constant.CALLBACK_EVENT_AUDIO_PLAY_START -> {
applyMessage("callback audio play start")
Log.i(TAG, "CALLBACK_EVENT_AUDIO_PLAY_START")
if (isAIConversationActive) {
runOnUiThread { binding.audioSpectrum.setAiSpeaking(true) }
}
}
Constant.CALLBACK_EVENT_AUDIO_PLAY_END -> {
applyMessage("callback audio play end")
Log.i(TAG, "CALLBACK_EVENT_PLAY_END")
if (isAIConversationActive) {
runOnUiThread { binding.audioSpectrum.setAiSpeaking(false) }
}
}
Constant.CALLBACK_EVENT_AUDIO_PLAY_ERROR -> {
@ -312,6 +319,8 @@ class CallActivity : BaseActivity() {
applyMessage("AI对话: 连接中...")
binding.tvSubtitle.visibility = View.VISIBLE
binding.tvSubtitle.text = "正在连接..."
binding.audioSpectrum.visibility = View.VISIBLE
binding.audioSpectrum.clear()
binding.btnAIConversation.setImageResource(R.drawable.ic_mic_off)
binding.btnAIConversation.setBackgroundResource(R.drawable.shape_fab_ai_active)
@ -327,6 +336,7 @@ class CallActivity : BaseActivity() {
streamingRecorder = StreamingAudioRecorder(mContext, object : StreamingAudioRecorder.StreamingCallback {
override fun onAudioData(pcmData: ByteArray) {
omniClient?.appendAudio(pcmData)
mainHandler.post { binding.audioSpectrum.updateAudio(pcmData) }
}
override fun onError(message: String) {
mainHandler.post {
@ -432,6 +442,8 @@ class CallActivity : BaseActivity() {
binding.btnCameraToggle.visibility = View.GONE
binding.tvSubtitle.visibility = View.GONE
binding.tvSubtitle.text = ""
binding.audioSpectrum.clear()
binding.audioSpectrum.visibility = View.GONE
applyMessage("AI对话: 已结束")
}
}

View File

@ -0,0 +1,215 @@
package ai.guiji.duix.test.ui.widget;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Shader;
import android.util.AttributeSet;
import android.view.View;
/**
* 淡雅音频频谱可视化 View
* 支持两种模式
* 1. 麦克风模式 用真实 PCM 数据驱动频谱柱
* 2. AI 说话模式 柔和呼吸脉动动画与数字人口型同步 SDK 回调驱动
*/
public class AudioSpectrumView extends View {
private static final int BAR_COUNT = 32;
private static final float SMOOTHING = 0.35f;
private static final float DECAY = 0.92f;
private final Paint barPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint glowPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final float[] magnitudes = new float[BAR_COUNT];
private final float[] displayMagnitudes = new float[BAR_COUNT];
private LinearGradient micGradient;
private LinearGradient aiGradient;
private int lastWidth = 0;
private int lastHeight = 0;
/** AI 说话脉动状态 */
private boolean aiSpeaking = false;
private long aiSpeakStartTime = 0;
public AudioSpectrumView(Context context) {
super(context);
init();
}
public AudioSpectrumView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public AudioSpectrumView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
barPaint.setStyle(Paint.Style.FILL);
glowPaint.setStyle(Paint.Style.FILL);
glowPaint.setAlpha(40);
}
/**
* 接收 PCM 16bit mono 音频数据麦克风提取频谱能量
*/
public void updateAudio(byte[] pcmData) {
if (pcmData == null || pcmData.length < 4) return;
if (aiSpeaking) return; // AI 说话时忽略麦克风数据
int sampleCount = pcmData.length / 2;
int samplesPerBar = Math.max(1, sampleCount / BAR_COUNT);
for (int i = 0; i < BAR_COUNT; i++) {
long sum = 0;
int start = i * samplesPerBar;
int end = Math.min(start + samplesPerBar, sampleCount);
for (int j = start; j < end; j++) {
int idx = j * 2;
if (idx + 1 >= pcmData.length) break;
short sample = (short) ((pcmData[idx] & 0xFF) | (pcmData[idx + 1] << 8));
sum += Math.abs(sample);
}
float avg = (float) sum / Math.max(1, end - start);
float normalized = Math.min(1f, avg / 8000f);
float boost = 1f + (float) i / BAR_COUNT * 1.0f;
magnitudes[i] = Math.min(1f, normalized * boost);
}
postInvalidateOnAnimation();
}
/** 通知 AI 数字人开始说话(由 DUIX SDK AUDIO_PLAY_START 回调触发) */
public void setAiSpeaking(boolean speaking) {
this.aiSpeaking = speaking;
if (speaking) {
aiSpeakStartTime = System.currentTimeMillis();
// 清除麦克风残留数据
for (int i = 0; i < BAR_COUNT; i++) {
magnitudes[i] = 0f;
}
}
postInvalidateOnAnimation();
}
public boolean isAiSpeaking() {
return aiSpeaking;
}
/** 清除频谱 */
public void clear() {
aiSpeaking = false;
for (int i = 0; i < BAR_COUNT; i++) {
magnitudes[i] = 0f;
}
postInvalidateOnAnimation();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int w = getWidth();
int h = getHeight();
if (w == 0 || h == 0) return;
if (micGradient == null || lastWidth != w || lastHeight != h) {
lastWidth = w;
lastHeight = h;
// 麦克风暖橙渐变
micGradient = new LinearGradient(
0, h, 0, 0,
new int[]{0x00E8926F, 0x66E8926F, 0x99F0B89A},
new float[]{0f, 0.5f, 1f},
Shader.TileMode.CLAMP
);
// AI 说话柔和蓝紫渐变
aiGradient = new LinearGradient(
0, h, 0, 0,
new int[]{0x009BB5E8, 0x669BB5E8, 0x99C4B5F0},
new float[]{0f, 0.5f, 1f},
Shader.TileMode.CLAMP
);
}
// 选择渐变色
LinearGradient activeGradient = aiSpeaking ? aiGradient : micGradient;
barPaint.setShader(activeGradient);
glowPaint.setShader(activeGradient);
float totalBarWidth = w * 0.85f;
float sideMargin = (w - totalBarWidth) / 2f;
float gap = totalBarWidth * 0.02f;
float barWidth = (totalBarWidth - gap * (BAR_COUNT - 1)) / BAR_COUNT;
float cornerRadius = barWidth / 2f;
float maxBarHeight = h * 0.96f;
float minBarHeight = h * 0.05f;
if (aiSpeaking) {
drawAiPulse(canvas, w, h, sideMargin, gap, barWidth, cornerRadius, maxBarHeight, minBarHeight);
} else {
drawMicSpectrum(canvas, h, sideMargin, gap, barWidth, cornerRadius, maxBarHeight, minBarHeight);
}
}
private void drawMicSpectrum(Canvas canvas, int h, float sideMargin, float gap,
float barWidth, float cornerRadius, float maxBarHeight, float minBarHeight) {
boolean hasActivity = false;
for (int i = 0; i < BAR_COUNT; i++) {
float target = magnitudes[i];
if (target > displayMagnitudes[i]) {
displayMagnitudes[i] += (target - displayMagnitudes[i]) * (1f - SMOOTHING);
} else {
displayMagnitudes[i] *= DECAY;
}
float barH = minBarHeight + displayMagnitudes[i] * maxBarHeight;
float left = sideMargin + i * (barWidth + gap);
float top = h - barH;
float right = left + barWidth;
canvas.drawRoundRect(left - 1, top - 2, right + 1, h, cornerRadius, cornerRadius, glowPaint);
canvas.drawRoundRect(left, top, right, h, cornerRadius, cornerRadius, barPaint);
if (displayMagnitudes[i] > 0.001f) hasActivity = true;
}
if (hasActivity) {
postInvalidateOnAnimation();
}
}
private void drawAiPulse(Canvas canvas, int w, int h, float sideMargin, float gap,
float barWidth, float cornerRadius, float maxBarHeight, float minBarHeight) {
float elapsed = (System.currentTimeMillis() - aiSpeakStartTime) / 1000f;
float center = BAR_COUNT / 2f;
for (int i = 0; i < BAR_COUNT; i++) {
// 多重正弦波叠加模拟自然呼吸感
float dist = Math.abs(i - center) / center; // 0~1, 离中心的距离
float wave1 = (float) Math.sin(elapsed * 2.5 - dist * 3.0) * 0.5f + 0.5f;
float wave2 = (float) Math.sin(elapsed * 1.7 + i * 0.4) * 0.3f + 0.5f;
float wave3 = (float) Math.sin(elapsed * 4.0 - i * 0.2) * 0.15f + 0.5f;
// 中间高两边低的包络
float envelope = 1f - dist * 0.6f;
float target = (wave1 * 0.5f + wave2 * 0.3f + wave3 * 0.2f) * envelope;
target = Math.max(0.05f, Math.min(1f, target));
// 平滑过渡
displayMagnitudes[i] += (target - displayMagnitudes[i]) * 0.15f;
float barH = minBarHeight + displayMagnitudes[i] * maxBarHeight * 0.9f;
float left = sideMargin + i * (barWidth + gap);
float top = h - barH;
float right = left + barWidth;
canvas.drawRoundRect(left - 1, top - 2, right + 1, h, cornerRadius, cornerRadius, glowPaint);
canvas.drawRoundRect(left, top, right, h, cornerRadius, cornerRadius, barPaint);
}
postInvalidateOnAnimation();
}
}

View File

@ -83,10 +83,22 @@
android:padding="12dp"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@+id/btnAIConversation"
app:layout_constraintBottom_toTopOf="@+id/audioSpectrum"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="8dp"
/>
<ai.guiji.duix.test.ui.widget.AudioSpectrumView
android:id="@+id/audioSpectrum"
android:layout_width="0dp"
android:layout_height="48dp"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/btnAIConversation"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginHorizontal="32dp"
android:layout_marginBottom="12dp"
/>