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

从零开发 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 场景类型,专门用于文字提示,无需自己实现渲染逻辑
  2. 蓝牙直连:手机与眼镜通过蓝牙通信,无需额外中转设备
  3. 流式传输:支持文本流发送,适合分页内容
  4. 开发门槛低:纯 Android 开发,无需学习 3D 引擎

1.3 系统架构

整个应用采用三层架构:

二、开发环境搭建

2.1 创建项目

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

// 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 中添加仓库地址:

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

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

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 配置蓝牙权限

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

<!-- 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 连接管理

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

// 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 眼镜:

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 方法建立连接:

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 发送内容到眼镜

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

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 数据模型设计

定义演讲稿的数据结构:

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

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

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

4.2 智能分页算法

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

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

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

// 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 构建眼镜显示格式

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

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 主界面布局

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

<!-- 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 列表适配器

// 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 中串联所有组件:

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 翻页防抖

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

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

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

6.2 演讲时屏幕常亮

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

解决:在 Activity 中添加标志:

override fun onCreate(savedInstanceState: Bundle?) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) // ... }

6.3 权限动态申请

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

解决

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 切换线程:

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

七、效果演示

7.1 使用流程

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

7.2 眼镜端显示效果

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

八、功能清单

功能

状态

说明

演讲稿管理

列表展示、选择切换

智能分页

保持段落语义完整

翻页控制

上一页/下一页,带防抖

眼镜连接

蓝牙配对、状态监听

内容同步

实时发送到眼镜

计时功能

开始/暂停,显示已用时间

九、后续改进方向

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

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

十、总结

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

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

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


项目源码SpeechPrompter/

参考资源

Read more

Z-Image-Turbo虚拟现实场景资产创建路径

Z-Image-Turbo虚拟现实场景资产创建路径 虚拟现实内容生产的挑战与AI破局 虚拟现实(VR)内容开发长期面临高成本、长周期、低复用性的三大瓶颈。传统3D建模流程依赖专业美术团队手工制作纹理、材质和环境贴图,单个高质量场景资产的制作周期往往需要数天甚至数周。随着元宇宙和沉浸式体验需求激增,行业亟需一种高效、可扩展的内容生成范式。 阿里通义实验室推出的 Z-Image-Turbo WebUI 图像快速生成模型,为这一难题提供了突破性解决方案。该模型基于扩散机制优化,在保持高图像质量的同时实现极快推理速度(最快1步生成),特别适合批量生产VR所需的高清环境贴图、角色概念图和材质资源。本文将深入解析由开发者“科哥”二次开发的Z-Image-Turbo定制版本,如何构建一条高效的虚拟现实场景资产自动化生成路径。 Z-Image-Turbo核心能力解析:为何适用于VR资产生成? 高分辨率支持与细节保真 VR场景对图像分辨率要求极高,通常需达到1024×1024以上以避免头显中的像素化现象。Z-Image-Turbo原生支持最高2048×2048输出,并在1024×1024

从零开始:OpenClaw安装+飞书机器人全流程配置指南(附踩坑实录)

从零开始:OpenClaw 安装 + 飞书机器人全流程配置指南(附踩坑实录) 本文面向完全零基础的小白,手把手带你从一台干净的 Linux 机器开始,安装 OpenClaw、配置 AI 模型、对接飞书机器人,最终实现在飞书里和 AI 直接对话。全程附带我自己踩过的坑和解决方案。 目录 * 一、OpenClaw 是什么? * 二、环境准备 * 三、安装 OpenClaw * 四、初始配置(onboard 向导) * 五、飞书机器人配置全流程 * 六、踩坑实录 & 避坑指南 * 七、验证一切正常 * 八、进阶:常用命令速查 一、OpenClaw 是什么? OpenClaw 是一个开源的 AI Agent

Flutter 三方库 modular_core 大型应用级鸿蒙微服务化架构适配解析:纵深拆解路由控制组件化隔离网格,利用轻量级依赖注入中枢斩断应用深层耦合羁绊-适配鸿蒙 HarmonyOS ohos

Flutter 三方库 modular_core 大型应用级鸿蒙微服务化架构适配解析:纵深拆解路由控制组件化隔离网格,利用轻量级依赖注入中枢斩断应用深层耦合羁绊-适配鸿蒙 HarmonyOS ohos

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.ZEEKLOG.net Flutter 三方库 modular_core 大型应用级鸿蒙微服务化架构适配解析:纵深拆解路由控制组件化隔离网格,利用轻量级依赖注入中枢斩断应用深层耦合羁绊 在构建超大型、多业务线的鸿蒙应用时,代码的模块化分层与解耦是决定项目成败的关键。modular_core 作为 flutter_modular 的核心逻辑库,提供了一套纯粹的依赖注入(DI)和模块生命周期管理机制。本文将深入解析该库在 OpenHarmony 上的适配与应用实践。 前言 什么是 modular_core?它不是一个 UI 框架,而是一套管理“对象如何创建”和“模块如何组织”的底层协议。在鸿蒙操作系统这种强调模块化分发(HAP/HSP)和细粒度原子化服务的生态中,利用 modular_core 可以帮助开发者构建出高内聚、低耦合的系统底座。本文将指导你如何在鸿蒙端侧实现模块的动态注入与回收。 一、

跨越天堑:机器人脑部药物递送三大技术路径的可转化性分析研究

跨越天堑:机器人脑部药物递送三大技术路径的可转化性分析研究

摘要 血脑屏障是中枢神经系统药物研发最核心的瓶颈。尽管相关基础研究层出不穷,但“论文成果显著、临床转化缓慢”的悖论依然存在。本文认为,突破这一瓶颈的关键在于,将研究重心从“单点机制”转向构建一条“可验证、可复现、可监管”的全链条递送系统。为此,本文提出了一个衡量脑部递送技术可转化性的四维评价标尺:剂量可定义、闭环可监测、质控可标准化、可回退。基于此标尺,本文深度剖析了当前最具潜力的三条技术路径: (1)FUS/低强度聚焦超声联合微泡; (2)血管内可导航载体/机器人; (3)针对胶质母细胞瘤(GBM)的多功能纳米系统。 通过精读关键临床试验、前沿工程研究和系统综述,我们抽离出可直接写入临床或产品方案的核心变量,识别了各自面临的最大转化风险,并提出了差异化的“押注”策略。分析表明,FUS+MB路径因其在“工程控制”上的成熟度,在近期(12-24个月)的转化确定性最高;血管内机器人代表了精准制导的未来趋势,