从零开发 AR 演讲提词器:基于 Rokid CXR-M SDK 的实战指南

从零开发 AR 演讲提词器:基于 Rokid CXR-M SDK 的实战指南

站在讲台上,数百双眼睛注视着你。你开始演讲,却发现关键时刻想不起下一句要说什么------这种场景,每个演讲者都不陌生。

传统的解决方案是在讲台上放一张稿子,或者用 PPT 做备注。但低头看稿显得不专业,看 PPT 又要扭头,容易打断演讲节奏。如果能有一个只有自己能看到的"隐形提词器",演讲就能更加从容自信。

Rokid AR 眼镜恰好提供了这种可能:将提词内容无线传输到眼镜显示屏,演讲者只需自然平视,文字便清晰呈现,而台下观众毫无察觉。本文将完整记录如何利用 Rokid CXR-M SDK 从零开发这款演讲提词器应用。

一、技术方案设计

1.1 为什么选择 AR 眼镜

在确定技术方案前,我们先对比几种提词方案:

方案 优点 缺点
纸质稿 简单、可靠 低头看稿不专业,翻页有声响
手机/平板 便携 需要低头,屏幕反光
专业提词器 效果好 设备昂贵,需要提前架设
AR 眼镜 隐蔽、便携、平视 需要设备支持

AR 眼镜的核心优势在于隐蔽性------观众完全察觉不到你在看提词,这与专业电视台主播使用的提词器效果类似,但成本和使用门槛大大降低。

1.2 为什么选择 CXR-M SDK

Rokid 提供了多套 SDK,我选择 CXR-M SDK(客户端模式)的原因:

  1. 内置提词器场景 :SDK 提供了 WORD_TIPS 场景类型,专门用于文字提示,无需自己实现渲染逻辑
  1. 蓝牙直连:手机与眼镜通过蓝牙通信,无需额外中转设备
  1. 流式传输:支持文本流发送,适合分页内容
  1. 开发门槛低:纯 Android 开发,无需学习 3D 引擎

1.3 系统架构

整个应用采用三层架构:

二、开发环境搭建

2.1 创建项目

使用 Android Studio 创建一个新项目,选择 Empty Views Activity 模板。项目配置如下:

ini 复制代码
// app/build.gradle.kts
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "com.rokid.speech"
    compileSdk = 34

    defaultConfig {
        applicationId = "com.rokid.speechprompter"
        minSdk = 28
        targetSdk = 34
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
    }

    kotlinOptions {
        jvmTarget = "17"
    }
}

2.2 添加 SDK 依赖

Rokid 的 SDK 托管在其私有 Maven 仓库,需要在 settings.gradle.kts 中添加仓库地址:

scss 复制代码
// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://maven.rokid.com/repository/maven-public/") }
    }
}

然后在 app/build.gradle.kts 中添加依赖:

scss 复制代码
dependencies {
    // Rokid CXR-M SDK
    implementation("com.rokid.cxr:client-m:1.0.1-20250812.080117-2")

    // Android 基础库
    implementation("androidx.core:core-ktx:1.12.0")
    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation("com.google.android.material:material:1.11.0")
    implementation("androidx.constraintlayout:constraintlayout:2.1.4")
}

2.3 配置蓝牙权限

眼镜通过蓝牙与手机通信,需要声明相关权限:

xml 复制代码
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

注意 :Android 12+ 需要动态申请 BLUETOOTH_SCANBLUETOOTH_CONNECT 权限。

三、SDK 封装层实现

直接在业务代码中调用 SDK 会导致代码耦合度高、难以测试。我设计了一个 RokidGlassesManager 单例来封装所有眼镜交互逻辑。

3.1 连接管理

首先定义连接状态回调接口:

kotlin 复制代码
// RokidGlassesManager.kt
object RokidGlassesManager {

    private val cxrApi: CxrApi by lazy { CxrApi.getInstance() }
    private var connectionCallback: ConnectionCallback? = null

