9 changed files with 951 additions and 39 deletions
File diff suppressed because one or more lines are too long
@ -0,0 +1,289 @@ |
|||||
|
#!/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/<pkg>/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 "$@" |
||||
Loading…
Reference in new issue