Branch:
along
along
along-test
main
memory-center-impl-v1
memory-design-v0.2
${ noResults }
18 Commits (along)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
18a7160183 |
清理:QQ 音乐整体下线(插件、Dart 服务、灵犀 AI 里的 QQ 音乐工具、iOS 回跳)
用户决定整个删掉(2026-09-24)。
- 删 local_plugins/qq_music 整个插件(含 QPlayAutoSDK 与 Android aidl aar,约 3.5MB);
pubspec、android settings.gradle、根 build.gradle 与 app build.gradle 里的 flatDir 引用同步去掉
- Dart:QqMusicService(qq_music.dart)删除;翻译页 suspend/restoreOtherServices 里暂停/恢复 QQ 音乐的分支去掉;
main.dart 的 deepvoice://qqmusic 深链接分支与 SplashController.handleQQMusicLogin 去掉
- iOS Runner:AppDelegate 里把 eaimar:// 转给 "qq_music" 通道的那段去掉(该通道名本来就和插件
实际注册的 com.yunqiinnovation.qq_music 对不上,回跳一直是空转);LSApplicationQueriesSchemes 去掉 qqmusic
- 灵犀/移动精灵 AI(原生):
- chat_api 两端删 QQMusicSystemFunctionHandler(9 个 qqmusic_* 工具);MCPClient 的 "QQ"/未指定/AUTO
一律走网易/系统音乐工具
- agent_service 两端删 qqmusic_searchandplay_songs 的播放处理、Android 的 QQMusicSingleton 初始化与
autopause/resume 的 QQ 分支;Package.swift / build.gradle 的 qq_music 依赖去掉
- 行为变化:Dart 从未传 musicServiceType,Android 原本就不注册音乐工具、QQ 分支走不到,无变化;
iOS 原本「装了 QQ 音乐就给 AI 用 QQ 工具」,现在一律用网易/系统音乐工具
保留:MusicManager(翻译时暂停其它 App 的音乐,走 music_service,与 QQ 无关);
eaimar:// URL scheme 注册(App 级深链接入口,只去掉转发给 QQ 的那步)。
验证:dart analyze 无 error、无 unused_import;flutter test 221 全过;flutter build ios --no-codesign 通过。
Android 编译同第 4 批,受本机缺 Rosetta 影响未验证。
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 362a3ac70aac2d79d0483076997949c8b2dac2a9)
|
2 weeks ago |
|
|
1f9925773d |
清理:第 3 批,Flutter 业务代码孤儿文件、死路由、无人用的服务与依赖(约 7500 行 + 24MB 素材)
- 23 个从 main.dart 不可达的孤儿文件(火山 ASR/TTS/声音复刻一组 3635 行、goodsai 页、
siri_orb/global_ai_orb、intro_overlay_builder、杰理 OTA 残留弹窗与 model 等)
- lib/modules/voice_replication 整目录:Routes.voiceReplication 唯一引用在注释里
- 注册但无人 find 的服务:TranslationUsage / MicrosoftTranslation / Navigation / Location
- api.dart 三个服务端已无接口的方法:getAliToken / getMusicUrl / getproducts
- pub 依赖:tencentcloud_cos_sdk_plugin、flutter_tts、flutter_shaders、flutter_svg、synchronized、
markdown(flutter_markdown 仍会传递依赖)、pip;shaders/ 随 flutter_shaders 一起去掉
- 素材:assets/images/agents(19MB)、assets/weather(2MB)、explore*、defaultPicture*、
robot_light/glasses_light/qqmusic/music、两个 lottie、listening.mp3
- dart analyze 报的 unused_import 31 处、unused_element 17 个(含连带暴露的 4 个)、
unused_field 8 个、unused_local_variable 8 处,全部清零
⚠️ pubspec.lock:仓库里这份锁与 pubspec 早已对不上(改动前的 pubspec 跑 pub get 也会重解析 82 行),
本次重解析后 firebase_auth 6.7.0 拉进的 firebase-auth 24.2.0 要求 Kotlin 2.3、工程是 2.1.10,
Android 编不过;故把 firebase_core / firebase_auth 钉回 4.13.0 / 6.5.7(pubspec 里有说明)。
其余 36 项均为小版本升级。
验证:dart analyze 无 error 且 unused_* 为 0;flutter test 221 全过;
flutter build apk --debug 与 flutter build ios --no-codesign 均通过。
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 355632a5b41818d33ff9c36b08440777d955cd62)
|
2 weeks ago |
|
|
5036ab4c3f |
client: 修通话翻译「德语/意大利语一开就卡死、只有中英正常」——并发初始化+默认参数兜底
不是语种问题,是竞态。原生阿里初始化天然 >=1.2s(A 路 init → delay(1200) → B 路 init),期间只要再来一次 initialize/dispose,「代数」就变了,前一次返回 false;Dart 拿到 false 把 _isInitialized 清掉;接着 recognizeCallback() 看到 「未初始化」就调**无参** initialize()——默认值 provider=azure + [zh-CN,en-US], 把刚握手成功的阿里 zh→de 会话整个换成 Azure 中英三段拼装。此后原生 provider 停在 azure、Azure 识别器一直在跑,每次 dispose 都慢几秒,下一轮更容易撞进 1.2s 窗口,**退出重进也不会好**。中英「正常」只是因为默认值恰好是中英。 修在三层,缺一层都会复发: - azure_ast_service.dart:initialize() 串行化(_initInFlight);记住最近一次的 provider/语言表;recognizeCallback / startContinuousTranslation 走 _ensureInitialized——先等在途初始化,仍没成就**按上次参数**补一次, 永远不用默认值,没有可复用参数就直接报错。 - translation_controller.dart:_initializeCallModeTranslationService 串行链; isPreparing 期间忽略重复点开始;_attachBesCallTranslationBridge 开桥前先等 初始化真正落地(10s 上限)——原来靠 _modeInitFuture 3s 超时硬往下走, 桥开在原生初始化之前。 - AzureSpeechPlugin.kt/.swift:被新一代取代时打出阶段与代数、A/B 任一失败打 结果,排查先找这两行。iOS 另修一处独有差异:原先 A/B 无论成败都回 true, Dart 以为就绪、桥照开而实际一路是空的,现在按真实结果回。 Redmi K90 Pro Max 验证:连开 9 场(it/de/en/ja/fr/ko×2/en,含切语言、关闭重开、 退出重进)全部阿里 3.5,初始化各 1.2s,87 句译文,0 错误,断粮 0 次。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
3 weeks ago |
|
|
d1db3e5951 |
client(android): 通话翻译三处修复——同页第二场聋、call.stop 偶发丢失、译文积压追赶
全部在 Android 原生(azure_speech / bluetooth_manager 的 Kotlin),iOS 一行未动。 2026-09-22 ALN-AL80 + Echo-one 真机,配合协议栈 RFCOMM 日志逐条定位与复验。 同页第二次开始没反应(azure_speech) - AST 就绪状态记在插件上(astSessionReady,与 iOS 同义),建桥与重新 enable 时都继承; BesCallPcmBridge.disable() 不再把 ready 打回 false。 - 原因:markReady 只在 initialize 末尾发一次,同页面再开始只走 startContinuousTranslation, disable() 清掉的 ready 再也没人置真,上行帧全扣在环形缓冲里。实测进页面第一场好、 之后每场 ready:false / downlinkFrames:0,退出重进又好一场。 结束指令偶发没到耳机(bluetooth_manager) - 控制命令走单线程队列:距上一条命令 ≥30ms、距上一个实时音频包 ≥500ms 才写; 下行音频改走 writeRealtime 直发。 - 原因:BluetoothSocket 写入经本地管道,协议栈按信用一次把管道里攒着的字节合成一帧 (PORT_WriteData p_len=4 / 172 / 256),耳机一帧只解开头一条命令。8 次收尾丢 3 次, 耳机不回 BB D2、继续推音频,下一次 call.start 应答 BB D1 07 01(已在通话模式)。 修后 8 轮 0 丢,边播译文边点结束也在 0.5s 内收到 D2。 译文积压越说越慢(bluetooth_manager CallTranslationDownlink 重写) - 积压 >1s 裁掉超过 150ms 的静音;>2s 起 WSOLA 变速不变调,4s 达 1.3x;不丢内容 (兜底丢弃常量默认关)。新增 getCallDownlinkStats 与收尾总账日志。 - 原因:译文音频突发产出、只能 1 倍速播,说话人不停顿时源时长≈原话时长,队列只涨不缩, 实测 A 路积压 6.2s。修后同量压测最大 3.2s,绝大部分时间 <1.5s,光裁静音就省了 10%, 变速最高只用到 1.11x。 其它 - translation_controller 收尾那几行日志提到 warn(release 包能看到 call.stop 有没有发)。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
3 weeks ago |
|
|
7ce56f693d |
client: 通话翻译音频链路修复(iOS GATT 写入、后台保活、状态竞态)
iOS 上恒玄走的是 GATT over BR/EDR 而非 BLE,之前按 BLE 模型处理导致一连串问题。 本次改动都有真机数据支撑(2026-09-21 压测)。 GATT 写入(bluetooth_manager) - 写类型改为优先 .withoutResponse。ATT 是请求-响应协议,带应答写每包等 ACK, 追不上下行 50 包/秒,表现为音频越听越滞后。 - canSendWriteWithoutResponse 只作观测、不作门槛。它是 CoreBluetooth 为 BLE 设计的提示,在 BR/EDR GATT 上不反映真实能力(实测 100% 为 false 却零丢弃); 拿它当闸门会死锁:不写就不回调,不回调就永不翻真,实测 548 包只出去 11 个。 - 控制命令无条件直发、不排队。排到积压音频后面会让 AA 09/AA 06/queryCallState 全部发不出去,曾被误判成「固件换了命令号」。 - 不再对标准电池服务 0x180F 做任何 ATT 操作:它在 Echo-one 上是空壳(值恒为 0), 而挂在它上面的读/订阅会把整条 ATT 通道堵死。 后台存活(translation) - 通话翻译期间持有 BackgroundKeepalive。两路音频全走耳机 GATT,手机侧没有 AVAudioSession 的实际 I/O,UIBackgroundModes=audio 不成立,切后台几十秒后 上行被掐断:WebSocket 还连着,但服务端收不到语音,靠默认 VAD 每 30 秒强切一句, 吐出空译文或幻觉 —— 表现为「切后台后只剩译文、原文全没」。 - BackgroundKeepalive 不再无条件把音频类别改成 .playback,避免踩掉通话翻译 正在跑的 .playAndRecord 录音链路。 状态竞态(azure_speech / device_hub) - markReady 的就绪状态记在插件上、建桥时继承。建桥与 AST 异步初始化是两条独立 路径,初始化先完成时那句 optional chaining 打在 nil 上静默丢失,之后 ready 恒为 false、上行帧全积在环形缓冲里,表现为「打开通话翻译没反应,再开一次才好」。 - DeviceHub.inCall 改以会话快照为准。事件会随 worker 在断连时被 dispose 而丢失, 重连后 ever 又不回调当前值,且后续重复赋同值不触发,永远不会自愈。 可观测性 - 下行发送器与 GATT 写入的日志从 NSLog 改为 os_log(release/profile 包里 idevicesyslog 只收得到 os_log),并经 DeviceSession.diagnostics() 暴露给 Dart, 写入文件日志后可用 devicectl 直接拉取,不依赖 usbmuxd。 - 新增 inCall 变化、闸门拦截、通话状态翻转、前后台切换四处观测点。 其它 - DeviceSession 新增 diagnostics()(默认空表);SmartcarSession 因是 implements 而非 extends,同步补了实现。 - CLAUDE.md 记录本次全部实测结论,含 BB 8E 为通话状态权威、BB 02/03 是噪声 (已知问题,本次未改判定)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
3 weeks ago |
|
|
8b50e52572 |
client: 通话翻译字幕错位/总结残留/退不出去/切后台被冻结,及 B 路说话人门限
真机实测(Android,恒玄耳机)逐条定位后修,原生能改的都在原生层改。 字幕乱(AliyunBailianE2EHelper,Android/iOS 同改) - 原文流与译文流各自按到达顺序自己发号,两条流的切句本来就不一致,off-by-one 一旦发生就一路错到底。改成认服务端的 item_id:译文帧的 item_id 经 itemParent (response item → input item)映射回原文那条,取不到才退回本地发号。 - response.audio_transcript 的 text 是累计值、delta 才是增量,此前一律当增量 append,于是出现「Hello,Hello,Hello,」。改成按字段名分支:有 delta 就追加, 只有 text 就整段替换。 - connectionGeneration 守住 onOpen/onMessage/onFailure/onClosed(iOS 用 activeTask 弱引用按任务身份判),旧 socket 的迟到回调不再打进新会话;stopContinuousConversation 同步清 webSocket/isStarted。 翻译结束后「总结内容」还留在屏幕上 - AliyunAstCallback.onSessionFinished 会把整场累计文本当作一条 translated 再发 一次,表现就是一大坨总结。不再发;原文缓存改为按 utteranceId 存,上限 32 条。 结束后无法再翻译、返回按钮像被遮挡 - 退出时那颗延时 Get.delete 会误杀刚重新进来的页面。按 tag 记活跃页面数 (_PageAliveMarker + _aliveCount),还有人用就跳过销毁。 重进页面提示「未在通话中」 - 耳机对 0xD1/0xD2(进/出通话模式)的应答被当成通话状态上报,把 isCallOngoing 改掉了。这两个 type 改为只记日志,不动通话状态。 切后台功能被关掉 - 前台服务类型加 phoneCall:microphone|phoneCall (132),ROM 的进程冻结不再掐断 通话翻译。startForeground 带类型失败时回退到仅 microphone。stopService 改用 context.stopService,后台也能调。 - AST 掉线自动重连(1s/2s/4s,最多 3 次),回到前台按 _astHealthy 补一次; 重连后的新会话用 800ms 宽限期,否则会被原来的 2s 窗口吞掉重试。 B 路说话人门限(PeerSpeechGate,新增 + 11 个单测) - 声音复刻配的是 frequency=once,只在会话开头刻一次音色。B 路会话一启动就建立, 此时对端多半还没开口,先收到的是机主的串音(实测 B 路 ASR 配 language=en 却 识别出机主刚说的中文),于是把机主音色刻成了「对端音色」,两个人听起来一样。 - 未开门期间音频进环形缓冲,开门瞬间整段补发——对端先开口时第一句的开头一个字 都不能丢。fallbackFrames 到时强行开门:门限太严会让对端整通电话不被翻译, 太松只是音色可能刻错,后者轻得多。 - 每次会话重建(含断线重连)都 reset,否则重连后照样刻到串音。 已知未尽:Android 14+ 会拒绝 phoneCall 这个前台服务类型并回退成仅 microphone, 那里的冻结保护不生效;rmsThreshold=600 尚未在真机上标定(上一轮跑到了兜底强开)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
3 weeks ago |
|
|
7aef22905b |
client: 语音翻译的语言弹窗两侧给同一张表,用过的置顶
此前左右两侧会把对侧已选的那项从列表里删掉(excludeLanguage),看起来像那个 语言没了。语音翻译(同传/面对面/音视频)不再传这个参数,选中对侧语言时由 controller 对调来避免源=目标。文本/图片翻译仍走排除——它们的源语言有「自动 检测」,对调会把这个非法值挪进目标语言。 - 对调实现体拆出 _swapLanguagesInner,不包 _runLanguageSwitch:撞上对侧时是 在已经排队进来的那次切换里调它,再包一层会等自己前面那条链,死锁。 - RecentLanguages(GetStorage)记最近选过的语言并置顶,源/目标各一份列表。 「自动检测」不是语言,不记。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
3 weeks ago |
|
|
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 |
|
|
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 |
|
|
c95996c251 |
修 iOS 上切到同传/面对面时字幕小窗关不掉、且中断 changeTranslationMode
_floatingWindowManager 是 late 且只在 onInit 的 `Platform.isAndroid` 分支里赋值 (iOS 走 initPlatformState 那套 SubtitlePip)。全项目 5 处引用里 4 处都带平台守卫, **只有 _disableFloatingWindow() 那处没有** —— 而它恰恰会被 changeTranslationMode 调到 (从多媒体/通话翻译切到同传或面对面时,小窗不受支持要自动关掉)。 iOS 上后果是连锁的,且没有一条指向真因:LateInitializationError 从 changeTranslationMode 中途抛出,后面的 _reinitializeAsrService() 再也走不到; 小窗又没真的停掉,继续浮在同传/面对面页上盖住顶部的标题与按钮;偏偏小窗开关按钮 只在 call/audioVideo 两个模式才渲染,用户连关都关不掉。 - _disableFloatingWindow() 分平台,并收敛成 toggle / onClose / changeTranslationMode 三处共用的唯一出口;Android 分支补 Get.isRegistered 守卫。 - 切到不支持小窗的模式时改成**无条件**关一次,不再依赖 isFloatingWindowEnabled: iOS 上这个标志位由 initPlatformState 里那次异步的 SubtitlePip.isActive() 回填, 和 onInit 紧接着调用的 changeTranslationMode 是竞态,回填晚一步就整个跳过。 没开着时多关一次是无害的。 真机验证(iPhone 12 / iOS 26.6.2):改前在运行中的 App 里直接取 _floatingWindowManager 确实抛 LateInitializationError;改后走完整路径(多媒体翻译 → 开小窗 → 切同传), changeTranslationMode 正常返回、pip=false、widget 树里 ErrorWidget 数为 0。 安卓侧(华为 ALN-AL80,debug + release)同传/面对面两页渲染不受影响。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
4 weeks ago |
|
|
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 |
|
|
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 |
|
|
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 |
|
|
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 |
|
|
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 |
|
|
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 |
|
|
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 |
|
|
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 |