Rodger-Wang
|
f9b9c8c72d
|
client: 翻译默认归档进语音纪要;列表改本地优先;登录按包收敛;EMAI 走 A2DP
## 通话/同声/面对面翻译 → 语音纪要
三种翻译模式默认边翻边录,结束时把音频**连同已有转写**一起归档
(rtype=VOICETRANSLAT),服务端跳过识别、直接用智能模板(General-Autopilot)总结,
在会议纪要里就能看到。转写段的构造见 translation_export.dart(纯函数,带金测试):
- 时间是**相对录音起点的毫秒**并扣掉暂停区间——面对面是逐句按住说话、松开就暂停的,
不扣的话越往后偏得越多;写成绝对时间戳会让点一句跳到音频末尾之外;
- 说话人用 Speaker_0/Speaker_1,与服务端转写同一口径(转写页直接把这个字段当文案显示,
裸 0/1 会和别的纪要长得不一样,喂给 LLM 抽待办时也会混出 Speaker2 这种负责人);
- AST socket 断开后 _terminateCallOnError 会取消事件订阅,重开时收不到任何转写
(真机上表现为「有日志但转写 0 段」),改成重新订阅。
## 语音纪要列表:服务端是真源、本地 sqlite 是缓存
列表接口改回骨架字段后(服务端同批),客户端改成本地优先渲染 + 后台对齐:
- Synchrodata 重写:拉骨架 → upsert → 删掉「本地有服务端没有」的记录(连本地音频、
上传队列、轮询任务一起清)。**业务错误时 data 是 null 不是空列表**,判空错了
会把本地整个清空;
- 详情进页面时才拉整条;离线操作队列那套上行是死代码(服务端接口整个注释掉了),删除。
## 登录按包收敛
苹果包只留苹果+游客、安卓包只留微信+游客、谷歌包只留邮箱+游客。
判定抽成 resolveLoginMethods 纯函数(login_methods.dart + 8 条测试),
邮箱包还要锁死国家区号选择。
## EMAI 与其它
- 手机侧触发的对话走 A2DP 而不是语音处理通道:iOS 上 A2DP 输出与 voiceChat 模式
互斥,开了后者蓝牙耳机会当场切到手机扬声器。按当前实际输出路由决定要不要开
voiceProcessing/回声消除;
- EMAI「开始对话」闪退:探测输入格式时 new 了一个一次性 AVAudioEngine 去读 inputNode,
配置变更时崩在 AVAudioIONodeImpl(EXC_BAD_ACCESS)。改成直接问 AVAudioSession;
- 语音纪要文件名可改:每行右侧加铅笔图标,复用已有的重命名弹窗(原来只有长按菜单,
实际入口已不可达);
- 拾忆上报 IANA 时区名:DateTime.now().timeZoneName 给的是「CST」这种缩写,
服务端 LoadLocation 解析不了、静默退回容器时区,海外用户的提醒时刻会全按北京时间算。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
3 weeks ago |
Rodger-Wang
|
41e3a023a3
|
client: 蓝牙名白名单(EaiRec/EaiCar);连接即按账号登记绑定;iOS 崩溃加固;弱网翻译超时
- 设备名闸门:支架只认 EaiRec/EAIMAR/Pad Note,车载香薰只认 EaiCar,固件名对不上直接不连;
一次性清掉此前被归一化名字污染的配对记录;Smartcar 资源与 agent 命名统一改为 EaiCar
- 绑定登记:新增 DeviceBindRegistry(按 uid|MAC 去重),支架也进 verifyConnected,
登录态从无到有时补报一次,换账号不再漏切绑定
- iOS 崩溃加固:补 NSPhotoLibraryAddUsageDescription(保存思维导图/统计图必崩);
StsAgent 先配音频会话再 installTap 并校验格式;支架录音两处 installTap 加 0Hz 守卫;
AzureTtsHelper 播放队列改加锁队列(三线程裸改 Array,release 下崩);
MicrophoneCapture 主线程不再用信号量等权限弹窗(看门狗误杀)
- 网络:connectionError 给中文文案;实时翻译请求 12s 超时,不再跟 30s 默认值等
- 附:AI 助手中枢设计文档 v0.2(未动代码)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
4 weeks ago |
Rodger-Wang
|
27cabe1bde
|
client: 设备内核抽象、Smartcar 车载香薰接入与语音纪要等一批改动
工作区里累积的 Flutter 客户端改动,随本轮一并入库。主要涉及:
- lib/devices/ 设备内核:DeviceHub / DevicePlugin / DeviceSession 抽象,
恒玄(bes)、EaiRec 支架(holder)、Smartcar 车载香薰(smartcar) 各自的插件实现,
业务层不再直接依赖任何厂商类;分层边界由 test/devices/ 下的测试守着
- local_plugins/device_plugin_interface、device_smartcar、recorder_holder:
纯 Dart 契约与原生侧实现
- lib/modules/ 能力模块注册表、语音纪要、OTA 升级等模块
- test/ 新增设备协议、Ogg Opus 时长、模块注册表、路由等金测试
- ios/sync_spm_platform.sh:修 Xcode 直接构建时 SPM 平台版本被打回 13.0 的问题
验证:dart analyze lib 无 error(264 条 warning 均为 HEAD 上既有的 unused_import /
avoid_print 等 lint,非本次引入)。未跑真机与打包。
注:这批不是本次会话中由 Claude 编写的,此处仅做入库。
|
4 weeks ago |
Rodger-Wang
|
110cbcb026
|
prod-deploy: 阿龙段的 SSH 私钥路径改为可被环境变量覆盖
三个 prod-deploy.sh 的 along 段都写死 /Users/liwei1dao/liwei/密钥/...,
该路径只在原作者机器上存在,换一台机就跑不了部署。改成
${DEPLOY_KEY:-<原路径>},默认值不变(原机器行为不受影响),
其它机器用 DEPLOY_KEY=~/xxx.pem ./prod-deploy.sh 即可。
写法与 dev-deploy.sh 的 along 段保持一致。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
1 month ago |
Rodger-Wang
|
abd0922d78
|
设备图改成按设备名从本地取;修同传页 GetX 报错遮挡与 AI 页 2px 溢出
## 设备图:关键字映射 → 按名字取图
原来是一张写死的映射表(名字里含 echo-one/deepvoice/buds… 就用 earphone.png),
加一个产品就要回代码里加一条。改成直接按设备名找
`assets/images/devices/<设备名>.png` 与 `<设备名>_icon.png`,
加新产品只要按这个命名把图丢进目录——pubspec 里该目录是**按目录**声明的,
新文件自动打包,代码和配置都不用动。
四个展示点统一到 DeviceImages:设备页主图、设备管理列表小图标、
恒玄固件升级页、杰理固件升级页。其中:
- 设备管理小图标原来是 Material 图标 + 紫色渐变块,现在换成产品实拍图,
底色仍区分连接状态(换成图之后原来靠渐变色传达的「这台连着」就没了)。
⚠️ 这一页取图用的是**服务端下发的 devicename** 而不是当前蓝牙广播名——
它列的是绑定关系,可能包含此刻没连上的设备,用当前连接名会让所有行显示成同一台。
- 杰理固件升级页原来拉服务端 productimage 网络图,一并去掉:产品图统一走本地,
留着会出现「同一台设备在设备页显示本地图、在升级页显示另一张网络图」。
匹配三级,从严到宽:完全相同 → 忽略大小写 → 最长前缀(兜 `Echo-one_L`
这类带后缀的广播名)。第三级必须取**最长**匹配,否则目录里同时有 `Echo` 和
`Echo-one` 时前者会把后者的设备抢走。
启动时读一次 AssetManifest(main.dart)记下目录里实际有哪些图。不做这一步就只能
靠 Image.asset 的 errorBuilder 在渲染失败时兜底,那是**静默**的——美术漏出一张图
或命名对不上,界面直接变通用图,没有任何地方看得出来。现在会打 warn。
资源侧:earphone.png → Echo-one.png(同一张耳机实拍图,只是改名以适配新规则),
补上缺失的 default_icon.png,删掉已无引用的 earphone*/record_card*(省约 1.6MB)。
## 修同传/面对面页「一层阴影盖住标题和返回键」
不是阴影,是 Flutter 的错误控件。translation_view 的 AppBar 里有个 Obx 写成
`Platform.isIOS && controller.currentMode.value == ...`,安卓上 && 短路,
右半边根本不执行,这个 Obx 一个可观测变量都没订阅到,GetX 判「用法不当」抛错,
错误控件顶替了整条 AppBar(debug 红框、release 灰框)。iOS 上 Platform.isIOS
为真会继续求值,所以只有安卓复现。把可观测变量提到平台判断之前读即可。
全仓扫过,同样写法只此一处。
## 修 AI 页三张卡片 RIGHT OVERFLOWED BY 2.0 PIXELS
VIP 区容器有一圈 Border.all()(默认宽 1.0),Border 画在 padding 内侧、左右各占
1px,而列宽计算只扣了 padding 没扣 border,于是正好多出 2px。把 border 宽度提成
具名常量与计算共用,免得改一处又错位。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
1 month ago |
Rodger-Wang
|
714a05b6d4
|
修 iOS EMAI 没有语音播报、连续对话上行断供;接入 MCP 令牌
iOS 上助手一直不出声,和这次改连续对话无关,是播报本来就坏的。三轮真机对照
实测:just_audio 喂的是「长度未知的直播 WAV 流」,AVPlayer 只发一次
`Range: bytes=0-1` 的嗅探请求就判定资源不可播,回 -11850 且 processingState
直接落 idle,一次都没真正起播。声明长度、支持 range、把收到的字节全缓存下来
允许重放,三种都试过,结论一样。ExoPlayer 不发这个嗅探请求,所以安卓一直是
好的——这就是「安卓有声、苹果没声」的全部原因。
iOS 改走原生 AVAudioEngine 排队播 PCM(PcmSpeakerPlayer),安卓不动。
验证方式:一次性灌 3 秒音频,7ms 喂完但 3708ms 才播完,证明是实时渲染不是丢弃。
连续对话的上行也是断的,服务端一直回 ClientAudioTimeout。两个原因:
- _ensureContinuousMic 被 Started 事件和 _enterContinuous 并发调用,两边都在
`_micRunning` 置位前通过守卫,同一个 recorder 被 startStream 两遍,第二次把
第一次那条流顶掉。控制器侧改成同步置位,MicPcmStreamer 内部再加一层 in-flight 去重。
- 开麦前没配好并激活音频会话。会话停在 soloAmbient 且未激活时,startStream
会**成功返回**、引擎也起得来,但输入 tap 一个 buffer 都不回调,客户端毫无报错。
麦克风和播放器统一到 EmaiAudioSession 一份会话配置:各配各的会在连续对话下
互相覆盖。iOS 固定 voiceChat——record 的 echoCancel 会打开 voice-processing 并
把 mode 改成 voiceChat 持有着,播放这边写回 default 就是两边互相掀桌子。
androidAudioAttributes 仍是 media,别改成 voiceCommunication(华为会静音)。
连续对话交互按要求改:进页面只建会话不开麦,短按开始、再按结束、可反复,
退出即停,10 秒无对话往来自动结束。计时依据必须是对话事件不是音频包——
麦克风没人说话也在稳定出包,拿它续命这个超时永远不会触发。
另修 _enterContinuous 在 reconnect() 一返回就判 `!= ready`:ready 要等服务端回
Started(一次网络往返),几乎每次都命中,于是刚打开的连续对话被当场关掉,
而 Started 稍后到达又会把麦开起来,留下一个界面上看不见的常开麦克风。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
1 month ago |
Rodger-Wang
|
9abed0008b
|
字幕小窗:加暂停/继续、去掉快进快退、大小与透明度可调
- 暂停/继续:实现 AVPictureInPictureSampleBufferPlaybackDelegate 的
setPlaying / isPlaybackPaused。语义定成「冻结当前这条字幕」而不是
「暂停翻译」——用户在别的 App 里看视频、想把一句话定住多看两眼,这是小窗
那颗按钮唯一说得通的含义,而且完全可逆、不影响正在跑的识别与翻译。
冻结期间新文案只暂存,恢复时补上最新一条。
⚠️ setPlaying 里必须调 invalidatePlaybackState(),否则按钮图标不跟着变,
点了「暂停」它还画着暂停图标,看着像没生效。
- 快进快退:requiresLinearPlayback = true 就是这个开关(原本已设,这次补了
注释说明它的作用,免得以后被当成无关配置删掉)。
- 大小 / 透明度:翻译页新增一颗 tune 按钮,弹出设置面板,两个滑块,
GetStorage 持久化。新增 3 个 i18n key(pipSettings/pipSize/pipOpacity),
41 个语言文件全部补齐,主要语种给了本地化文案、其余回退英文。
三条平台限制已写进注释与面板说明,避免以后重复尝试:
- 小窗**实际大小由用户捏合手势决定**,App 无权直接设定。「大小」滑块改的是
画布宽高比,系统按新形状重新布局,观感上是变宽/变窄。
- 透明度**做不到透出下面的 App**。iOS 的 PiP 窗口本身不透明,系统未开放接口。
滑块只改卡片底色深浅。要少挡内容只能把小窗调小。
- 播放/暂停按钮的**尺寸不可控**,那是系统绘制的 chrome,随小窗一起缩放——
所以「按钮小一点」等价于「把小窗调小」。pip 那个 pub 包用私有 API
_setControlsStyle 去动控件,上架有被拒风险,不采用。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
1 month ago |
Rodger-Wang
|
56aa55773d
|
字幕小窗改成自绘:pip 包只喂了一块纯黑像素,字幕根本进不去小窗
用户反馈「悬浮弹幕上没有显示翻译内容」,根因在 pub 包 pip 0.0.4:
PipView.m: const int pixel = 0xFF000000; // BGRA 全黑
填满 pixelBuffer → enqueue 进 AVSampleBufferDisplayLayer
小窗显示的就是这一帧纯黑占位。真正的字幕卡片它是靠
insertContentViewIfNeeded 塞进 activeKeyWindow 的,而 PiP 窗口由系统进程托管、
**不在 UIApplication.connectedScenes 的窗口列表里**,那个 keyWindow 永远是
App 自己的窗口。于是字幕落在 App 身上(上一版还因此盖住整个屏幕点不动),
小窗里只可能是黑的。PipView.m 全文件 0 处引用 contentView,可以佐证。
改成自己实现:SubtitlePipController 把 PlayerView 画进 CVPixelBuffer,
逐帧喂给自己的 AVSampleBufferDisplayLayer,再用 sampleBufferDisplayLayer
这一路 content source 起 AVPictureInPictureController。小窗显示的就是它。
Dart 侧不再依赖 pip 包(import 与相关字段一并删除)。
同时回答另外两个问题:
- 大小:PiP 小窗的实际尺寸由用户捏合手势决定,App 无权直接设定。能控的是
**宽高比**(= 画布宽高比),setPipWidthScale 改的是这个,系统会按新形状
重新布局。另外卡片有内容时自动变高、没内容时收成一条,也走同一条路。
- 透明度:**做不到透过小窗看见下面的 App**。iOS 的 PiP 窗口本身不透明,
系统没有开放任何接口。setPipOpacity 只作用于卡片自己的底色(在小窗那块底
之上显得深浅不同)。真要少挡内容只能把小窗做小或拖到别处。
这一条在 Dart 与原生两侧的注释里都写明了,免得以后有人再试一遍。
几处实现要点:
- 心跳定时器每秒补一帧。只在文案变化时推帧的话,长时间没有新帧会让
AVSampleBufferDisplayLayer 被判定停滞并清空,表现是「翻译停一会儿小窗就黑」。
- start() 之前先推一帧:没有任何已入队帧时 AVPictureInPictureController
会判定内容源为空,直接回调 failed。
- 渲染上下文要做一次 y 轴翻转,否则字幕上下颠倒(UIKit 原点左上、CG 左下)。
- renderView 必须挂在真实窗口层级里且尺寸非零,脱离层级的 layer 不被 PiP 采纳。
- 时间轴返回无限区间,让系统按直播流处理,小窗上不出现进度条和快进按钮。
⚠️ 构建注意:给本地 pod 加新文件后必须重跑 pod install,否则文件不进 Pods
工程,报 "Cannot find 'SubtitlePipController' in scope"。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
1 month ago |
Rodger-Wang
|
a9ce8bcdef
|
广播扩展改走 127.0.0.1 环回 TCP,去掉 App Group 依赖
上一版用 App Group + Unix domain socket 传音频,结果签不出包:
- com.yimai.eaimar 的 profile 不带 App Groups capability
- com.yimai.eaimar.BroadcastExtension 没有任何 profile
而 Xcode 又登录不上账号去申请(-1200)。实测原因是网络层:
developer.apple.com 通(200),appleid.apple.com 与
developerservices2.apple.com 都连不上(000)——后两个正是登录与签发 profile
要走的域名。
改成环回 TCP 之后不需要任何 entitlement:扩展和主 App 在同一台设备上,
127.0.0.1 天然互通,而且不触发 iOS 的「本地网络」隐私弹窗(那个只针对局域网,
环回是明确豁免的)。主 App 的 entitlements 回到原样,用已有的 profile 就能签,
扩展的 profile 由自动签名就地生成。已真机安装成功。
代价:没有 App Group 就没有地方发布「主 App 这次监听在哪个端口」,只能约定
一个固定端口(52781,两边手工同步,注释里写明了)。撞端口时主 App bind 会
失败并打日志,不是静默失败。
顺带补的两个 socket 选项:
- SO_REUSEADDR:上次进程被杀后端口停留在 TIME_WAIT,不开的话几十秒内 bind
都会 EADDRINUSE,表现是「刚重启 App 就用不了」。
- TCP_NODELAY:音频要低延迟,攒包没有意义。
- 只绑 127.0.0.1 而不是 INADDR_ANY,否则这个端口对局域网可见。
另外记两个这次踩到的构建坑:
- flutter clean 会删掉 ios/Flutter/ephemeral/,其中的 swift_sdk_local 软链由
Podfile 的补丁在 pod install 时创建、**flutter 不会自己重建**,于是后续
xcodebuild 直接卡在 "Could not resolve package dependencies"。清理之后要
补跑一次 pod install。
- 跑过 --no-codesign 之后再跑签名构建,会复用 build/ 里未签名的 framework,
装机时报 objective_c.framework "invalid signature"。中间做过 no-codesign
验证的话,出正式包前要先 flutter clean。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
1 month ago |
Rodger-Wang
|
8377745992
|
iOS 多媒体翻译接入 ReplayKit 广播扩展,真正拿系统音频
iOS 不允许一个 App 读另一个 App 的音频,Broadcast Upload Extension 是系统
留的唯一口子。链路:
其他 App 播放 → 系统录屏广播 → BroadcastExtension(独立进程)
收 RPSampleBufferType.audioApp → 转 16k/mono/Int16
→ App Group 容器下的 Unix domain socket
→ 主 App BroadcastAudioReceiver
→ azureAsrHelper.audioStream.saveAudioDataTo() ← 已有的 external 通路
→ ASR → 翻译 → 弹幕
新增:
- ios/BroadcastExtension/{SampleHandler,BroadcastAudioSocket}.swift、Info.plist、
entitlements、Extension.xcconfig
- azure_speech 插件的 BroadcastAudioReceiver.swift + 三个 method channel
(startBroadcastAudioReceiver / stopBroadcastAudioReceiver / presentBroadcastPicker)
- Runner.entitlements 加 App Group;xcodeproj 加 BroadcastExtension target
并在 Runner 里内嵌
对上层复用已有的 requestScreenCapture/stopScreenCapture 接缝(Android 是
MediaProjection,iOS 是广播扩展),控制器里两端走同一条分支。
几处踩过的坑,都写进了注释:
- SOCK_STREAM 不保留消息边界,帧必须带 4 字节长度前缀,否则喂给 ASR 的是
错位样本。
- CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer 不拷贝数据,只把
mData 指向 blockBuffer 的内存;blockBuffer 出作用域即释放,交出去就是野指针。
改成在 withExtendedLifetime 里 memcpy 出来。
- AudioBufferList 的 buffer 个数要按 interleaved 与否算,写死 1 会让非交错
立体声整帧拿不到。
- sun_path 只有 104 字节,socket 只能放在 App Group 容器根下,不能建子目录。
- 扩展侧 connect 失败要限流重试(1s),每帧重连会把扩展 CPU 打满,而它的
内存上限只有 50MB。
- SO_NOSIGPIPE:主 App 挂掉时写 socket 默认会直接杀死扩展进程。
- media 这个 ASR 模式的会话配置必须排在 audioSourceType 判断之前,且要
.mixWithOthers —— 否则我们一 setActive 就把正在播放的那个 App 掐了。
- Embed App Extensions 阶段必须插在 Thin Binary 之前。追加到最后会和
Flutter/CocoaPods 的脚本阶段构成依赖环,Xcode 报 Cycle inside Runner。
⚠️ 尚未打通签名:需要在苹果开发者后台登记 App Group group.com.yimai.eaimar、
给 com.yimai.eaimar 开 App Groups capability、并为
com.yimai.eaimar.BroadcastExtension 建 App ID。Xcode 当前还登录不上账号
(-1200)。在这之前 iOS 包签不出来。代码已用 --no-codesign 验证可编译、
appex 也正确内嵌进 PlugIns/。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
1 month ago |
Rodger-Wang
|
5e2b4f5431
|
修 iOS 多媒体翻译一个字都翻不出来:ASR 在等一份永远没人推的 PCM
根因是 iOS 分支还停在杰理时代的假设上。_configureAudioVideoMode 的非 Android
分支做的是 openDecoder() → 等 isCodecActive → 音频源置 external,语义是
「从耳机的 A2DP 解码器拿手机正在播放的音乐」。本项目只有恒玄耳机,没有这个
能力;而 openDecoder() 如今是个乐观桩,只把 _isCodecActive 置 true 就返回。
于是 iOS 的实际行为是:ASR 以 external 源启动,然后等一份永远不会有人推的
PCM——不报错、不提示,就是一个字都翻不出来。Android 早已整条换成
MediaProjection(isAndroidAudioVideo 分支),iOS 这条没跟着改。
iOS 平台层面也没有「抓其他 App 音频」的能力,唯一合规途径是 ReplayKit
Broadcast Upload Extension(需新建 extension target + App Group,且要用户
手动发起录屏广播)——那是产品决策,本次没做。先退化成手机麦克风外放收音,
让功能是通的:
- _configureAudioVideoMode 的 iOS 分支不再碰杰理解码器;
_audioSourceType = Platform.isAndroid(Android=external/MediaProjection,
iOS=麦克风)。
- 启动守卫里排除 iOS 音视频。那个守卫要求 isCodecActive,而它只由
openEncoder/openDecoder 这两个杰理血统的入口置位——不排除的话直接 return,
识别根本不会启动。
- iOS 音视频用新的 'media' ASR 模式,原生据此把 AVAudioSession 配成
.playAndRecord + .default + [.mixWithOthers, .defaultToSpeaker,
.allowBluetoothA2DP]:
· .mixWithOthers 是前提——不加的话我们一 setActive 就把正在播放的那个 App
掐了,点开翻译视频就哑了;
· mode 用 .default 不用 .voiceChat——后者会让 MicrophoneCapture 打开
setVoiceProcessingEnabled,而要收的恰恰是本机扬声器的声音,AEC 会把它
当回声精确消掉,结果一片安静。
iOS 侧只有两处 currentRecognitionMode 比较(phone_call / push_to_talk),
新增的 media 落到 normal 的默认分支,不影响其它模式。
- enableRecord 的音频源跟着改成 microphone。留 systemAudio 的话原生把
index>0 一律当 external,会建好文件然后等没人推的 PCM,存下来是个空录音。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
1 month ago |
Rodger-Wang
|
8841e76e59
|
修 iOS 多媒体翻译开弹幕后整屏点不动
三段代码叠出来的,安卓不受影响(走的是系统悬浮窗,另一条路):
1. pip 包(pub: pip 0.0.4)在 PiP 启动回调里,把 contentView insertSubview 进
**App 自己的 keyWindow** 并 `frame = window.frame` 铺满全屏。它的
activeKeyWindow 只按「前台活跃场景的 keyWindow」找父视图,而我们是在前台
主动 _pip.start() 的,于是必然落到 App 自己的窗口上。
2. PlayerView 是普通 UIView,isUserInteractionEnabled 默认 YES,背景还是
rgba(40,40,40,0.35)。铺满之后=一层吃掉所有触摸的半透明黑罩子。
3. 关掉弹幕也恢复不了:插件的 restoreContentViewIfNeeded 在
originalParentView == nil 时直接 return,而 PlayerView 是 createPipContentView
里凭空 new 出来的、从来没有过 superview,那条还原分支永远走不到。
罩子会一直留到进程被杀。
改动(都在我们自己这边,没有 fork pub 包):
- PlayerView.isUserInteractionEnabled = false。字幕卡片是纯展示,任何情况下
都不该吃触摸——这一条单独就能解掉「点不动」。
- 视觉(底色/圆角/描边/阴影)从 self 搬到内层 cardView,self 透明;
cardView 只钉 top/leading/trailing + 固定高度,不钉 bottom。真 PiP 小窗里
窗口高度就等于这个高度、看着是铺满的;万一又被铺到全屏窗口上,也只占顶部
一条,下面保持透明。
- disposePipContentView 里补 removeFromSuperview(插件不会替我们摘)。
- Dart 关闭弹幕/PiP 失败时调 _releasePipContentView(),并把 _pipContentView
清零,避免复用一个已经被搬走、再也摘不下来的 view。
⚠️ 未验证:PiP 小窗本身能不能正确显示字幕。pip 包的 PipView 只往
sampleBufferDisplayLayer 里塞自己造的 buffer,全文件没有一处引用 contentView,
所以「切出 App 后小窗里有没有字」这件事需要真机确认,本次没测。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
1 month ago |
Rodger-Wang
|
46ebf28d64
|
闹钟改成只在 App 内提示;通话翻译/通话录音挂前台服务;复刻档位日志提到 warn
1) 拾忆提醒不再发系统通知
改成进程内 Timer + App 内弹窗。代价必须说清楚:**App 不在前台就不会提示**,
它是「打开 App 时告诉你有什么要做」,不是系统闹钟。
- 一个 Timer 只指向队首那条,响完武装下一条;定时器最长压 30 分钟,
超过就到期重算(长定时器在移动端本来就不可靠)。
- 错过的不补弹,否则早上打开 App 会糊一串昨天的提醒。
- 仍保留 flutter_local_notifications,只用来撤老版本排进系统的那些排期
(装过老包的机器上还留着,不撤的话改口径之后仍会收到、且没法关)。
只撤自己的 id 区间,绝不 cancelAll(会干掉录音的前台通知)。
- 顺带删掉上一版加的「通知权限未开启」提示条,不再需要。
2) 通话翻译 / 通话录音 切后台继续工作
两条链路原先都没有前台服务:
- 通话翻译走 AliyunBailianE2EHelper,与 AzureAsrHelper 不是同一条路,
后者的前台服务管不到它;
- 通话录音的音频来自耳机 SPP 流,压根不经过识别插件。
没有前台服务时切后台会发生两件事且都不报错:国内 ROM(荣耀/华为尤其)
冻结或回收进程,WebSocket 断、PCM 流停摆;Android 9+ 后台拿不到麦克风。
→ AST 的 start/stopContinuousTranslation 里起停 AudioRecordingForegroundService;
另开两个 method channel 口子(startAudioForegroundService/stop…)给
通话录音用。三条链路互斥使用,暂不做引用计数,注释里写明并存时要改。
⚠️ 前台服务自带常驻通知,用户关掉通知权限后它不显示但服务照常跑——
所以第 1 条「不要系统通知」和这一条不冲突。
3) 声音复刻的档位日志提到 warn 级
[TRANS-INIT] 命中哪一档、[STS] initialize 的 voiceClone 开关,原先是 info,
release 包里看不到。而「有没有复刻」完全取决于命中的是 alibaba35 还是旧版档
(旧版模型静默忽略 enable_voice_clone),不看这两行只能靠猜。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
1 month ago |
Rodger-Wang
|
80c6ea1be3
|
iOS 真机排查:修同传播报路由、录音时长与电平崩溃
一轮 iPhone 真机联调里发现并修掉的一组问题,多数是既有缺陷,两处是我在
本轮前期改出来的回归(已在注释里标明,避免以后再踩)。
同传播报路由(AzureTtsHelper.swift,phone_call 分支)
这一支被改坏过三次,结论固定在注释里:
① 最早 `.defaultToSpeaker` + `overrideOutputAudioPort(.speaker)`
→ 连了 A2DP 也被强拽到外放;元凶是那次 override(已删)。
② 修 ① 时把 `.defaultToSpeaker` 一并删了 → 删过头:`.playAndRecord` 的
默认输出是**听筒**,没耳机时译文几乎听不见(「翻译出来了但不播报」)。
③ 补回 `.defaultToSpeaker` 且 mode 设成 `.videoChat`(为了 AEC)
→ `.videoChat` 本身偏好内置扬声器,连了耳机又被拽到外放,绕回 ①。
现在按**有没有耳机**分两支:有耳机走 `.default` + 仅 A2DP;没耳机走
`.videoChat` + `.defaultToSpeaker`(外放且开回声消除)。分支口径与采集侧
MicrophoneCapture 启用 voice processing 的条件完全一致,两边不再打架。
没有这个 AEC 时,外放的译文会被自己的麦克风收回去,ASR 当成新一句再翻一遍。
现场录音(bes_recording_service.dart)
- 时长改为按**已写入的数据量**算(32 字节 = 1ms),与 WAV 头一致,
因此界面时长与文件实际播放长度永远相等。墙钟算不准:耳机收到 AA 59 后
要 1.8~6.5 秒才上行首帧,还会把等待期缓存的音频一次性补推,实测数据量
比「首帧到停止」的墙钟窗口还多 21~45%,从哪头计时都对不齐。
真机验证:音频19.15s/墙钟18.88s、音频18.72s/墙钟18.69s,
等效采样率均为 16000Hz 整、0 丢帧。
- 修 `_updateLevel` 的 `asInt16List(offsetInBytes, …)`:原生来的 Uint8List
是带 offset 的视图,偏移为奇数时必抛 RangeError。原来藏在 try 里被吞掉,
表现是波形/电平一直不动(数据本身没丢)。改成按小端手工拼 int16,
与 BesCallRecordingService._writeStereo 同一修法。
⚠️ 本轮我一度把它挪到写入之前且未包 try,一抛异常整个 _onPcm 中断、
音频一帧都写不进去(实测 6990 次未捕获异常)——现在它排在写入成功之后
并单独包 try,波形挂了绝不能影响录音。
- 音频写入排成 Future 队列。Stream.listen 的 async 回调不会被等待,
每秒上百帧必然重叠,而 RandomAccessFile 同一时刻只允许一个异步操作。
(事后核对通话录音同样写法却没丢数据,所以这条是防御性的,不是主因。)
识别异常与状态收口(translation_controller.dart)
- 新增 _abortRecognition:catch / error / canceled 三条异常路径统一回滚,
**把录音一起停掉**。此前 `isRecording` 全文件只有一处 `= true`、从来没被
置回 false,识别挂了右上角还在计时;异常路径更是连 stopRecording 都不调。
标记同时收口进 stopRecording,所有停止路径都覆盖得到。
- startRecognition 点击即点亮 isPreparing(view 早已绑好按钮反馈,只是从来
没人设过它,点下去毫无视觉变化,就是「点了没反应」)。
- `await _modeInitFuture` 补 3 秒超时。它是本轮前期为消除语言竞态加的,
但没有超时——iOS 首次要激活音频会话、连着蓝牙还要等音频路由,一旦迟迟
不返回点击就永久卡住。把「可能用错语言」换成「可能完全卡住」是坏交易,
超时后走兜底 initialize,至少起得来。
- 三段耗时打点提到 warning 级:release 的日志级别就是 warning,用 info 打
在真机上一条都留不下。
其它
- Logger 在非 release 下同时 print 一份:`developer.log` 只进 VM service 的
logging stream,`flutter run` 控制台看不到、调试态下 idevicesyslog 也抓不到,
真机排查时加了一堆打点却一条都看不见。
- 连接握手不再自动跑 probeCommands:每条间隔 400ms、一轮十几秒,期间 SPP
通道被占满,用户点录音/翻译的指令只能排队。需要重扫时手动调。
本次在固件 0.0.3 上的扫描结果记进注释(0x0B/0C/0D/0F/10/19 有回包,
但取值都不像电量百分比,电量命令仍未定位到)。
- 通话状态变化打出触发它的原始字节,便于排查「刚连上就被判成通话中」。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
1 month ago |
Rodger-Wang
|
24e17b3434
|
收拢积压改动:恒玄 OTA、发版配置拆表、实名认证、芯片厂商/代工厂管理等
把工作区里积压的多轮改动一次性入库。主要几块:
客户端
- 恒玄 OTA 打通:新增 local_plugins/bes_ota_manager(自包含 BES SDK,
Android Java + iOS ObjC,SPP OTA 2.0),路由切到 BesOtaUpgradeView /
BesOtaUpgradeController,配套下载服务;杰理那套文件保留但已不挂路由。
- 通话翻译新增阿里 3.5 档(alibaba35_language_config.dart,
qwen3.5-livetranslate-flash-realtime + 实时声音复刻)。
- 实名认证接入、游客登录改走服务端 api_sgin 的 Tourists 分支、
功能闸门由 GuestGuard 改为 IdVerifyGuard。
- 41 个语种文案同步(OTA 新增 28 个 key 等)。
- 默认主题改为浅色,不跟随系统。
服务端
- 发版类配置拆表:app_release(版本控制 / 游客显隐,一应用一行)与
channel_app(渠道级下载地址 / 上架状态 / 支付渠道)分家,
新增 comm/apprelease.go 与 console/api_apprelease.go。
- 授权码字符串整体移除:删除 utils/license 包,新增 utils/devcode
(MAC 规范化与 PID 反解),配套迁移 SQL 在 docs/migrations/。
- console 新增芯片厂商、代工厂、用户管理接口与对应 admin 页面。
- 身份证实名核验(sys/idverify)与第三方服务的服务端专用类别。
其它
- .gitignore 补挡 apps/services/lego/sys/gin/log-*.log:gin 子系统跑起来
会按时间戳滚动生成日志(多为 0 字节),与 apps/services/comm/log 同一
性质,不进版本管理。已跟踪的 log-2026-08-20 那个仍在库里,未动。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
1 month ago |
Rodger-Wang
|
946204cc3c
|
翻译延迟优化:接上阿里增量文本、面对面/同传去掉固定等待
通话翻译:阿里百炼端到端协议本来就是流式的(音频 response.audio.delta
一直在逐帧回灌),但增量文本订阅的事件名是错的——代码监听
response.audio_transcript.delta(字段 delta),而实时语音翻译
audio+text 模态实际发的是 response.audio_transcript.text(字段 text),
.delta 是 OpenAI Realtime / 通义 Omni 的写法。于是 onPartialText 一次
都不触发,译文只在 .done 时整句蹦出来,看着像"等说完才翻"。
现在两套名字都认、两个字段都取,并按首次出现的事件名锁定,防止服务端
兼容层双发导致文本累计两遍。Android / iOS 两端同改。
面对面 / 同声翻译(走 ASR → HTTP 机器翻译 → TTS 三段串行):
- 补上 Speech_SegmentationSilenceTimeoutMs=300,原来一行没设走 Azure
默认 500ms,这是"说完到出译文"里唯一一段纯等待;
- 同传 / 音视频改单语 ASR,关掉连续语种识别(它要缓冲够音频、给候选
语言逐个打分才敢出 final)。只有面对面需要判断"谁在说",仍保持双语;
- 中间结果翻译从"每 300ms 无条件发"改成 700ms + 至少多 4 个字 +
CancelToken 撤旧请求 + 序号丢弃过期结果。中间译文只上屏不播报,
原来一句 5 秒的话要打十几次真实服务商调用,还会跟 final 抢链路;
- TTS 提到 _updateExistingHistoryItem 最前,不再排在浮窗 / 写历史 /
记统计那串 await 之后;
- startRecognition 先 await onInit 那次 changeTranslationMode,堵掉
"进页面立刻点开始 → 落到默认 zh-CN/en-US"的竞态。
AuthInterceptor 的 DioExceptionType.cancel 分支不再弹红色"请求已取消"
——取消永远是客户端自己发起的,不改的话中间结果一接上就会一路狂弹。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
1 month ago |
Rodger-Wang
|
2d1ee0b783
|
apps/client 首次入库(Flutter 移动端)
从 /Users/yunyanzhineng/work/eaimar_client-main 搬入,未带原 .git,历史留在原仓库。
3215 个文件 / 约 118MB,其中约 40MB 是构建必需的预编译 SDK(azure_speech 的
MicrosoftCognitiveServicesSpeech.xcframework、device_jieli 的 JL_BLEKit 等)。
build/ (5.2G)、ios/Pods/ (311M)、android/.gradle/ (120M) 已由 .gitignore 挡掉。
本次入库的代码包含两项刚做完的改动:
1) 游客登录入口接上服务端开关。此前 _checkGuestLoginConfig() 恒定写死 true,
入口根本不读配置。现改为取服务端算好的结论,并把入口所在的 Builder 改成 Obx
——配置是异步拿到的,Builder 只渲染 onInit 时的初值 false,入口再也不会出现。
拿不到配置时按隐藏处理:默认显示会在送审期把不该露的入口露给审核员。
⚠️ 已发布的老客户端关不掉(写死 true),只对本版及以后生效。
2) iOS 上架合规修复:
- 移除 ATT/IDFA:删掉 Info.plist 的 NSUserTrackingUsageDescription、
SKAdNetworkItems、NSAdvertisingAttributionReportEndpoint,删掉 splash 里的
授权请求与 app_tracking_transparency 依赖。本 App 从不读取 IDFA,也没有任何
广告/归因 SDK,弹框纯粹劝退用户,还要在 App Store Connect 申报追踪数据类型。
- ios/Podfile 里那段 config.build_settings.delete('NSUserTrackingUsageDescription')
是无效代码(该 key 在 Info.plist 里,不是 build setting),换成注释说明。
- 通讯录符号(ITMS-90683 被拒原因)随之一并解决:permission_handler 走 SPM 后
按 Info.plist 有无对应 key 决定编不编,判定结果被 SwiftPM 按内容哈希缓存,
改完 plist 必须清 DerivedData 与 manifests 缓存才会重新求值。
另附 GOOGLE_PLAY_RELEASE_CHECKLIST.md:Google Play 上架阻塞项的实测梳理
(targetSdk 36 与 Billing 8 的 2026-08-31 期限、16KB 页对齐不合规的两个 .so、
支付回退逻辑的下架风险)。
⚠️ android/app/sign/ 下的 eaimar.keystore 与 Eaimar.properties(含明文签名密码)
随本次提交入库,与本仓库"私有仓库下接受真实凭据"的既有取舍一致。
仓库若要公开,这两个必须先摘出去。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
1 month ago |