    interface ConnectionCallback {
        fun onConnecting()
        fun onConnected()
        fun onDisconnected()
        fun onFailed(errorMsg: String)
    }

    val isConnected: Boolean
        get() = cxrApi.isBluetoothConnected

    fun setConnectionCallback(callback: ConnectionCallback?) {
        this.connectionCallback = callback
    }
}

3.2 查找已配对设备

用户在使用前需要先在系统设置中将眼镜与手机配对。我们的应用从已配对设备列表中查找 Rokid 眼镜:

kotlin 复制代码
fun findRokidGlasses(bluetoothAdapter: BluetoothAdapter): BluetoothDevice? {
    if (ActivityCompat.checkSelfPermission(
            bluetoothAdapter.javaClass,
            Manifest.permission.BLUETOOTH_CONNECT
        ) != PackageManager.PERMISSION_GRANTED
    ) {
        return null
    }
    return bluetoothAdapter.bondedDevices.find {
        it.name?.contains("Rokid", ignoreCase = true) == true ||
        it.name?.contains("Glasses", ignoreCase = true) == true
    }
}

3.3 建立连接

调用 SDK 的 initBluetooth 方法建立连接:

kotlin 复制代码
fun connectGlasses(context: Context, device: BluetoothDevice) {
    connectionCallback?.onConnecting()

    cxrApi.initBluetooth(context, device, object : BluetoothStatusCallback() {
        override fun onConnectionInfo(
            socketUuid: String?,
            macAddress: String?,
            rokidAccount: String?,
            glassesType: Int
        ) {
            // 连接信息获取成功
        }

        override fun onConnected() {
            connectionCallback?.onConnected()
        }

        override fun onDisconnected() {
            connectionCallback?.onDisconnected()
        }

        override fun onFailed(errorCode: ValueUtil.CxrBluetoothErrorCode?) {
            connectionCallback?.onFailed(errorCode?.name ?: "连接失败")
        }
    })
}

3.4 发送内容到眼镜

这是核心功能------将提词内容发送到眼镜显示:

kotlin 复制代码
interface SendCallback {
    fun onSuccess()
    fun onFailed(errorMsg: String)
}

fun sendContent(text: String, callback: SendCallback? = null): Boolean {
    if (!isConnected) {
        callback?.onFailed("眼镜未连接")
        return false
    }

    // 1. 启用提词器场景
    cxrApi.controlScene(ValueUtil.CxrSceneType.WORD_TIPS, true, null)

    // 2. 发送文本流
    val status = cxrApi.sendStream(
        type = ValueUtil.CxrStreamType.WORD_TIPS,
        stream = text.toByteArray(Charsets.UTF_8),
        fileName = "speech.txt",
        cb = object : SendStatusCallback() {
            override fun onSendSucceed() {
                callback?.onSuccess()
            }
            override fun onSendFailed(errorCode: ValueUtil.CxrSendErrorCode?) {
                callback?.onFailed(errorCode?.name ?: "发送失败")
            }
        }
    )

    return status == ValueUtil.CxrStatus.REQUEST_SUCCEED
}

代码要点说明

  • controlScene(WORD_TIPS, true, null) 告诉眼镜启用提词器场景
  • sendStream 将文本以 UTF-8 编码发送
  • 回调在子线程执行,更新 UI 时需要切回主线程

四、业务逻辑实现

4.1 数据模型设计

定义演讲稿的数据结构:

kotlin 复制代码
// Speech.kt
data class Speech(
    val id: Int,
    val title: String,
    val content: String,
    val createdAt: Long = System.currentTimeMillis()
)

为了演示,我创建了一个内存数据源(实际项目中可使用 Room 数据库):

ini 复制代码
object SpeechData {
    val speeches = listOf(
        Speech(
            id = 1,
            title = "年度工作汇报",
            content = """各位领导、各位同事:
                |
                |大家好!2025年是我部门快速发展的一年...
                |""".trimMargin()
        ),
        Speech(
            id = 2,
            title = "产品发布演示",
            content = "今天给大家介绍我们的最新产品..."
        )
    )

    fun getSpeech(id: Int): Speech? = speeches.find { it.id == id }
}

4.2 智能分页算法

这是整个应用的核心算法。演讲稿不能简单地按字符数切割,因为:

  1. 不能在句子中间断开
  1. 要保持段落的语义完整
  1. 每页内容要适中,便于阅读

我设计的分页策略是:按段落优先,单段过长时按句子分割

scss 复制代码
// MainActivity.kt
private fun splitContent(content: String): List<String> {
    val result = mutableListOf<String>()
    val lines = content.split("\n")
    val currentParagraph = StringBuilder()
    var charCount = 0

    for (line in lines) {
        // 当当前段落超过100字符且新行会超限时,开始新页
        if (charCount + line.length > 100 && currentParagraph.isNotEmpty()) {
            result.add(currentParagraph.toString().trim())
            currentParagraph.clear()
            charCount = 0
        }
        currentParagraph.append(line).append("\n")
        charCount += line.length
    }

    // 添加最后一段
    if (currentParagraph.isNotEmpty()) {
        result.add(currentParagraph.toString().trim())
    }

    return result
}

4.3 构建眼镜显示格式

发送到眼镜的内容需要格式化,包含标题、页码、正文和计时:

kotlin 复制代码
private fun buildDisplayText(speech: Speech, paragraph: String): String {
    return buildString {
        appendLine("📝 ${speech.title}")
        appendLine()
        appendLine("────── ${currentParagraph + 1}/${paragraphs.size} ──────")
        appendLine()
        appendLine(paragraph)
        appendLine()
        appendLine("⏱ ${formatElapsedTime(elapsedTime)}")
        appendLine()
        appendLine("◀ 上页  下页 ▶")
    }
}

private fun formatElapsedTime(millis: Long): String {
    val minutes = millis / (1000 * 60)
    val seconds = (millis / 1000) % 60
    return String.format("%02d:%02d", minutes, seconds)
}

五、界面开发

5.1 主界面布局

主界面分为三个区域:演讲稿列表、内容显示区、眼镜控制区:

xml 复制代码
<!-- activity_main.xml -->
<LinearLayout
    android:orientation="vertical"
    android:padding="16dp">

    <!-- 演讲稿列表 -->
    <TextView text="选择演讲稿" />
    <RecyclerView android:id="@+id/rvSpeeches"
        android:layout_weight="1" />

    <!-- 内容显示区(初始隐藏) -->
    <CardView android:id="@+id/controlPanel"
        android:visibility="gone">

        <TextView android:id="@+id/tvTitle" />
        <TextView android:id="@+id/tvPage" />
        <TextView android:id="@+id/tvTimer" />
        <ScrollView>
            <TextView android:id="@+id/tvContent" />
        </ScrollView>

        <!-- 翻页按钮 -->
        <Button android:id="@+id/btnPrev" text="◀ 上一页" />
        <Button android:id="@+id/btnNext" text="下一页 ▶" />

    </CardView>

    <!-- 眼镜控制区 -->
    <CardView>
        <Button android:id="@+id/btnConnect" text="连接眼镜" />
        <Button android:id="@+id/btnSend" text="📤 发送到眼镜" />
    </CardView>

</LinearLayout>

5.2 列表适配器

kotlin 复制代码
// SpeechAdapter.kt
class SpeechAdapter(
    private val speeches: List<Speech>,
    private val onItemClickListener: (Speech) -> Unit
) : RecyclerView.Adapter<SpeechAdapter.ViewHolder>() {

    private var selectedPosition = -1

    inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val tvTitle: TextView = view.findViewById(R.id.tvTitle)
        val tvPreview: TextView = view.findViewById(R.id.tvPreview)

        init {
            view.setOnClickListener {
                val position = bindingAdapterPosition
                if (position != RecyclerView.NO_POSITION) {
                    selectedPosition = position
                    onItemClickListener(speeches[position])
                }
            }
        }
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val speech = speeches[position]
        holder.tvTitle.text = speech.title
        holder.tvPreview.text = speech.content.take(50) + "..."
    }

    override fun getItemCount() = speeches.size
}

5.3 主 Activity 逻辑

在 Activity 中串联所有组件:

kotlin 复制代码
class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private var currentSpeech: Speech? = null
    private var currentParagraph = 0
    private var paragraphs: List<String> = emptyList()
    private var isRunning = false
    private var startTime: Long = 0

    // 翻页防抖
    private var lastPageChangeTime = 0L

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        setupSpeechList()
        setupButtons()
        checkPermissions()
        observeConnection()
    }

    private fun loadSpeech(speech: Speech) {
        currentSpeech = speech
        currentParagraph = 0
        paragraphs = splitContent(speech.content)
        updateDisplay()
        binding.controlPanel.visibility = View.VISIBLE
    }

    private fun previousParagraph() {
        if (currentParagraph > 0) {
            currentParagraph--
            updateDisplay()
            if (RokidGlassesManager.isConnected) {
                sendToGlasses()
            }
        }
    }

    private fun nextParagraph() {
        if (currentParagraph < paragraphs.size - 1) {
            currentParagraph++
            updateDisplay()
            if (RokidGlassesManager.isConnected) {
                sendToGlasses()
            }
        }
    }

    private fun sendToGlasses() {
        val speech = currentSpeech ?: return
        val paragraph = paragraphs.getOrNull(currentParagraph) ?: return
        val displayText = buildDisplayText(speech, paragraph)

        RokidGlassesManager.sendContent(displayText, object : RokidGlassesManager.SendCallback {
            override fun onSuccess() {
                runOnUiThread {
                    Toast.makeText(this@MainActivity, "已同步到眼镜", Toast.LENGTH_SHORT).show()
                }
            }
            override fun onFailed(errorMsg: String) {
                runOnUiThread {
                    Toast.makeText(this@MainActivity, "发送失败: $errorMsg", Toast.LENGTH_SHORT).show()
                }
            }
        })
    }
}

六、开发中的踩坑与解决

6.1 翻页防抖

问题:用户快速点击翻页按钮会导致跳过多页。

解决:添加时间间隔判断:

ini 复制代码
private var lastPageChangeTime = 0L

binding.btnNext.setOnClickListener {
    if (System.currentTimeMillis() - lastPageChangeTime > 300) {
        lastPageChangeTime = System.currentTimeMillis()
        nextParagraph()
    }
}

6.2 演讲时屏幕常亮

问题:演讲过程中手机自动休眠,导致蓝牙断开。

解决:在 Activity 中添加标志:

kotlin 复制代码
override fun onCreate(savedInstanceState: Bundle?) {
    window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
    // ...
}

6.3 权限动态申请

问题:Android 12+ 需要动态申请蓝牙权限。

解决

kotlin 复制代码
private fun checkPermissions() {
    val permissions = mutableListOf<String>()

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        permissions.add(Manifest.permission.BLUETOOTH_SCAN)
        permissions.add(Manifest.permission.BLUETOOTH_CONNECT)
    }

    val notGranted = permissions.filter {
        ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
    }

    if (notGranted.isNotEmpty()) {
        ActivityCompat.requestPermissions(this, notGranted.toTypedArray(), 100)
    }
}

6.4 回调线程问题

问题:SDK 回调在子线程执行,直接更新 UI 会崩溃。

解决 :使用 runOnUiThread 切换线程:

kotlin 复制代码
override fun onConnected() {
    runOnUiThread {
        binding.btnConnect.text = "断开连接"
        Toast.makeText(this@MainActivity, "眼镜已连接", Toast.LENGTH_SHORT).show()
    }
}

