第八章:移动端适配与 PWA

3 阅读10分钟

第八章:移动端适配与 PWA

开篇场景

你花了两周时间把 VoiceBot 做得在 Chrome 桌面端运行得很顺滑——语音输入、流式回复、实时播放,一气呵成。然后你把链接发给朋友,朋友用 iPhone 打开,发来一张截图:页面空白,控制台一堆红色报错。

你拿起自己的 Android 手机试了试,好像能用,但声音怪怪的,有时候说话没反应,有时候 AI 还没说完话就自动停了。

移动端是 VoiceAI 的重灾区。这一章我们逐一拆解这些坑,并且教你把 VoiceBot 打包成 PWA(渐进式网页应用),让用户可以安装到主屏幕,像原生 App 一样使用。


8.1 iOS Safari:麦克风的"用户手势"限制

为什么 iOS 这么特别?

苹果对移动端浏览器的限制非常严格,核心原则是:任何需要用户权限的操作,必须在用户的直接手势(tap/click)事件处理函数内发起

这意味着你不能这样写:

// 错误示例:页面加载后自动请求麦克风
window.addEventListener('load', async () => {
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  // iOS 上这会直接失败,或者弹出权限框后立即被拒绝
});

正确的做法是把麦克风请求放在按钮的点击回调里:

// 正确示例
document.getElementById('start-btn').addEventListener('click', async () => {
  // 用户点击了按钮,这是一个合法的"用户手势"
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  startRecording(stream);
});

iOS 上 getUserMedia 的兼容写法

iOS 12 以前根本没有 getUserMedia,iOS 13+ 才加入,但写法和标准有些差异。下面是一个健壮的兼容写法:

// src/audio/microphone.js

/**
 * 跨平台获取麦克风输入流
 * 兼容 iOS Safari、Android Chrome、桌面浏览器
 */
async function getMicrophoneStream() {
  // 标准化 getUserMedia API
  const getUserMedia =
    navigator.mediaDevices?.getUserMedia?.bind(navigator.mediaDevices) ||
    navigator.getUserMedia?.bind(navigator) ||
    navigator.webkitGetUserMedia?.bind(navigator) ||
    navigator.mozGetUserMedia?.bind(navigator);

  if (!getUserMedia) {
    throw new Error('此浏览器不支持麦克风访问,请使用 Chrome 或 Safari 最新版');
  }

  const constraints = {
    audio: {
      // 回声消除(通话场景必须开启)
      echoCancellation: true,
      // 噪声抑制
      noiseSuppression: true,
      // 自动增益(手机离嘴远近不同时有用)
      autoGainControl: true,
      // iOS Safari 要求:不要指定采样率,让浏览器自动选择
      // sampleRate: 16000,  // 注释掉这行!iOS 会报错
    },
  };

  // 旧版 API 使用 callback 风格,包装成 Promise
  if (navigator.mediaDevices?.getUserMedia) {
    return navigator.mediaDevices.getUserMedia(constraints);
  } else {
    return new Promise((resolve, reject) => {
      getUserMedia(constraints, resolve, reject);
    });
  }
}

export { getMicrophoneStream };

为什么不能在 iOS 上指定采样率?

iOS Safari 对 getUserMediasampleRate 约束支持不完整。如果你指定了 sampleRate: 16000,iOS 不会报错,但实际采样率可能还是 44100Hz,更糟的情况是直接返回空流。

正确做法:让 iOS 用它默认的采样率采集,然后在 AudioContext 里做重采样。


8.2 AudioContext 的激活问题(iOS 特殊行为)

问题描述

即使你成功拿到了麦克风流,在 iOS 上还有另一个坑:AudioContext 创建后处于 suspended 状态,必须在用户手势里调用 resume() 才能真正工作。

// 这段代码在 iOS 上不发声,也不报错!
const ctx = new AudioContext();
console.log(ctx.state); // 输出 "suspended" ← 问题在这里

const source = ctx.createBufferSource();
source.connect(ctx.destination);
source.start(); // 静默,没有任何声音

正确的激活流程

// src/audio/audio-context-manager.js

class AudioContextManager {
  constructor() {
    this._ctx = null;
    this._isResumed = false;
  }

  /**
   * 获取 AudioContext 实例
   * 必须在用户手势回调中调用 ensureResumed()
   */
  getContext() {
    if (!this._ctx) {
      // AudioContext 构造时就处于 suspended(iOS)或 running(其他)
      this._ctx = new (window.AudioContext || window.webkitAudioContext)();
    }
    return this._ctx;
  }

