10 changed files with 1066 additions and 19 deletions
@ -0,0 +1,551 @@ |
|||||
|
package com.example.deep_voice |
||||
|
|
||||
|
import android.app.Notification |
||||
|
import android.app.NotificationChannel |
||||
|
import android.app.NotificationManager |
||||
|
import android.app.PendingIntent |
||||
|
import android.app.Service |
||||
|
import android.content.Context |
||||
|
import android.content.Intent |
||||
|
import android.os.Build |
||||
|
import android.os.IBinder |
||||
|
import android.util.Log |
||||
|
import android.view.KeyEvent |
||||
|
import androidx.annotation.Nullable |
||||
|
import androidx.core.app.NotificationCompat |
||||
|
import androidx.media.session.MediaButtonReceiver |
||||
|
import android.support.v4.media.session.MediaSessionCompat |
||||
|
import android.support.v4.media.session.PlaybackStateCompat |
||||
|
import android.content.ComponentName |
||||
|
import android.media.AudioManager |
||||
|
import java.util.concurrent.atomic.AtomicBoolean |
||||
|
import org.json.JSONArray |
||||
|
|
||||
|
/** |
||||
|
* 后台语音交互 Service: |
||||
|
* 1) 前台服务,确保不会被系统轻易杀死 |
||||
|
* 2) MediaSession 捕获蓝牙耳机按键 |
||||
|
* 3) 处理录音/语音识别 |
||||
|
*/ |
||||
|
class VoiceInteractionService : Service() { |
||||
|
|
||||
|
companion object { |
||||
|
private const val TAG = "VoiceInteractionService" |
||||
|
private const val NOTIFICATION_ID = 1001 |
||||
|
private const val CHANNEL_ID = "voice_interaction_channel" |
||||
|
|
||||
|
// 用于跟踪服务是否正在运行 |
||||
|
private val isRunning = AtomicBoolean(false) |
||||
|
|
||||
|
// 获取服务运行状态 |
||||
|
fun isServiceRunning(): Boolean { |
||||
|
return isRunning.get() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private lateinit var mediaSession: MediaSessionCompat |
||||
|
private var isRecording = false |
||||
|
private var isProcessing = false |
||||
|
private var lastKeyEventTime = 0L |
||||
|
private var keyEventCount = 0 |
||||
|
private lateinit var audioManager: AudioManager |
||||
|
|
||||
|
// Azure服务 |
||||
|
private lateinit var azureAsrHelper: AzureAsrHelper |
||||
|
private lateinit var azureTtsHelper: AzureTtsHelper |
||||
|
|
||||
|
// 火山AI服务 |
||||
|
private lateinit var volcanoAIService: VolcanoAIService |
||||
|
|
||||
|
// 系统提示词 |
||||
|
private val systemPrompt = """ |
||||
|
你是一个智能语音助手,能够简洁明了地回答用户的问题。 |
||||
|
请保持回答简短、准确,避免过长的解释。 |
||||
|
如果用户的问题不清楚,请礼貌地请求澄清。 |
||||
|
不要使用复杂的术语,除非用户明确要求。 |
||||
|
""".trimIndent() |
||||
|
|
||||
|
override fun onCreate() { |
||||
|
super.onCreate() |
||||
|
Log.d(TAG, "onCreate") |
||||
|
isRunning.set(true) |
||||
|
|
||||
|
// 获取 AudioManager |
||||
|
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager |
||||
|
|
||||
|
// 1. 初始化Azure服务 |
||||
|
initAzureServices() |
||||
|
|
||||
|
// 2. 初始化火山AI服务 |
||||
|
volcanoAIService = VolcanoAIService(applicationContext) |
||||
|
|
||||
|
// 3. 初始化MediaSession |
||||
|
initMediaSession() |
||||
|
|
||||
|
// 4. 启动前台通知(Foreground Service) |
||||
|
startForegroundServiceWithNotification() |
||||
|
|
||||
|
// 5. 注册成为媒体按钮接收器 |
||||
|
registerMediaButtonReceiver() |
||||
|
|
||||
|
// 6. 测试按键事件 |
||||
|
testKeyEvent() |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 测试按键事件,模拟一个按键事件以验证处理逻辑 |
||||
|
*/ |
||||
|
private fun testKeyEvent() { |
||||
|
Log.d(TAG, "测试按键事件处理") |
||||
|
val keyEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_HEADSETHOOK) |
||||
|
val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON).apply { |
||||
|
putExtra(Intent.EXTRA_KEY_EVENT, keyEvent) |
||||
|
component = ComponentName(packageName, VoiceInteractionService::class.java.name) |
||||
|
} |
||||
|
|
||||
|
// MediaButtonReceiver.handleIntent 返回的是 KeyEvent 类型,而不是 Boolean |
||||
|
MediaButtonReceiver.handleIntent(mediaSession, mediaButtonIntent) |
||||
|
Log.d(TAG, "测试按键事件已发送") |
||||
|
|
||||
|
// 直接处理按键事件 |
||||
|
handleKeyEvent(keyEvent) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 处理按键事件 |
||||
|
*/ |
||||
|
private fun handleKeyEvent(keyEvent: KeyEvent) { |
||||
|
if (keyEvent.action == KeyEvent.ACTION_DOWN) { |
||||
|
when (keyEvent.keyCode) { |
||||
|
KeyEvent.KEYCODE_MEDIA_PLAY, |
||||
|
KeyEvent.KEYCODE_MEDIA_PAUSE, |
||||
|
KeyEvent.KEYCODE_HEADSETHOOK, |
||||
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { |
||||
|
Log.d(TAG, "处理按键事件: ${keyEvent.keyCode}") |
||||
|
|
||||
|
// 检测双击 |
||||
|
val currentTime = System.currentTimeMillis() |
||||
|
if (currentTime - lastKeyEventTime < 500) { // 500毫秒内的按键视为连续按键 |
||||
|
keyEventCount++ |
||||
|
if (keyEventCount == 1) { // 这是第二次按键,即双击 |
||||
|
Log.d(TAG, "检测到双击,开始语音交互") |
||||
|
handleDoubleClick() |
||||
|
keyEventCount = 0 |
||||
|
} |
||||
|
} else { |
||||
|
keyEventCount = 0 |
||||
|
} |
||||
|
lastKeyEventTime = currentTime |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 注册成为媒体按钮接收器 |
||||
|
*/ |
||||
|
private fun registerMediaButtonReceiver() { |
||||
|
val mediaButtonReceiver = ComponentName(packageName, MediaButtonReceiver::class.java.name) |
||||
|
audioManager.registerMediaButtonEventReceiver(mediaButtonReceiver) |
||||
|
Log.d(TAG, "已注册媒体按钮接收器: $mediaButtonReceiver") |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 初始化Azure服务 |
||||
|
*/ |
||||
|
private fun initAzureServices() { |
||||
|
azureAsrHelper = AzureAsrHelper() |
||||
|
azureTtsHelper = AzureTtsHelper() |
||||
|
|
||||
|
// 从应用的SharedPreferences获取Azure配置 |
||||
|
val sharedPreferences = applicationContext.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) |
||||
|
val subscriptionKey = sharedPreferences.getString("flutter.azure_speech_key", "") ?: "" |
||||
|
val serviceRegion = sharedPreferences.getString("flutter.azure_speech_region", "") ?: "" |
||||
|
|
||||
|
Log.d(TAG, "Azure配置信息: subscriptionKey=$subscriptionKey, serviceRegion=$serviceRegion") |
||||
|
|
||||
|
if (subscriptionKey.isNotEmpty() && serviceRegion.isNotEmpty()) { |
||||
|
// 初始化ASR |
||||
|
azureAsrHelper.initialize(subscriptionKey, serviceRegion, "zh-CN") |
||||
|
|
||||
|
// 初始化TTS |
||||
|
azureTtsHelper.initialize(subscriptionKey, serviceRegion, "zh-CN") |
||||
|
} else { |
||||
|
Log.e(TAG, "Azure配置信息不完整,无法初始化Azure服务") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 初始化 MediaSession 以捕获蓝牙耳机按键 |
||||
|
*/ |
||||
|
private fun initMediaSession() { |
||||
|
mediaSession = MediaSessionCompat(applicationContext, "VoiceServiceSession") |
||||
|
|
||||
|
// 支持媒体按钮和传输控件 |
||||
|
mediaSession.setFlags( |
||||
|
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or |
||||
|
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS |
||||
|
) |
||||
|
|
||||
|
// 设置播放状态 |
||||
|
val playbackStateBuilder = PlaybackStateCompat.Builder() |
||||
|
.setActions( |
||||
|
PlaybackStateCompat.ACTION_PLAY or |
||||
|
PlaybackStateCompat.ACTION_PAUSE or |
||||
|
PlaybackStateCompat.ACTION_PLAY_PAUSE or |
||||
|
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or |
||||
|
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS |
||||
|
) |
||||
|
.setState(PlaybackStateCompat.STATE_PAUSED, 0, 1.0f) |
||||
|
mediaSession.setPlaybackState(playbackStateBuilder.build()) |
||||
|
|
||||
|
// 监听回调 |
||||
|
mediaSession.setCallback(object : MediaSessionCompat.Callback() { |
||||
|
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { |
||||
|
// 根据 Android 版本使用不同的方式获取 KeyEvent |
||||
|
val keyEvent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { |
||||
|
mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) |
||||
|
} else { |
||||
|
@Suppress("DEPRECATION") |
||||
|
mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as? KeyEvent |
||||
|
} |
||||
|
|
||||
|
Log.d(TAG, "收到媒体按钮事件: $keyEvent") |
||||
|
|
||||
|
if (keyEvent != null) { |
||||
|
Log.d(TAG, "按键代码: ${keyEvent.keyCode}, 动作: ${keyEvent.action}") |
||||
|
|
||||
|
if (keyEvent.action == KeyEvent.ACTION_DOWN) { |
||||
|
when (keyEvent.keyCode) { |
||||
|
KeyEvent.KEYCODE_MEDIA_PLAY, |
||||
|
KeyEvent.KEYCODE_MEDIA_PAUSE, |
||||
|
KeyEvent.KEYCODE_HEADSETHOOK, |
||||
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { |
||||
|
Log.d(TAG, "蓝牙耳机按键被按下: ${keyEvent.keyCode}") |
||||
|
|
||||
|
// 检测双击 |
||||
|
val currentTime = System.currentTimeMillis() |
||||
|
if (currentTime - lastKeyEventTime < 500) { // 500毫秒内的按键视为连续按键 |
||||
|
keyEventCount++ |
||||
|
if (keyEventCount == 1) { // 这是第二次按键,即双击 |
||||
|
Log.d(TAG, "检测到双击,开始语音交互") |
||||
|
handleDoubleClick() |
||||
|
keyEventCount = 0 |
||||
|
return true |
||||
|
} |
||||
|
} else { |
||||
|
keyEventCount = 0 |
||||
|
} |
||||
|
lastKeyEventTime = currentTime |
||||
|
|
||||
|
// 单击也处理 |
||||
|
if (keyEventCount == 0) { |
||||
|
Log.d(TAG, "检测到单击,开始语音交互") |
||||
|
handleDoubleClick() |
||||
|
return true |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return super.onMediaButtonEvent(mediaButtonEvent) |
||||
|
} |
||||
|
|
||||
|
override fun onPlay() { |
||||
|
Log.d(TAG, "MediaSession.onPlay()") |
||||
|
handleDoubleClick() |
||||
|
return super.onPlay() |
||||
|
} |
||||
|
|
||||
|
override fun onPause() { |
||||
|
Log.d(TAG, "MediaSession.onPause()") |
||||
|
handleDoubleClick() |
||||
|
return super.onPause() |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
// 激活 MediaSession |
||||
|
mediaSession.isActive = true |
||||
|
Log.d(TAG, "MediaSession 已激活: ${mediaSession.isActive}") |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 处理耳机双击事件 |
||||
|
*/ |
||||
|
private fun handleDoubleClick() { |
||||
|
if (isProcessing) { |
||||
|
Log.d(TAG, "正在处理中,忽略此次点击") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if (!isRecording) { |
||||
|
startVoiceInteraction() |
||||
|
} else { |
||||
|
stopVoiceInteraction() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 开始语音交互 |
||||
|
*/ |
||||
|
private fun startVoiceInteraction() { |
||||
|
if (isRecording || isProcessing) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
isRecording = true |
||||
|
isProcessing = true |
||||
|
|
||||
|
// 播放提示音 |
||||
|
playStartPrompt() |
||||
|
|
||||
|
// 开始录音识别 |
||||
|
azureAsrHelper.startContinuousRecognition(null, object : AzureAsrHelper.ContinuousRecognizeCallback { |
||||
|
override fun onRecognizing(recognizing: String) { |
||||
|
// 实时识别结果,可以记录日志 |
||||
|
Log.d(TAG, "正在识别: $recognizing") |
||||
|
} |
||||
|
|
||||
|
override fun onResult(result: String) { |
||||
|
// 最终识别结果 |
||||
|
Log.d(TAG, "语音识别结果: $result") |
||||
|
|
||||
|
// 停止录音 |
||||
|
isRecording = false |
||||
|
|
||||
|
// 如果有识别结果,则发送到VolcanoAI处理 |
||||
|
if (result.isNotEmpty()) { |
||||
|
processWithVolcanoAI(result) |
||||
|
} else { |
||||
|
isProcessing = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onSessionStarted() { |
||||
|
Log.d(TAG, "语音识别会话开始") |
||||
|
} |
||||
|
|
||||
|
override fun onSessionStopped() { |
||||
|
Log.d(TAG, "语音识别会话结束") |
||||
|
} |
||||
|
|
||||
|
override fun onCanceled(reason: String, errorDetails: String) { |
||||
|
Log.e(TAG, "语音识别取消: $reason, 详情: $errorDetails") |
||||
|
isRecording = false |
||||
|
isProcessing = false |
||||
|
} |
||||
|
|
||||
|
override fun onError(error: String) { |
||||
|
Log.e(TAG, "语音识别错误: $error") |
||||
|
isRecording = false |
||||
|
isProcessing = false |
||||
|
|
||||
|
// 播放错误提示音 |
||||
|
playErrorPrompt() |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 停止语音交互 |
||||
|
*/ |
||||
|
private fun stopVoiceInteraction() { |
||||
|
if (!isRecording) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 停止录音 |
||||
|
azureAsrHelper.stopContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { |
||||
|
override fun onResult(result: String) { |
||||
|
// 不处理结果 |
||||
|
} |
||||
|
|
||||
|
override fun onRecognizing(recognizing: String) { |
||||
|
// 不处理中间结果 |
||||
|
} |
||||
|
|
||||
|
override fun onSessionStarted() { |
||||
|
// 不处理会话开始事件 |
||||
|
} |
||||
|
|
||||
|
override fun onSessionStopped() { |
||||
|
Log.d(TAG, "语音识别会话已停止") |
||||
|
} |
||||
|
|
||||
|
override fun onCanceled(reason: String, errorDetails: String) { |
||||
|
Log.d(TAG, "语音识别已取消: $reason") |
||||
|
} |
||||
|
|
||||
|
override fun onError(error: String) { |
||||
|
Log.e(TAG, "停止语音识别错误: $error") |
||||
|
} |
||||
|
}) |
||||
|
isRecording = false |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 播放开始提示音 |
||||
|
*/ |
||||
|
private fun playStartPrompt() { |
||||
|
azureTtsHelper.speakText("我在听", object : AzureTtsHelper.TTSCallback { |
||||
|
override fun onSuccess(message: String) { |
||||
|
Log.d(TAG, "提示音播放完成") |
||||
|
} |
||||
|
|
||||
|
override fun onError(error: String) { |
||||
|
Log.e(TAG, "提示音播放错误: $error") |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 播放错误提示音 |
||||
|
*/ |
||||
|
private fun playErrorPrompt() { |
||||
|
azureTtsHelper.speakText("抱歉,我没有听清", object : AzureTtsHelper.TTSCallback { |
||||
|
override fun onSuccess(message: String) { |
||||
|
Log.d(TAG, "错误提示音播放完成") |
||||
|
} |
||||
|
|
||||
|
override fun onError(error: String) { |
||||
|
Log.e(TAG, "错误提示音播放错误: $error") |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 使用VolcanoAI处理语音识别结果 |
||||
|
*/ |
||||
|
private fun processWithVolcanoAI(text: String) { |
||||
|
// 使用原生VolcanoAIService处理 |
||||
|
Thread { |
||||
|
try { |
||||
|
// 创建用户消息 |
||||
|
val messages = JSONArray().apply { |
||||
|
put(volcanoAIService.createUserMessage(text)) |
||||
|
} |
||||
|
|
||||
|
// 发送请求到VolcanoAI |
||||
|
val response = volcanoAIService.sendMessage(messages, systemPrompt) |
||||
|
|
||||
|
// 播放AI回复 |
||||
|
speakAIResponse(response) |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "VolcanoAI处理错误: ${e.message}") |
||||
|
isProcessing = false |
||||
|
|
||||
|
// 播放错误提示音 |
||||
|
playErrorPrompt() |
||||
|
} |
||||
|
}.start() |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 播放AI回复 |
||||
|
*/ |
||||
|
private fun speakAIResponse(text: String) { |
||||
|
azureTtsHelper.speakText(text, object : AzureTtsHelper.TTSCallback { |
||||
|
override fun onSuccess(message: String) { |
||||
|
Log.d(TAG, "AI回复播放完成") |
||||
|
isProcessing = false |
||||
|
} |
||||
|
|
||||
|
override fun onError(error: String) { |
||||
|
Log.e(TAG, "AI回复播放错误: $error") |
||||
|
isProcessing = false |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 启动一个"前台通知",保证此Service在后台不会被系统回收 |
||||
|
*/ |
||||
|
private fun startForegroundServiceWithNotification() { |
||||
|
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager |
||||
|
|
||||
|
// Android 8.0+ 需要创建通知渠道 |
||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
||||
|
val channel = NotificationChannel( |
||||
|
CHANNEL_ID, |
||||
|
"语音交互服务", |
||||
|
NotificationManager.IMPORTANCE_LOW |
||||
|
) |
||||
|
manager.createNotificationChannel(channel) |
||||
|
} |
||||
|
|
||||
|
// 点击通知打开MainActivity |
||||
|
val intent = Intent(this, MainActivity::class.java).apply { |
||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP |
||||
|
} |
||||
|
val pendingIntent = PendingIntent.getActivity( |
||||
|
this, |
||||
|
0, |
||||
|
intent, |
||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT |
||||
|
) |
||||
|
|
||||
|
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID) |
||||
|
.setContentTitle("语音助手") |
||||
|
.setContentText("双击蓝牙耳机按键即可开始语音交互") |
||||
|
.setSmallIcon(R.mipmap.ic_launcher) |
||||
|
.setContentIntent(pendingIntent) |
||||
|
.build() |
||||
|
|
||||
|
startForeground(NOTIFICATION_ID, notification) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 当 Service 被启动时调用 |
||||
|
*/ |
||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { |
||||
|
Log.d(TAG, "onStartCommand: $intent") |
||||
|
|
||||
|
// 处理媒体按键 Intent |
||||
|
if (intent != null) { |
||||
|
// 如果是媒体按钮事件,尝试手动处理 |
||||
|
if (Intent.ACTION_MEDIA_BUTTON == intent.action) { |
||||
|
Log.d(TAG, "收到媒体按钮事件: $intent") |
||||
|
|
||||
|
// 根据 Android 版本使用不同的方式获取 KeyEvent |
||||
|
val keyEvent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { |
||||
|
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) |
||||
|
} else { |
||||
|
@Suppress("DEPRECATION") |
||||
|
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as? KeyEvent |
||||
|
} |
||||
|
|
||||
|
Log.d(TAG, "手动处理媒体按钮事件: $keyEvent") |
||||
|
if (keyEvent != null) { |
||||
|
handleKeyEvent(keyEvent) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 无论如何都要尝试让 MediaSession 处理 |
||||
|
MediaButtonReceiver.handleIntent(mediaSession, intent) |
||||
|
} |
||||
|
|
||||
|
return START_STICKY |
||||
|
} |
||||
|
|
||||
|
@Nullable |
||||
|
override fun onBind(intent: Intent?): IBinder? = null |
||||
|
|
||||
|
override fun onDestroy() { |
||||
|
Log.d(TAG, "onDestroy") |
||||
|
super.onDestroy() |
||||
|
|
||||
|
// 取消注册媒体按钮接收器 |
||||
|
val mediaButtonReceiver = ComponentName(packageName, MediaButtonReceiver::class.java.name) |
||||
|
audioManager.unregisterMediaButtonEventReceiver(mediaButtonReceiver) |
||||
|
|
||||
|
// 释放资源 |
||||
|
mediaSession.isActive = false |
||||
|
mediaSession.release() |
||||
|
|
||||
|
// 释放Azure服务 |
||||
|
azureAsrHelper.dispose() |
||||
|
azureTtsHelper.dispose() |
||||
|
|
||||
|
isRunning.set(false) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,324 @@ |
|||||
|
package com.example.deep_voice |
||||
|
|
||||
|
import android.content.Context |
||||
|
import android.util.Log |
||||
|
import okhttp3.* |
||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull |
||||
|
import okhttp3.RequestBody.Companion.toRequestBody |
||||
|
import org.json.JSONArray |
||||
|
import org.json.JSONObject |
||||
|
import java.io.IOException |
||||
|
import java.util.concurrent.CountDownLatch |
||||
|
import java.util.concurrent.TimeUnit |
||||
|
|
||||
|
/** |
||||
|
* 火山AI服务的原生实现 |
||||
|
* |
||||
|
* 参考Flutter端的VolcanoAIService实现,提供同步和异步的API调用方式 |
||||
|
*/ |
||||
|
class VolcanoAIService(private val context: Context) { |
||||
|
private val TAG = "VolcanoAIService" |
||||
|
private val baseUrl = "https://ark.cn-beijing.volces.com/api/v3" |
||||
|
private val chatEndpoint = "/chat/completions" |
||||
|
private val client = OkHttpClient.Builder() |
||||
|
.connectTimeout(30, TimeUnit.SECONDS) |
||||
|
.readTimeout(30, TimeUnit.SECONDS) |
||||
|
.writeTimeout(30, TimeUnit.SECONDS) |
||||
|
.build() |
||||
|
|
||||
|
private val apiKey: String |
||||
|
get() { |
||||
|
val sharedPreferences = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) |
||||
|
return sharedPreferences.getString("flutter.volcano_ai_api_key", "") ?: "" |
||||
|
} |
||||
|
|
||||
|
init { |
||||
|
if (apiKey.isEmpty()) { |
||||
|
Log.e(TAG, "火山AI配置信息不完整,请检查环境变量") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 生成个性化问候语 |
||||
|
* |
||||
|
* @param agentName 代理名称 |
||||
|
* @param systemPrompt 系统提示词 |
||||
|
* @param callback 回调函数,返回生成的问候语 |
||||
|
*/ |
||||
|
fun generateGreeting(agentName: String, systemPrompt: String, callback: (String?, Exception?) -> Unit) { |
||||
|
val messages = JSONArray().apply { |
||||
|
put(JSONObject().apply { |
||||
|
put("role", "system") |
||||
|
put("content", systemPrompt) |
||||
|
}) |
||||
|
put(JSONObject().apply { |
||||
|
put("role", "user") |
||||
|
put("content", "请用一句简短的话向我打个招呼,要符合你的身份和性格特点,不要超过18个字。") |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
sendMessageStream(messages, systemPrompt, object : StreamCallback { |
||||
|
val stringBuilder = StringBuilder() |
||||
|
|
||||
|
override fun onToken(token: String) { |
||||
|
stringBuilder.append(token) |
||||
|
} |
||||
|
|
||||
|
override fun onComplete() { |
||||
|
callback(stringBuilder.toString(), null) |
||||
|
} |
||||
|
|
||||
|
override fun onError(e: Exception) { |
||||
|
callback(null, e) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 发送消息(非流式输出) |
||||
|
* |
||||
|
* @param messages 消息列表 |
||||
|
* @param systemPrompt 系统提示词 |
||||
|
* @return 返回AI的回复 |
||||
|
* @throws VolcanoAIException 如果API调用失败 |
||||
|
*/ |
||||
|
@Throws(VolcanoAIException::class) |
||||
|
fun sendMessage(messages: JSONArray, systemPrompt: String): String { |
||||
|
val fullMessages = JSONArray().apply { |
||||
|
put(JSONObject().apply { |
||||
|
put("role", "system") |
||||
|
put("content", systemPrompt) |
||||
|
}) |
||||
|
for (i in 0 until messages.length()) { |
||||
|
put(messages.getJSONObject(i)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
val requestBody = JSONObject().apply { |
||||
|
put("model", "doubao-1-5-lite-32k-250115") |
||||
|
put("messages", fullMessages) |
||||
|
put("temperature", 0.7) |
||||
|
put("max_tokens", 2000) |
||||
|
put("stream", false) |
||||
|
} |
||||
|
|
||||
|
val mediaType = "application/json".toMediaTypeOrNull() |
||||
|
val request = Request.Builder() |
||||
|
.url("$baseUrl$chatEndpoint") |
||||
|
.addHeader("Content-Type", "application/json") |
||||
|
.addHeader("Authorization", "Bearer $apiKey") |
||||
|
.post(requestBody.toString().toRequestBody(mediaType)) |
||||
|
.build() |
||||
|
|
||||
|
try { |
||||
|
client.newCall(request).execute().use { response -> |
||||
|
if (!response.isSuccessful) { |
||||
|
val errorBody = response.body?.string() ?: "" |
||||
|
val errorMessage = try { |
||||
|
JSONObject(errorBody).getJSONObject("error").getString("message") |
||||
|
} catch (e: Exception) { |
||||
|
"Unknown error occurred" |
||||
|
} |
||||
|
throw VolcanoAIException(errorMessage) |
||||
|
} |
||||
|
|
||||
|
val responseBody = response.body?.string() ?: throw VolcanoAIException("Empty response") |
||||
|
val jsonResponse = JSONObject(responseBody) |
||||
|
|
||||
|
if (jsonResponse.has("choices") && |
||||
|
jsonResponse.getJSONArray("choices").length() > 0 && |
||||
|
jsonResponse.getJSONArray("choices").getJSONObject(0).has("message")) { |
||||
|
return jsonResponse.getJSONArray("choices") |
||||
|
.getJSONObject(0) |
||||
|
.getJSONObject("message") |
||||
|
.getString("content") |
||||
|
} |
||||
|
|
||||
|
throw VolcanoAIException("Invalid response format") |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
if (e is VolcanoAIException) throw e |
||||
|
throw VolcanoAIException("Failed to communicate with AI service: ${e.message}") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 发送消息(流式输出) |
||||
|
* |
||||
|
* @param messages 消息列表 |
||||
|
* @param systemPrompt 系统提示词 |
||||
|
* @param callback 回调函数,用于接收流式输出的结果 |
||||
|
*/ |
||||
|
fun sendMessageStream(messages: JSONArray, systemPrompt: String, callback: StreamCallback) { |
||||
|
val fullMessages = JSONArray().apply { |
||||
|
put(JSONObject().apply { |
||||
|
put("role", "system") |
||||
|
put("content", systemPrompt) |
||||
|
}) |
||||
|
for (i in 0 until messages.length()) { |
||||
|
put(messages.getJSONObject(i)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
val requestBody = JSONObject().apply { |
||||
|
put("model", "doubao-1-5-lite-32k-250115") |
||||
|
put("messages", fullMessages) |
||||
|
put("temperature", 0.7) |
||||
|
put("max_tokens", 2000) |
||||
|
put("stream", true) |
||||
|
} |
||||
|
|
||||
|
val mediaType = "application/json".toMediaTypeOrNull() |
||||
|
val request = Request.Builder() |
||||
|
.url("$baseUrl$chatEndpoint") |
||||
|
.addHeader("Content-Type", "application/json") |
||||
|
.addHeader("Authorization", "Bearer $apiKey") |
||||
|
.addHeader("Accept", "text/event-stream") |
||||
|
.post(requestBody.toString().toRequestBody(mediaType)) |
||||
|
.build() |
||||
|
|
||||
|
client.newCall(request).enqueue(object : Callback { |
||||
|
override fun onFailure(call: Call, e: IOException) { |
||||
|
callback.onError(VolcanoAIException("Failed to communicate with AI service: ${e.message}")) |
||||
|
} |
||||
|
|
||||
|
override fun onResponse(call: Call, response: Response) { |
||||
|
if (!response.isSuccessful) { |
||||
|
val errorBody = response.body?.string() ?: "" |
||||
|
val errorMessage = try { |
||||
|
JSONObject(errorBody).getJSONObject("error").getString("message") |
||||
|
} catch (e: Exception) { |
||||
|
"Unknown error occurred" |
||||
|
} |
||||
|
callback.onError(VolcanoAIException(errorMessage)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
val responseBody = response.body ?: return |
||||
|
val source = responseBody.source() |
||||
|
val bufferedSource = source.buffer |
||||
|
|
||||
|
try { |
||||
|
while (!bufferedSource.exhausted()) { |
||||
|
val line = bufferedSource.readUtf8Line() ?: continue |
||||
|
|
||||
|
if (line.isEmpty()) continue |
||||
|
if (line.startsWith("data: ")) { |
||||
|
val data = line.substring(6) |
||||
|
if (data == "[DONE]") { |
||||
|
callback.onComplete() |
||||
|
break |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
val jsonData = JSONObject(data) |
||||
|
if (jsonData.has("choices") && |
||||
|
jsonData.getJSONArray("choices").length() > 0 && |
||||
|
jsonData.getJSONArray("choices").getJSONObject(0).has("delta") && |
||||
|
jsonData.getJSONArray("choices").getJSONObject(0).getJSONObject("delta").has("content")) { |
||||
|
val content = jsonData.getJSONArray("choices") |
||||
|
.getJSONObject(0) |
||||
|
.getJSONObject("delta") |
||||
|
.getString("content") |
||||
|
callback.onToken(content) |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
// 忽略无效的JSON数据 |
||||
|
continue |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
callback.onError(VolcanoAIException("Error processing stream: ${e.message}")) |
||||
|
} finally { |
||||
|
response.close() |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 同步方式发送消息(流式输出) |
||||
|
* |
||||
|
* 注意:此方法会阻塞当前线程,请在后台线程中调用 |
||||
|
* |
||||
|
* @param messages 消息列表 |
||||
|
* @param systemPrompt 系统提示词 |
||||
|
* @return 返回完整的AI回复 |
||||
|
* @throws VolcanoAIException 如果API调用失败 |
||||
|
*/ |
||||
|
@Throws(VolcanoAIException::class) |
||||
|
fun sendMessageStreamSync(messages: JSONArray, systemPrompt: String): String { |
||||
|
val result = StringBuilder() |
||||
|
val latch = CountDownLatch(1) |
||||
|
var exception: Exception? = null |
||||
|
|
||||
|
sendMessageStream(messages, systemPrompt, object : StreamCallback { |
||||
|
override fun onToken(token: String) { |
||||
|
result.append(token) |
||||
|
} |
||||
|
|
||||
|
override fun onComplete() { |
||||
|
latch.countDown() |
||||
|
} |
||||
|
|
||||
|
override fun onError(e: Exception) { |
||||
|
exception = e |
||||
|
latch.countDown() |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
// 等待流式输出完成或出错 |
||||
|
latch.await(60, TimeUnit.SECONDS) |
||||
|
|
||||
|
if (exception != null) { |
||||
|
throw exception as VolcanoAIException |
||||
|
} |
||||
|
|
||||
|
return result.toString() |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 创建用户消息 |
||||
|
*/ |
||||
|
fun createUserMessage(content: String): JSONObject { |
||||
|
return JSONObject().apply { |
||||
|
put("role", "user") |
||||
|
put("content", content) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 创建系统消息 |
||||
|
*/ |
||||
|
fun createSystemMessage(content: String): JSONObject { |
||||
|
return JSONObject().apply { |
||||
|
put("role", "system") |
||||
|
put("content", content) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 创建助手消息 |
||||
|
*/ |
||||
|
fun createAssistantMessage(content: String): JSONObject { |
||||
|
return JSONObject().apply { |
||||
|
put("role", "assistant") |
||||
|
put("content", content) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 流式输出回调接口 |
||||
|
*/ |
||||
|
interface StreamCallback { |
||||
|
fun onToken(token: String) |
||||
|
fun onComplete() |
||||
|
fun onError(e: Exception) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 火山AI异常 |
||||
|
*/ |
||||
|
class VolcanoAIException(message: String) : Exception(message) |
||||
@ -0,0 +1,57 @@ |
|||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:get_storage/get_storage.dart'; |
||||
|
import 'package:shared_preferences/shared_preferences.dart'; |
||||
|
import 'package:logger/logger.dart'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
|
||||
|
/// 配置服务,用于管理应用配置和环境变量 |
||||
|
class ConfigService extends GetxService { |
||||
|
final Logger _logger = Logger(); |
||||
|
late final SharedPreferences _prefs; |
||||
|
|
||||
|
// 创建一个 MethodChannel 用于与原生代码通信 |
||||
|
static const MethodChannel _channel = MethodChannel('com.example.deep_voice/config'); |
||||
|
|
||||
|
// 初始化服务 |
||||
|
Future<ConfigService> init() async { |
||||
|
try { |
||||
|
_prefs = await SharedPreferences.getInstance(); |
||||
|
|
||||
|
// 从 .env 读取 Azure 语音服务配置 |
||||
|
final azureSpeechKey = dotenv.env['AZURE_ASR_SUBSCRIPTION_KEY']; |
||||
|
final azureSpeechRegion = dotenv.env['AZURE_ASR_SERVICE_REGION']; |
||||
|
|
||||
|
// 从 .env 读取火山 AI API Key |
||||
|
final volcanoAiApiKey = dotenv.env['VOLCANO_AI_API_KEY']; |
||||
|
|
||||
|
|
||||
|
// 通过 MethodChannel 向原生代码传递配置值 |
||||
|
try { |
||||
|
await _channel.invokeMethod('setConfig', { |
||||
|
'azure_speech_key': azureSpeechKey ?? '', |
||||
|
'azure_speech_region': azureSpeechRegion ?? '', |
||||
|
'volcano_ai_api_key': volcanoAiApiKey ?? '', |
||||
|
}); |
||||
|
_logger.i('已通过 MethodChannel 向原生代码传递配置值'); |
||||
|
} catch (e) { |
||||
|
_logger.e('通过 MethodChannel 传递配置值失败: $e'); |
||||
|
} |
||||
|
|
||||
|
return this; |
||||
|
} catch (e) { |
||||
|
_logger.e('初始化配置服务失败: $e'); |
||||
|
rethrow; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取配置值 |
||||
|
String? getString(String key) { |
||||
|
return _prefs.getString(key); |
||||
|
} |
||||
|
|
||||
|
// 设置配置值 |
||||
|
Future<bool> setString(String key, String value) { |
||||
|
return _prefs.setString(key, value); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue