#!/usr/bin/env bash # # BLE 通话翻译日志抓取 / 过滤工具 # # 用途:排查"通话中 BLE buff 溢出且不恢复"问题,把 Android / iOS / Dart 三处日志 # 按同一套关键词过滤出来,边看边落盘,方便和设备端同事对时间轴。 # # 常用: # ./tools/ble_log.sh android # 实时抓 Android BLE 日志(同时写文件) # ./tools/ble_log.sh android -k # 只看通话翻译相关行(强过滤) # ./tools/ble_log.sh ios # 实时抓 iOS 真机日志 # ./tools/ble_log.sh pull-android # 拉 app 内的 Dart 日志文件 app_*.log # ./tools/ble_log.sh pull-ios # 同上(iOS,需 devicectl) # ./tools/ble_log.sh filter <日志文件> # 从已有日志里筛通话翻译相关行 # ./tools/ble_log.sh stat <日志文件> # 快速统计:拥塞/休眠/发送节拍 # set -uo pipefail APP_ID="com.saitong.voitrans" IOS_SUBSYSTEM="com.yunqiinnovation.ble_service" # Android 需要抓的 TAG(flutter 用于捞 Dart 侧 Logger 输出) ANDROID_TAGS=( BleService BleCommandSender BleServicePlugin BleCompanionManager OpusAudioManager flutter ) # --------------------------------------------------------------------------- # 通话翻译相关关键词。 # 前半段是代码里已有的日志标记,后半段 CT_* 是排查期建议补充的聚合日志标记 # (每秒吞吐 CT_RATE / 设备流控 CT_FLOW / 写入背压 CT_TX / 心跳 CT_HB)。 # 新增日志时统一用 [CT_xxx] 前缀,这里就不用再改。 # --------------------------------------------------------------------------- CT_PATTERN='CT_RATE|CT_FLOW|CT_TX|CT_HB|CALL_TRANS_CMD|CALL_TRANS_HB|CALL_TRANS_DEBUG' CT_PATTERN="$CT_PATTERN"'|音频下行发送|下行写入拥塞|下行写入已恢复|音频发送队列已满|音频发送线程|声道积压缓存' CT_PATTERN="$CT_PATTERN"'|休眠|补发积压|恢复左声道|恢复右声道|通话翻译|编解码|心跳' CT_PATTERN="$CT_PATTERN"'|连接参数|连接优先级|onConnectionUpdated|MTU|mtu' CT_PATTERN="$CT_PATTERN"'|已连接|已断开|连接断开|重连|GATT' OUT_DIR="${BLE_LOG_DIR:-./ble_logs}" usage() { # 打印文件头部的注释块(到第一个非注释行为止) awk 'NR==1 {next} /^#/ {sub(/^# ?/, ""); print; next} {exit}' "$0" exit "${1:-0}" } die() { echo "错误: $*" >&2; exit 1; } ts() { date +%Y%m%d_%H%M%S; } need() { command -v "$1" >/dev/null 2>&1 || die "未找到命令 $1,请先安装"; } # 生成输出文件路径 out_file() { mkdir -p "$OUT_DIR" echo "$OUT_DIR/$1_$(ts).log" } # --------------------------------------------------------------------------- # Android 实时抓取 # --------------------------------------------------------------------------- cmd_android() { local keyword_only=0 all_history=0 outfile="" while [[ $# -gt 0 ]]; do case "$1" in -k|--keyword) keyword_only=1; shift ;; -a|--all) all_history=1; shift ;; -o|--out) outfile="$2"; shift 2 ;; *) die "未知参数 $1" ;; esac done need adb adb get-state >/dev/null 2>&1 || die "adb 没有连接到设备(adb devices 检查一下)" [[ -z "$outfile" ]] && outfile="$(out_file android)" # 只保留关心的 TAG,其余全部静音(*:S) local specs=() for t in "${ANDROID_TAGS[@]}"; do specs+=("$t:V"); done specs+=("AndroidRuntime:E" "*:S") echo "==> 抓取 Android 日志,落盘: $outfile" echo " TAG: ${ANDROID_TAGS[*]}" [[ $keyword_only -eq 1 ]] && echo " 模式: 仅通话翻译关键词" echo " Ctrl+C 结束" echo # -T 1 表示从当前时刻开始,避免历史日志刷屏;-a 则抓取缓冲区里的全部历史 local head_args=(-v threadtime) [[ $all_history -eq 0 ]] && head_args+=(-T 1) adb logcat -c 2>/dev/null || true if [[ $keyword_only -eq 1 ]]; then adb logcat "${head_args[@]}" "${specs[@]}" \ | grep --line-buffered -E "$CT_PATTERN" \ | tee "$outfile" else adb logcat "${head_args[@]}" "${specs[@]}" | tee "$outfile" fi } # --------------------------------------------------------------------------- # iOS 实时抓取 # 真机优先用 idevicesyslog(libimobiledevice,brew install libimobiledevice) # 模拟器 / 本机用 log stream # --------------------------------------------------------------------------- cmd_ios() { local keyword_only=0 use_sim=0 outfile="" while [[ $# -gt 0 ]]; do case "$1" in -k|--keyword) keyword_only=1; shift ;; --sim) use_sim=1; shift ;; -o|--out) outfile="$2"; shift 2 ;; *) die "未知参数 $1" ;; esac done [[ -z "$outfile" ]] && outfile="$(out_file ios)" echo "==> 抓取 iOS 日志,落盘: $outfile" echo " Ctrl+C 结束" echo local filter_cmd=(cat) [[ $keyword_only -eq 1 ]] && filter_cmd=(grep --line-buffered -E "$CT_PATTERN") if [[ $use_sim -eq 1 ]]; then # 模拟器 / 本机:按 subsystem + 进程名过滤 log stream --style syslog --level debug \ --predicate "subsystem == \"$IOS_SUBSYSTEM\" OR process == \"Runner\"" \ | "${filter_cmd[@]}" | tee "$outfile" elif command -v idevicesyslog >/dev/null 2>&1; then # 真机:idevicesyslog 最稳,能同时拿到 os_log 和 Dart print idevicesyslog -m "" 2>/dev/null \ | grep --line-buffered -E "ble_service|BleService|Runner|flutter|$CT_PATTERN" \ | "${filter_cmd[@]}" | tee "$outfile" else echo "提示: 未安装 idevicesyslog,真机日志建议 brew install libimobiledevice" >&2 echo " 现在退回 log stream(新版 macOS 对真机支持受限,若无输出请用 Console.app)" >&2 echo >&2 log stream --style syslog --level debug \ --predicate "subsystem == \"$IOS_SUBSYSTEM\" OR process == \"Runner\"" \ | "${filter_cmd[@]}" | tee "$outfile" fi } # --------------------------------------------------------------------------- # 拉取 app 内的 Dart 日志文件(Logger 写的 app_YYYY-MM-DD*.log) # --------------------------------------------------------------------------- cmd_pull_android() { need adb adb get-state >/dev/null 2>&1 || die "adb 没有连接到设备" local dest="$OUT_DIR/dart_android_$(ts)" mkdir -p "$dest" # getApplicationDocumentsDirectory() → /data/data//app_flutter local remote_dir="app_flutter/logs" echo "==> 从 $APP_ID 拉取 $remote_dir → $dest" local files files="$(adb exec-out run-as "$APP_ID" ls "$remote_dir" 2>/dev/null)" || { die "run-as 失败。release 包不可调试,请改用 debug/profile 包,或让 app 提供日志导出入口" } [[ -z "$files" ]] && die "设备上没有日志文件(app 可能还没初始化 Logger)" while IFS= read -r f; do [[ -z "$f" ]] && continue echo " - $f" adb exec-out run-as "$APP_ID" cat "$remote_dir/$f" > "$dest/$f" done <<< "$files" echo echo "完成: $dest" ls -lh "$dest" } cmd_pull_ios() { need xcrun local dest="$OUT_DIR/dart_ios_$(ts)" mkdir -p "$dest" local udid udid="$(xcrun devicectl list devices 2>/dev/null | awk '/connected/ {print $(NF-1); exit}')" [[ -z "$udid" ]] && die "未找到已连接的 iOS 设备(xcrun devicectl list devices 检查一下)" echo "==> 从设备 $udid 拉取 Documents/logs → $dest" xcrun devicectl device copy from \ --device "$udid" \ --domain-type appDataContainer \ --domain-identifier "$APP_ID" \ --source "Documents/logs" \ --destination "$dest" \ || die "拉取失败。备选方案:Xcode → Window → Devices and Simulators → 选中 app → Download Container" echo "完成: $dest" ls -lh "$dest" } # --------------------------------------------------------------------------- # 离线过滤 / 统计 # --------------------------------------------------------------------------- cmd_filter() { local f="${1:-}" [[ -f "$f" ]] || die "用法: $0 filter <日志文件>" grep -E "$CT_PATTERN" "$f" } cmd_stat() { local f="${1:-}" [[ -f "$f" ]] || die "用法: $0 stat <日志文件>" echo "=========== 通话翻译日志速览: $f ===========" echo echo "-- 关键事件计数 --" local -a keys=( '通话翻译指令下发' '音频下行发送' '下行写入拥塞' '下行写入已恢复' '音频发送队列已满' '声道积压缓存超上限' '收到左声道恢复指令' '收到右声道恢复指令' '心跳连续未发出' '连接参数' '连接断开' ) for k in "${keys[@]}"; do printf ' %-24s %s\n' "$k" "$(grep -c -- "$k" "$f" 2>/dev/null || true)" done echo echo "-- 设备流控(休眠/恢复配对)--" local sleep_n resume_n sleep_n="$(grep -cE '休眠|SLEEP_LEFT|SLEEP_RIGHT|0xF0|0xF1' "$f" 2>/dev/null || true)" resume_n="$(grep -cE '恢复左声道|恢复右声道|0xF2|0xF3' "$f" 2>/dev/null || true)" echo " 休眠(F0/F1) = $sleep_n 恢复(F2/F3) = $resume_n" if [[ "$sleep_n" -gt "$resume_n" ]]; then echo " ⚠️ 休眠多于恢复 $((sleep_n - resume_n)) 次 —— resume 指令可能在拥塞时丢了," echo " 声道会一直卡在休眠态,这正是「怎么都不恢复」的典型形态,重点看最后一次休眠的时刻。" fi echo echo "-- 下行发送节拍 Δ 分布(正常应贴近配置的发送间隔)--" grep -o 'Δ=[0-9]\+ms' "$f" 2>/dev/null | grep -o '[0-9]\+' | awk ' { n++; s+=$1; if($1>max) max=$1; if($1<=50) b1++; else if($1<=100) b2++; else if($1<=300) b3++; else if($1<=1000) b4++; else b5++ } END { if (n==0) { print " (没有匹配到「音频下行发送」行,先确认日志已打开)"; exit } printf " 样本=%d 均值=%.1fms 最大=%dms\n", n, s/n, max printf " <=50ms: %d 51-100ms: %d 101-300ms: %d 301-1000ms: %d >1s: %d\n", b1+0, b2+0, b3+0, b4+0, b5+0 if (b4+b5 > 0) print " ⚠️ 存在 >300ms 的发送空档,对照同一时刻的拥塞/休眠日志看是哪一类原因" }' echo echo "-- 积压水位变化(hold(L=..,R=..),持续上涨即为发不出去)--" grep -o 'hold(L=[0-9]\+,R=[0-9]\+)' "$f" 2>/dev/null | tail -20 || true echo echo "-- 最后 15 条拥塞/丢包告警 --" grep -E '下行写入拥塞|音频发送队列已满|声道积压缓存超上限|心跳连续未发出' "$f" 2>/dev/null | tail -15 || true echo } # --------------------------------------------------------------------------- main() { local sub="${1:-}" [[ $# -gt 0 ]] && shift case "$sub" in android) cmd_android "$@" ;; ios) cmd_ios "$@" ;; pull-android) cmd_pull_android "$@" ;; pull-ios) cmd_pull_ios "$@" ;; filter) cmd_filter "$@" ;; stat) cmd_stat "$@" ;; -h|--help|help|"") usage 0 ;; *) echo "未知子命令: $sub" >&2; usage 1 ;; esac } main "$@"