  /**
   * 在用户手势里调用这个方法来激活 AudioContext
   * 只需要调用一次,之后 AudioContext 会保持 running 状态
   */
  async ensureResumed() {
    const ctx = this.getContext();
    if (ctx.state === 'suspended') {
      await ctx.resume();
      this._isResumed = true;
      console.log('[AudioContext] 已激活,state:', ctx.state);
    }
    return ctx;
  }

  get isRunning() {
    return this._ctx?.state === 'running';
  }
}

// 全局单例,整个应用共用一个 AudioContext
export const audioContextManager = new AudioContextManager();

把这个管理器整合到开始录音的按钮里:

// src/app.js

import { audioContextManager } from './audio/audio-context-manager.js';
import { getMicrophoneStream } from './audio/microphone.js';

document.getElementById('start-btn').addEventListener('click', async () => {
  try {
    // 第一步:在用户手势里激活 AudioContext(iOS 关键步骤)
    const ctx = await audioContextManager.ensureResumed();

    // 第二步:请求麦克风权限(同样需要在用户手势里)
    const stream = await getMicrophoneStream();

    // 第三步:开始处理音频
    await startVoiceSession(ctx, stream);

    // 更新 UI
    document.getElementById('start-btn').textContent = '停止';
    document.getElementById('status').textContent = '正在聆听...';
  } catch (err) {
    handleMicrophoneError(err);
  }
});

function handleMicrophoneError(err) {
  let message = '无法访问麦克风';

  if (err.name === 'NotAllowedError') {
    message = '麦克风权限被拒绝,请在浏览器设置中允许访问';
  } else if (err.name === 'NotFoundError') {
    message = '未找到麦克风设备';
  } else if (err.name === 'NotSupportedError') {
    message = '此浏览器不支持麦克风访问';
  }

  document.getElementById('status').textContent = message;
  console.error('[麦克风错误]', err.name, err.message);
}

8.3 Android Chrome:性能才是真正的坑

Android Chrome 对 Web Audio API 的支持比 iOS Safari 好得多,基本没有 API 层面的坑。但低端 Android 机(1-2GB RAM,八核但频率很低)有明显的性能问题:

  • ScriptProcessor 回调卡顿:低端机上 JavaScript 主线程负载过高,音频回调延迟超过 100ms,导致录音断帧
  • AudioWorklet 是解药:把音频处理移到独立线程,即使主线程忙碌也不影响音频
  • WebSocket 压力:频繁发送音频数据包(每 100ms 一次)可能把低端机压垮

用 AudioWorklet 代替 ScriptProcessorNode

// src/audio/worklet/audio-processor.worklet.js
// 这个文件运行在独立的 AudioWorklet 线程里

class AudioProcessor extends AudioWorkletProcessor {
  constructor() {
    super();
    this._buffer = [];
    this._bufferSize = 0;
    // 每积累 4096 个采样点(约 256ms@16kHz)才发送一次,减少消息传递频率
    this._targetSize = 4096;
  }

  process(inputs, outputs, parameters) {
    const input = inputs[0];
    if (!input || !input[0]) return true;

    const samples = input[0]; // Float32Array,单声道

    // 累积到目标大小再发送,避免过于频繁的消息传递
    this._buffer.push(...samples);
    this._bufferSize += samples.length;

    if (this._bufferSize >= this._targetSize) {
      // 把 Float32Array 转成 Int16Array(减少数据量一半)
      const int16 = this._float32ToInt16(new Float32Array(this._buffer));
      this.port.postMessage(int16, [int16.buffer]); // Transferable,零拷贝
      this._buffer = [];
      this._bufferSize = 0;
    }

    return true; // 返回 true 表示节点保持活跃
  }

  _float32ToInt16(float32Array) {
    const int16 = new Int16Array(float32Array.length);
    for (let i = 0; i < float32Array.length; i++) {
      // 限幅到 [-1, 1],然后乘以 32767
      const clamped = Math.max(-1, Math.min(1, float32Array[i]));
      int16[i] = clamped * 32767;
    }
    return int16;
  }
}

registerProcessor('audio-processor', AudioProcessor);
// src/audio/recorder.js

export class AudioRecorder {
  constructor(audioContext) {
    this._ctx = audioContext;
    this._workletNode = null;
    this._stream = null;
    this._onAudioData = null;
  }

  /**
   * 初始化 AudioWorklet(需要提前加载 worklet 模块)
   */
  async init() {
    await this._ctx.audioWorklet.addModule('/src/audio/worklet/audio-processor.worklet.js');
    console.log('[AudioRecorder] Worklet 加载完成');
  }

  /**
   * 开始录音
   * @param {MediaStream} stream - 麦克风流
   * @param {Function} onData - 收到音频数据的回调,参数为 Int16Array
   */
  async start(stream, onData) {
    this._stream = stream;
    this._onAudioData = onData;

    // 创建麦克风输入节点
    const source = this._ctx.createMediaStreamSource(stream);

    // 创建 Worklet 处理节点
    this._workletNode = new AudioWorkletNode(this._ctx, 'audio-processor');

    // 监听 worklet 发来的数据
    this._workletNode.port.onmessage = (event) => {
      if (this._onAudioData) {
        this._onAudioData(event.data); // Int16Array
      }
    };

    // 连接音频图
    source.connect(this._workletNode);
    // 注意:worklet 节点不需要连接到 destination,它只是处理数据

    console.log('[AudioRecorder] 开始录音');
  }

  stop() {
    if (this._stream) {
      this._stream.getTracks().forEach((track) => track.stop());
      this._stream = null;
    }
    if (this._workletNode) {
      this._workletNode.disconnect();
      this._workletNode = null;
    }
    console.log('[AudioRecorder] 停止录音');
  }
}

8.4 把 VoiceBot 变成 PWA

PWA(Progressive Web App)可以让用户把网页"安装"到手机主屏幕,启动时没有浏览器地址栏,体验接近原生 App。对 VoiceBot 来说,PWA 还有一个重要好处:Service Worker 可以缓存静态资源,让应用在网络较差时依然能快速加载

PWA 三要素

浏览器检查 PWA 安装条件:
┌─────────────────────────────────────────┐
│  1. HTTPS(或 localhost 开发时)         │
│  2. manifest.json(应用元信息)          │
│  3. Service Worker(已注册且激活)       │
└─────────────────────────────────────────┘
        ↓ 三个条件都满足
   浏览器显示"添加到主屏幕"提示

8.4.1 manifest.json

在项目根目录创建 public/manifest.json

{
  "name": "VoiceBot - AI 语音助手",
  "short_name": "VoiceBot",
  "description": "随时随地与 AI 对话的语音助手",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#1a1a2e",
  "theme_color": "#6c63ff",
  "orientation": "portrait-primary",
  "lang": "zh-CN",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable any"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/home.png",
      "sizes": "1080x1920",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "VoiceBot 主界面"
    }
  ],
  "categories": ["productivity", "utilities"],
  "shortcuts": [
    {
      "name": "开始对话",
      "short_name": "对话",
      "description": "立即开始语音对话",
      "url": "/?action=start",
      "icons": [{ "src": "/icons/shortcut-chat.png", "sizes": "96x96" }]
    }
  ]
}

在 HTML 里引用 manifest:

<!-- public/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />

    <!-- PWA manifest -->
    <link rel="manifest" href="/manifest.json" />

    <!-- iOS 特殊标签(Safari 不完全支持 manifest,需要这些) -->
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
    <meta name="apple-mobile-web-app-title" content="VoiceBot" />
    <link rel="apple-touch-icon" href="/icons/icon-192x192.png" />

    <!-- Android Chrome 主题色 -->
    <meta name="theme-color" content="#6c63ff" />

    <title>VoiceBot - AI 语音助手</title>
  </head>
  <body>
    <!-- 应用内容 -->
    <div id="app"></div>
    <script type="module" src="/src/app.js"></script>
  </body>
</html>

8.4.2 Service Worker 缓存策略

Service Worker 是一个运行在浏览器后台的脚本,可以拦截网络请求并决定是从缓存返回还是从网络获取。

VoiceBot 的资源可以分为两类:

  • 静态资源(HTML、CSS、JS、图标):可以积极缓存,更新时用新版本替换
  • API 请求(WebSocket 连接、后端接口):不缓存,始终走网络
// public/service-worker.js

const CACHE_VERSION = 'voicebot-v1.2.0'; // 每次发版时更新这个版本号
const STATIC_CACHE = `${CACHE_VERSION}-static`;

// 预缓存列表:应用外壳(App Shell)
const APP_SHELL = [
  '/',
  '/index.html',
  '/src/app.js',
  '/src/audio/audio-context-manager.js',
  '/src/audio/microphone.js',
  '/src/audio/recorder.js',
  '/src/audio/worklet/audio-processor.worklet.js',
  '/src/styles/main.css',
  '/icons/icon-192x192.png',
  '/icons/icon-512x512.png',
  '/manifest.json',
];

// ==================== 安装阶段 ====================
// Service Worker 首次注册或版本更新时触发
self.addEventListener('install', (event) => {
  console.log('[SW] 安装中,版本:', CACHE_VERSION);

  event.waitUntil(
    caches
      .open(STATIC_CACHE)
      .then((cache) => {
        console.log('[SW] 预缓存 App Shell');
        return cache.addAll(APP_SHELL);
      })
      .then(() => {
        // 跳过等待,立即激活新版本
        return self.skipWaiting();
      })
  );
});

// ==================== 激活阶段 ====================
// 新版本 SW 激活时,清理旧缓存
self.addEventListener('activate', (event) => {
  console.log('[SW] 激活,清理旧缓存');

  event.waitUntil(
    caches
      .keys()
      .then((cacheNames) => {
        return Promise.all(
          cacheNames
            .filter((name) => name.startsWith('voicebot-') && name !== STATIC_CACHE)
            .map((name) => {
              console.log('[SW] 删除旧缓存:', name);
              return caches.delete(name);
            })
        );
      })
      .then(() => {
        // 让新 SW 立即接管所有页面
        return self.clients.claim();
      })
  );
});

// ==================== 请求拦截 ====================
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  // WebSocket 请求不拦截(SW 无法处理 WebSocket)
  if (url.protocol === 'ws:' || url.protocol === 'wss:') {
    return;
  }

  // API 请求不缓存,直接走网络
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(fetch(event.request));
    return;
  }

  // 静态资源:Cache First 策略
  // 先查缓存,命中直接返回;未命中再请求网络并更新缓存
  event.respondWith(cacheFirst(event.request));
});

async function cacheFirst(request) {
  const cache = await caches.open(STATIC_CACHE);
  const cached = await cache.match(request);

  if (cached) {
    // 后台异步更新缓存(Stale-While-Revalidate 变体)
    updateCacheInBackground(cache, request);
    return cached;
  }

  // 缓存未命中,从网络获取
  try {
    const response = await fetch(request);
    if (response.ok) {
      cache.put(request, response.clone()); // 存入缓存
    }
    return response;
  } catch (err) {
    // 网络也失败了,返回离线页面(如果有的话)
    console.warn('[SW] 网络请求失败:', request.url);
    return new Response('离线模式:请检查网络连接', {
      status: 503,
      headers: { 'Content-Type': 'text/plain; charset=utf-8' },
    });
  }
}

function updateCacheInBackground(cache, request) {
  fetch(request)
    .then((response) => {
      if (response.ok) {
        cache.put(request, response);
      }
    })
    .catch(() => {
      // 后台更新失败,静默忽略
    });
}

在应用里注册 Service Worker:

// src/sw-register.js

export async function registerServiceWorker() {
  if (!('serviceWorker' in navigator)) {
    console.warn('[SW] 此浏览器不支持 Service Worker');
    return;
  }

  try {
    const registration = await navigator.serviceWorker.register('/service-worker.js', {
      scope: '/',
    });

    console.log('[SW] 注册成功,scope:', registration.scope);

    // 监听 SW 更新
    registration.addEventListener('updatefound', () => {
      const newWorker = registration.installing;
      newWorker.addEventListener('statechange', () => {
        if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
          // 有新版本可用,提示用户刷新
          showUpdatePrompt();
        }
      });
    });
  } catch (err) {
    console.error('[SW] 注册失败:', err);
  }
}

function showUpdatePrompt() {
  // 显示一个非侵入式的更新提示
  const banner = document.createElement('div');
  banner.style.cssText = `
    position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
    background: #6c63ff; color: white; padding: 12px 20px; border-radius: 8px;
    font-size: 14px; z-index: 9999; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
    display: flex; align-items: center; gap: 12px;
  `;
  banner.innerHTML = `
    <span>VoiceBot 有新版本可用</span>
    <button onclick="location.reload()" style="
      background: white; color: #6c63ff; border: none;
      padding: 4px 12px; border-radius: 4px; cursor: pointer; font-weight: bold;
    ">立即更新</button>
  `;
  document.body.appendChild(banner);
}

8.4.3 引导用户安装到主屏幕

浏览器会在合适的时机触发 beforeinstallprompt 事件,我们可以拦截它,保存起来,等用户深度使用后再展示安装提示。

// src/pwa-install.js

class PWAInstallManager {
  constructor() {
    this._deferredPrompt = null;
    this._installBtn = null;
    this._sessionStartCount = 0;
  }

  init() {
    // 拦截浏览器的默认安装提示
    window.addEventListener('beforeinstallprompt', (event) => {
      event.preventDefault(); // 阻止立即显示
      this._deferredPrompt = event;
      console.log('[PWA] 安装提示已准备好');

      // 用户用了 3 次以上,才显示安装按钮
      this._sessionStartCount++;
      if (this._sessionStartCount >= 3) {
        this._showInstallButton();
      }
    });

    // 监听安装完成事件
    window.addEventListener('appinstalled', () => {
      console.log('[PWA] 用户已安装 VoiceBot');
      this._deferredPrompt = null;
      this._hideInstallButton();
      // 可以记录到分析平台
    });

    // 检查是否已经在 standalone 模式下运行(已安装)
    if (window.matchMedia('(display-mode: standalone)').matches) {
      console.log('[PWA] 正在以独立应用模式运行');
      document.body.classList.add('pwa-standalone');
    }
  }

  async triggerInstall() {
    if (!this._deferredPrompt) {
      alert('请使用浏览器菜单中的"添加到主屏幕"选项');
      return;
    }

    // 显示浏览器的原生安装对话框
    this._deferredPrompt.prompt();

    const { outcome } = await this._deferredPrompt.userChoice;
    console.log('[PWA] 用户选择:', outcome); // 'accepted' 或 'dismissed'

    this._deferredPrompt = null;
    this._hideInstallButton();
  }

  _showInstallButton() {
    if (!this._installBtn) {
      this._installBtn = document.createElement('button');
      this._installBtn.textContent = '📱 安装到手机';
      this._installBtn.className = 'install-btn';
      this._installBtn.addEventListener('click', () => this.triggerInstall());
      document.getElementById('toolbar').appendChild(this._installBtn);
    }
    this._installBtn.style.display = 'block';
  }

  _hideInstallButton() {
    if (this._installBtn) {
      this._installBtn.style.display = 'none';
    }
  }
}

export const pwaInstallManager = new PWAInstallManager();

8.5 移动端音频省电策略

手机在后台或息屏时,浏览器会限制 JavaScript 执行,AudioContext 也可能被暂停。我们需要监听页面可见性变化,主动管理音频资源。

// src/audio/visibility-manager.js

import { audioContextManager } from './audio-context-manager.js';

/**
 * 监听页面可见性,在后台时暂停音频处理,切回前台时恢复
 * 这能显著减少后台功耗,避免在用户不使用时持续占用资源
 */
class VisibilityManager {
  constructor() {
    this._recorder = null;
    this._wasRecording = false;
    this._onPause = null;
    this._onResume = null;
  }

  init({ onPause, onResume } = {}) {
    this._onPause = onPause;
    this._onResume = onResume;

    document.addEventListener('visibilitychange', this._handleVisibilityChange.bind(this));

    // iOS Safari 的页面隐藏事件(visibilitychange 有时不可靠)
    window.addEventListener('pagehide', this._handlePageHide.bind(this));
    window.addEventListener('pageshow', this._handlePageShow.bind(this));
  }

  _handleVisibilityChange() {
    if (document.visibilityState === 'hidden') {
      this._handleBackground();
    } else if (document.visibilityState === 'visible') {
      this._handleForeground();
    }
  }

  _handlePageHide(event) {
    // event.persisted = true 表示页面进入 BFCache(返回时不会重新加载)
    this._handleBackground();
  }

  _handlePageShow(event) {
    if (event.persisted) {
      // 从 BFCache 恢复,AudioContext 可能已经被挂起
      this._handleForeground();
    }
  }

  _handleBackground() {
    const ctx = audioContextManager.getContext();
    if (ctx?.state === 'running') {
      // 挂起 AudioContext,释放音频硬件资源
      ctx.suspend().then(() => {
        console.log('[可见性] 页面进入后台,音频已暂停');
      });
    }

    if (this._onPause) {
      this._onPause();
    }
  }

  _handleForeground() {
    const ctx = audioContextManager.getContext();
    if (ctx?.state === 'suspended') {
      // 注意:resume() 在某些情况下需要用户手势,这里可能失败
      ctx.resume().then(() => {
        console.log('[可见性] 页面回到前台,音频已恢复');
      }).catch((err) => {
        console.warn('[可见性] 恢复音频失败,需要用户点击:', err);
        // 显示一个提示让用户点击
        showTapToResumeHint();
      });
    }

    if (this._onResume) {
      this._onResume();
    }
  }
}

function showTapToResumeHint() {
  const hint = document.getElementById('tap-to-resume');
  if (hint) {
    hint.style.display = 'block';
    hint.addEventListener('click', async () => {
      await audioContextManager.ensureResumed();
      hint.style.display = 'none';
    }, { once: true });
  }
}

export const visibilityManager = new VisibilityManager();

在 HTML 里加上这个提示:

<!-- 后台返回时的恢复提示 -->
<div id="tap-to-resume" style="display:none; position:fixed; inset:0;
  background:rgba(0,0,0,0.7); color:white; font-size:18px;
  display:none; align-items:center; justify-content:center; z-index:1000;">
  <div style="text-align:center; padding:20px;">
    <div style="font-size:48px; margin-bottom:16px;">🎙️</div>
    <div>点击任意位置继续对话</div>
  </div>
</div>

8.6 完整的移动端入口文件

把上面所有模块组合起来:

// src/app.js - 移动端优化版

import { audioContextManager } from './audio/audio-context-manager.js';
import { getMicrophoneStream } from './audio/microphone.js';
import { AudioRecorder } from './audio/recorder.js';
import { visibilityManager } from './audio/visibility-manager.js';
import { pwaInstallManager } from './pwa-install.js';
import { registerServiceWorker } from './sw-register.js';
import { VoiceBotClient } from './voicebot-client.js';

// ==================== 初始化 ====================

let recorder = null;
let botClient = null;
let isSessionActive = false;

async function init() {
  // 注册 Service Worker
  await registerServiceWorker();

  // 初始化 PWA 安装管理
  pwaInstallManager.init();

  // 初始化可见性管理(后台省电)
  visibilityManager.init({
    onPause: () => {
      if (isSessionActive) {
        updateStatus('应用在后台,对话已暂停');
      }
    },
    onResume: () => {
      if (isSessionActive) {
        updateStatus('对话已恢复');
      }
    },
  });

  // 绑定按钮事件
  document.getElementById('start-btn').addEventListener('click', handleStartClick);
  document.getElementById('stop-btn').addEventListener('click', handleStopClick);

  updateStatus('点击下方按钮开始对话');
  console.log('[App] 初始化完成');
}

// ==================== 对话控制 ====================

async function handleStartClick() {
  try {
    updateStatus('正在请求麦克风权限...');

    // 关键:在用户手势里同时激活 AudioContext 和请求麦克风
    const [ctx, stream] = await Promise.all([
      audioContextManager.ensureResumed(),
      getMicrophoneStream(),
    ]);

    // 初始化录音器
    recorder = new AudioRecorder(ctx);
    await recorder.init(); // 加载 AudioWorklet

    // 初始化 WebSocket 客户端
    botClient = new VoiceBotClient({
      wsUrl: `wss://${location.host}/ws/voice`,
      onTranscript: (text) => updateTranscript('你', text),
      onReply: (text) => updateTranscript('AI', text),
      onError: (err) => updateStatus(`错误: ${err}`),
    });
    await botClient.connect();

    // 开始录音,把数据发给服务端
    await recorder.start(stream, (audioData) => {
      botClient.sendAudio(audioData);
    });

    isSessionActive = true;
    setUIState('recording');
    updateStatus('正在聆听...');
  } catch (err) {
    handleError(err);
  }
}

async function handleStopClick() {
  if (recorder) {
    recorder.stop();
    recorder = null;
  }
  if (botClient) {
    botClient.disconnect();
    botClient = null;
  }
  isSessionActive = false;
  setUIState('idle');
  updateStatus('对话已结束');
}

// ==================== UI 更新 ====================

function setUIState(state) {
  const startBtn = document.getElementById('start-btn');
  const stopBtn = document.getElementById('stop-btn');

  if (state === 'recording') {
    startBtn.style.display = 'none';
    stopBtn.style.display = 'block';
  } else {
    startBtn.style.display = 'block';
    stopBtn.style.display = 'none';
  }
}