七、效果演示

7.1 使用流程

  1. 打开应用,从列表选择要演讲的稿件
  1. 点击「连接眼镜」,等待配对的 Rokid 眼镜连接成功
  1. 点击「开始演讲」,计时器启动
  1. 演讲过程中点击「上一页」「下一页」控制内容
  1. 内容自动同步到眼镜,平视即可看到提词

7.2 眼镜端显示效果

复制代码
┌──────────────────────────────┐
│  📝 年度工作汇报              │
│                               │
│  ────── 1/5 ──────            │
│                               │
│  各位领导、各位同事:          │
│                               │
│  大家好!2025年是我部门        │
│  快速发展的一年,在全体成员    │
│  的共同努力下,我们取得了      │
│  显著的成绩...                │
│                               │
│  ⏱ 02:35                      │
│                               │
│  ◀ 上页  下页 ▶               │
└──────────────────────────────┘

八、功能清单

功能 状态 说明
演讲稿管理 列表展示、选择切换
智能分页 保持段落语义完整
翻页控制 上一页/下一页,带防抖
眼镜连接 蓝牙配对、状态监听
内容同步 实时发送到眼镜
计时功能 开始/暂停,显示已用时间

九、后续改进方向

当前版本已实现核心功能,后续可考虑增强:

  1. 语音翻页:集成语音识别,说"下一页"自动翻页,双手彻底解放
  1. 时间预警:设定预计时长,超时震动提醒
  1. 文档导入:支持导入 Word、PDF、TXT 文件
  1. 云端同步:演讲稿云端存储,多设备共享
  1. 演讲分析:记录翻页节奏,分析演讲速度

十、总结

这款演讲提词器利用 Rokid AR 眼镜的 WORD_TIPS 场景,实现了"隐形提词"的效果。整个开发过程让我对 CXR-M SDK 有了深入理解:

  • 场景化设计:SDK 预置的场景类型降低了开发难度,开发者只需关注业务逻辑
  • 流式传输sendStream API 设计简洁,适合分页内容的实时推送
  • 连接管理:蓝牙连接的复杂性被封装在 SDK 内部,回调机制清晰

AR 眼镜在办公场景有着广阔的应用空间。除了演讲提词,还可以用于实时翻译、会议记录、远程协助等场景。希望这篇文章能给其他开发者带来启发,探索更多 AR 应用的可能性。


项目源码SpeechPrompter/

参考资源

相关推荐
BBTSOH1590151604412 天前
VR每日热点简报2026.2.24
人工智能·meta·vr·虚拟现实·热点
mtouch33313 天前
三维数字沙盘智能交互式可视化动态主界面系统
人工智能·ai·信息可视化·无人机·虚拟现实·电子沙盘·数字沙盘
mtouch33316 天前
三维沙盘系统配置管理数字沙盘模块
人工智能·ai·ar·vr·虚拟现实·电子沙盘·数字沙盘
mtouch33317 天前
三维电子沙盘模型全参数化精准调控数字沙盘系统
人工智能·ai·虚拟现实·电子沙盘·数字沙盘·增强现实·军事指挥沙盘
星幻元宇VR1 个月前
5D动感影院,科技与沉浸式体验的完美融合
人工智能·科技·虚拟现实
星幻元宇VR1 个月前
消防数字展厅智能升级|AR消防巡检员体验系统
学习·安全·ar·虚拟现实
星幻元宇VR1 个月前
消防安全教育展厅设备|消防器材装备3D展示系统
安全·3d·虚拟现实
搞科研的小刘选手1 个月前
【虚拟现实/人机交互会议】第二届人工智能、虚拟现实与交互设计国际学术会议(AIVRID)
大数据·人工智能·计算机·aigc·虚拟现实·国际学术会议·交互技术
星幻元宇VR1 个月前
公共安全展厅一体机设备,跨步电压安全体验系统
学习·安全·虚拟现实