function updateStatus(message) {
  document.getElementById('status').textContent = message;
}

function updateTranscript(speaker, text) {
  const container = document.getElementById('transcript');
  const entry = document.createElement('div');
  entry.className = `transcript-entry transcript-${speaker === '你' ? 'user' : 'ai'}`;
  entry.textContent = `${speaker}${text}`;
  container.appendChild(entry);
  container.scrollTop = container.scrollHeight;
}

function handleError(err) {
  console.error('[App] 错误:', err);
  let message = '发生未知错误';

  if (err.name === 'NotAllowedError') {
    message = '麦克风被拒绝,请在浏览器设置中开启权限';
  } else if (err.name === 'NotFoundError') {
    message = '未找到麦克风设备';
  } else if (err.message?.includes('AudioWorklet')) {
    message = '音频处理初始化失败,请刷新重试';
  }

  updateStatus(message);
  setUIState('idle');
}

// 页面加载后初始化(不涉及音频,不需要用户手势)
document.addEventListener('DOMContentLoaded', init);

8.7 移动端测试清单

在发布前,对照这个清单逐项测试:

iOS Safari 测试

iOS Safari 兼容性检查:

  基础功能
  ├── [ ] 页面加载正常,无 JS 错误
  ├── [ ] 点击按钮后弹出麦克风权限请求
  ├── [ ] 授权后可以正常录音
  ├── [ ] 拒绝权限后显示友好错误提示
  └── [ ] 音频播放正常(AI 回复可以听到)

  AudioContext 行为
  ├── [ ] 首次点击按钮后 AudioContext.state === 'running'
  ├── [ ] 页面进后台再回来,音频恢复正常
  └── [ ] 锁屏后解锁,提示用户点击恢复

  PWA
  ├── [ ] Safari 地址栏显示"分享"按钮可以"添加到主屏幕"
  ├── [ ] 从主屏幕启动,全屏显示(无地址栏)
  └── [ ] 应用图标显示正确

Android Chrome 测试

Android Chrome 兼容性检查:

  基础功能
  ├── [ ] 高端机(骁龙 8xx)流畅运行
  ├── [ ] 低端机(骁龙 4xx 或联发科低端)可以运行
  ├── [ ] 录音无明显延迟或断帧
  └── [ ] 长时间对话(10分钟+)无内存泄漏

  PWA
  ├── [ ] 地址栏显示安装提示(首次访问后)
  ├── [ ] 安装后从主屏幕启动正常
  ├── [ ] Service Worker 缓存生效(断网后能加载页面)
  └── [ ] 更新后显示"有新版本"提示

  后台行为
  ├── [ ] 切换到其他 App 再回来,对话可以恢复
  ├── [ ] 后台不持续消耗电量(AudioContext 已暂停)
  └── [ ] 通话打断后能正常处理

用 Chrome DevTools 模拟移动端

# 在 Chrome 打开 DevTools → 切换设备模拟模式
# 重点检查:
# 1. Application → Service Workers:SW 是否注册成功
# 2. Application → Cache → Cache Storage:缓存内容是否正确
# 3. Application → Manifest:manifest.json 是否解析正确
# 4. Lighthouse → PWA 评分:应达到 90+ 分

本章小结

本章覆盖了 VoiceBot 移动端适配的核心难点:

  • iOS 麦克风限制getUserMediaAudioContext.resume() 必须在用户手势回调里调用,不能在页面加载时自动触发
  • AudioContext 激活:iOS 上新建的 AudioContext 默认是 suspended 状态,必须显式调用 resume()
  • 跨平台 getUserMedia:不要在 iOS 上指定 sampleRate,让浏览器自动选择
  • Android 性能:用 AudioWorklet 替代 ScriptProcessorNode,把音频处理移到独立线程
  • PWA 三要素:HTTPS + manifest.json + Service Worker,满足这三个条件即可安装
  • Service Worker 缓存:静态资源用 Cache First 策略,API 请求不缓存
  • 省电策略:监听 visibilitychange 事件,后台时暂停 AudioContext

下一章,我们把视线从客户端移到服务端。客户端的 VAD 可能会漏检或误触发,服务端需要一套自己的 VAD 机制来兜底——这就是服务端 VAD的价值所在。