突破内网HTTPS限制:音频采集与播放完整解决方案

74 阅读4分钟

突破内网HTTPS限制:音频采集与播放完整解决方案

背景与挑战

在前端开发中,调用浏览器音频API(包括录音和语音播放)通常要求页面运行在HTTPS环境下,这是现代浏览器的安全策略。然而,内部网络环境中,部署HTTPS面临以下实际困难:

  1. 证书部署复杂:内网证书需要自建CA或申请专用证书
  2. 用户体验差:首次访问时需手动信任证书,操作繁琐
  3. 维护成本高:证书更新和续期带来额外运维负担

解决方案概述

本方案通过本地化App打包结合H5+扩展能力,完全绕过HTTPS限制,实现在警务通等专用设备上的稳定音频功能。核心思路是:将Web应用打包为本地App,利用H5+桥接能力直接调用设备原生音频权限。

技术实现详述

一、环境检测与权限管理

通过封装PlusHelper类,智能检测运行环境并统一权限调用接口:

/**
 * Plus 工具类 - 完全修复版
 */
class PlusHelper {
  constructor() {
    this.isReady = false
    this.isPlus = false
    this.readyCallbacks = []

    this.init()
  }

  init() {
    // 检测环境
    this.checkEnvironment()
  }

  checkEnvironment() {
    const hasPlus = typeof window !== 'undefined' && !!window.plus
    const ua = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : ''
    const isPlusEnv = hasPlus || ua.indexOf('html5plus') > -1 || ua.indexOf('streamapp') > -1

    console.log('[PlusHelper] 环境检测:', {
      hasPlus: hasPlus,
      isPlusEnv: isPlusEnv,
      isBrowser: !isPlusEnv
    })

    if (hasPlus) {
      // App 环境且 plus 已就绪
      console.log('[PlusHelper] App环境,plus已存在')
      this.isPlus = true
      this.isReady = true
      // 执行所有回调(异步,确保不阻塞)
      setTimeout(() => this.flushCallbacks(), 0)
    } else if (isPlusEnv) {
      // App 环境但 plus 未就绪,等待事件
      console.log('[PlusHelper] App环境,等待plusready')
      this.isPlus = true
      this.listenPlusReady()
    } else {
      // 浏览器环境
      console.log('[PlusHelper] 浏览器环境,直接回调')
      this.isPlus = false
      this.isReady = true
      // 浏览器环境也要执行回调,传入 null
      setTimeout(() => this.flushCallbacks(), 0)
    }
  }

  listenPlusReady() {
    const handleReady = () => {
      console.log('[PlusHelper] plusready事件触发')
      this.isReady = true
      this.flushCallbacks()
    }

    // 监听事件
    if (typeof document !== 'undefined') {
      document.addEventListener('plusready', handleReady, false)
    }

    // 备用:可能已经错过了事件
    setTimeout(() => {
      if (!this.isReady && window.plus) {
        console.log('[PlusHelper] 延迟检查发现plus就绪')
        handleReady()
      }
    }, 100)
  }

  flushCallbacks() {
    console.log('[PlusHelper] 执行回调队列,长度:', this.readyCallbacks.length)

    while (this.readyCallbacks.length > 0) {
      const callback = this.readyCallbacks.shift()
      try {
        callback(this.isPlus ? window.plus : null)
      } catch (e) {
        console.error('[PlusHelper] 回调执行错误:', e)
      }
    }
  }

  /**
     * 等待 plus 就绪
     */
  ready(callback) {
    console.log('[PlusHelper] ready()被调用,当前状态:', {
      isReady: this.isReady,
      isPlus: this.isPlus,
      callback类型: typeof callback
    })

    if (typeof callback !== 'function') {
      console.error('[PlusHelper] 错误:callback必须是函数')
      return Promise.reject(new Error('callback must be a function'))
    }

    if (this.isReady) {
      // 已经就绪,立即执行
      console.log('[PlusHelper] 立即执行回调')
      setTimeout(() => {
        callback(this.isPlus ? window.plus : null)
      }, 0)
    } else {
      // 加入队列等待
      console.log('[PlusHelper] 加入等待队列')
      this.readyCallbacks.push(callback)
    }
  }

  isApp() {
    const ua = navigator.userAgent.toLowerCase()
    return !!window.plus || ua.indexOf('html5plus') > -1 || ua.indexOf('streamapp') > -1
  }
}

// 创建单例
const plusHelper = new PlusHelper()

export default plusHelper
export const plusReady = (callback) => plusHelper.ready(callback)
export const isApp = () => plusHelper.isApp()

二、音频权限获取策略

  1. 双环境适配机制

  • App环境:通过plus.android.requestPermissions调用系统级权限对话框
  • 浏览器环境:降级使用navigator.mediaDevices.getUserMediaAPI
async getInitAudio () {
      // 如果之前已经初始化过,先清掉
      if (this.rec) {
        this.rec && this.rec.destroy && this.rec.destroy()
        this.rec = null
      }
      if (this.audioCtx?.state !== 'closed') await this.audioCtx?.close()
      if (this.localStream) {
        this.localStream.getTracks().forEach(t => t.stop())
      }

      // plus 环境先弹系统权限
      if (this.plusReady && window.plus) {
        await new Promise((resolve, reject) => {
          plus.android.requestPermissions(
            ['android.permission.RECORD_AUDIO'],
            res => {
              res.deniedAlways.length
                ? reject(new Error('RECORD_AUDIO deniedAlways'))
                : resolve()
            },
            err => reject(new Error(err.message || 'requestPermissions fail'))
          )
        })
      }

      // 再取流
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
      this.localStream = stream
      this.audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 })
      this.rec = new Recorder(this.audioCtx, { numChannels: 1 })
      await this.rec.init(stream)
    }

2. #### 权限释放机制

 async stopSpeech () {
      if (!this.rec) return
      this.isRecording = false
      const { buffer } = await this.rec.stop()
      // 1. 销毁 recorder 实例(释放 ScriptProcessor 节点)
      this.rec.destroy && this.rec.destroy()
      this.rec = null

      // 2. 关闭 AudioContext
      if (this.rec && this.rec.context && this.rec.context.state !== 'closed') {
        await this.rec.context.close()
      }

      // 3. 真正释放麦克风
      if (this.localStream) {
        this.localStream.getTracks().forEach(track => track.stop())
        this.localStream = null
      }

      // 后面保持原来的逻辑
      const int16 = floatTo16BitPCM(buffer[0])
      this.wavBlob = encodeWAV(int16, 16000)
      this.wavUrl = URL.createObjectURL(this.wavBlob)
      this.uploadFile()
    }

三、流式TTS语音合成优化

  1. 预缓存池设计

prefetchTts () {
      const N = 3
      const todo = this.ttsQueue.slice(0, N).filter(t => !this.ttsCache.has(t))
      todo.forEach(text => this.ttsCache.set(text, this.doFetchTts(text)))
    },

    async doFetchTts (text) {
      const res = await fetch(`${window.baseURL}/bkw/v1/audio/speech`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: 'Basic ' + window.token
        },
        body: JSON.stringify({
          input: text,
          model: 'fish-speech',
          response_format: 'mp3',
          speed: this.speed,
          voice: this.voice,
          streaming: true
        })
      })
      if (!res.ok) throw new Error('TTS 请求失败')
      return res.blob()
    },

2. #### 智能分段与播放队列

appendTextForTts (text) {
      this.textBuffer += text
      const regex = /([。!?;\n])/
      const parts = this.textBuffer.split(regex)
      while (parts.length >= 2) {
        const sentence = parts.shift() + parts.shift()
        const trimmed = sentence.trim()
        if (trimmed.length > 1) this.addToTtsQueue(trimmed)
      }
      this.textBuffer = parts.join('')
      if (this.textBuffer.length > 30) {
        this.addToTtsQueue(this.textBuffer)
        this.textBuffer = ''
      }
    },

3. #### 结合以上代码,以下是完成可用的代码

import Recorder from 'recorder-js'
import { plusReady, isApp } from '@/utils/plus'

// ========== 工具:float32 → int16 ==========
function floatTo16BitPCM (input) {
  const output = new Int16Array(input.length)
  for (let i = 0; i < input.length; i++) {
    const s = Math.max(-1, Math.min(1, input[i]))
    output[i] = s < 0 ? s * 0x8000 : s * 0x7FFF
  }
  return output
}

// ========== 工具:封装 WAV ==========
function encodeWAV (samples, sampleRate) {
  const len = samples.length * 2 + 44
  const buffer = new ArrayBuffer(len)
  const view = new DataView(buffer)
  const writeString = (offset, string) => {
    for (let i = 0; i < string.length; i++) view.setUint8(offset + i, string.charCodeAt(i))
  }

  writeString(0, 'RIFF')
  view.setUint32(4, len - 8, true)
  writeString(8, 'WAVE')
  writeString(12, 'fmt ')
  view.setUint32(16, 16, true)
  view.setUint16(20, 1, true)
  view.setUint16(22, 1, true)
  view.setUint32(24, sampleRate, true)
  view.setUint32(28, sampleRate * 2, true)
  view.setUint16(32, 2, true)
  view.setUint16(34, 16, true)
  writeString(36, 'data')
  view.setUint32(40, samples.length * 2, true)

  let offset = 44
  for (let i = 0; i < samples.length; i++, offset += 2) view.setInt16(offset, samples[i], true)
  return new Blob([buffer], { type: 'audio/wav' })
}

export default {
  data () {
    return {
      // STT
      rec: null,
      wavBlob: null,
      wavUrl: null,
      isLoading: false,
      isRecording: false,

      // TTS
      ttsQueue: [],
      isTtsPlaying: false,
      currentAudio: null,
      textBuffer: '',
      isMuted: false,

      // plus
      plusReady: false,
      isAppEnv: false,

      // 新增:TTS 预缓冲池
      ttsCache: new Map(), // key=文本,value=Promise<Blob>
      localStream: null,
      audioCtx: null
    }
  },

  mounted () {
    this.checkPlus()
  },

  beforeDestroy () {
    if (this.wavUrl) URL.revokeObjectURL(this.wavUrl)

    // 停 TTS
    this.stopTts()

    // 停录音
    if (this.rec) {
      this.rec.destroy && this.rec.destroy()
      if (this.rec.context && this.rec.context.state !== 'closed') {
        this.rec.context.close()
      }
    }
    if (this.localStream) {
      this.localStream.getTracks().forEach(track => track.stop())
      this.localStream = null
    }

    // 清缓冲池
    this.ttsCache.forEach((_, t) => this.cleanCache(t))
    this.ttsCache.clear()
  },

  methods: {
    /* ======================================== */
    /* ================  STT  ================= */
    /* ======================================== */
    async speech () {
      if (!this.rec || !this.localStream) {
        try {
          await this.getInitAudio() // 真正拿流
        } catch (e) {
          this.$mNotify({ message: '麦克风权限获取失败', type: 'warning', duration: 2000 })
          return
        }
      }
      this.wavBlob = this.wavUrl = null
      this.isRecording = true
      await this.rec.start()
    },

    async stopSpeech () {
      if (!this.rec) return
      this.isRecording = false
      const { buffer } = await this.rec.stop()
      // 1. 销毁 recorder 实例(释放 ScriptProcessor 节点)
      this.rec.destroy && this.rec.destroy()
      this.rec = null

      // 2. 关闭 AudioContext
      if (this.rec && this.rec.context && this.rec.context.state !== 'closed') {
        await this.rec.context.close()
      }

      // 3. 真正释放麦克风
      if (this.localStream) {
        this.localStream.getTracks().forEach(track => track.stop())
        this.localStream = null
      }

      // 后面保持原来的逻辑
      const int16 = floatTo16BitPCM(buffer[0])
      this.wavBlob = encodeWAV(int16, 16000)
      this.wavUrl = URL.createObjectURL(this.wavBlob)
      this.uploadFile()
    },

    async cancelSpeech () {
      this.isRecording = false
      await this.rec.stop()

      // 同样三步
      this.rec.destroy && this.rec.destroy()
      this.rec = null
      if (this.rec && this.rec.context && this.rec.context.state !== 'closed') {
        await this.rec.context.close()
      }
      if (this.localStream) {
        this.localStream.getTracks().forEach(track => track.stop())
        this.localStream = null
      }

      this.wavBlob = null
      if (this.wavUrl) {
        URL.revokeObjectURL(this.wavUrl)
        this.wavUrl = null
      }
    },

    async uploadFile () {
      if (!this.wavBlob) return
      this.isLoading = true
      const form = new FormData()
      form.append('file', this.wavBlob, 'voice.wav')
      form.append('model', 'funasr')

      try {
        const res = await fetch(`${window.baseURL}/bkw/v1/audio/transcriptions`, {
          method: 'POST',
          body: form,
          headers: { Authorization: 'Bearer ' + this.AppAgentId }
        })
        if (!res.ok) throw new Error(await res.text())
        const data = await res.json()
        this.isLoading = false

        if (data.code === 0) {
          this.searchValue = data.result
          if (data.result) this.send()
        } else {
          this.$mNotify({ message: data.message, type: 'warning', duration: 2000 })
        }
      } catch (e) {
        this.isLoading = false
      }
    },

    /* ======================================== */
    /* ============= 流式分段 TTS ============= */
    /* ======================================== */
    appendTextForTts (text) {
      this.textBuffer += text
      const regex = /([。!?;\n])/
      const parts = this.textBuffer.split(regex)
      while (parts.length >= 2) {
        const sentence = parts.shift() + parts.shift()
        const trimmed = sentence.trim()
        if (trimmed.length > 1) this.addToTtsQueue(trimmed)
      }
      this.textBuffer = parts.join('')
      if (this.textBuffer.length > 30) {
        this.addToTtsQueue(this.textBuffer)
        this.textBuffer = ''
      }
    },

    flushTtsBuffer () {
      if (this.textBuffer.trim().length > 0) {
        this.addToTtsQueue(this.textBuffer.trim())
        this.textBuffer = ''
      }
    },

    addToTtsQueue (text) {
      const cleanText = text
        .replace(/[\u{1F000}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '')
        .replace(/[^\u4e00-\u9fa5a-zA-Z0-9,。!?;:""''()、\s]/g, '')
        .replace(/\s+/g, ' ')
        .trim()
      if (!cleanText || cleanText.length < 2) return

      this.ttsQueue.push(cleanText)
      this.prefetchTts()
      if (!this.isTtsPlaying) this.playNextTts()
    },

    /* ======================================== */
    /* ============ 预拉 + 缓存 =============== */
    /* ======================================== */
    prefetchTts () {
      const N = 3
      const todo = this.ttsQueue.slice(0, N).filter(t => !this.ttsCache.has(t))
      todo.forEach(text => this.ttsCache.set(text, this.doFetchTts(text)))
    },

    async doFetchTts (text) {
      const res = await fetch(`${window.baseURL}/bkw/v1/audio/speech`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: 'Basic ' + window.token
        },
        body: JSON.stringify({
          input: text,
          model: 'fish-speech',
          response_format: 'mp3',
          speed: this.speed,
          voice: this.voice,
          streaming: true
        })
      })
      if (!res.ok) throw new Error('TTS 请求失败')
      return res.blob()
    },

    async playNextTts () {
      if (!this.ttsQueue.length) {
        this.isTtsPlaying = false
        this.onAllTtsFinished()
        return
      }
      const text = this.ttsQueue.shift()
      this.isTtsPlaying = true

      try {
        const blob = await this.ttsCache.get(text)
        const audioUrl = URL.createObjectURL(blob)

        this.currentAudio = new Audio(audioUrl)
        this.currentAudio.muted = this.isMuted

        this.currentAudio.onended = () => {
          URL.revokeObjectURL(audioUrl)
          this.cleanCache(text)
          this.currentAudio = null
          this.prefetchTts()
          this.playNextTts()
        }
        this.currentAudio.onerror = () => {
          URL.revokeObjectURL(audioUrl)
          this.cleanCache(text)
          this.currentAudio = null
          this.prefetchTts()
          this.playNextTts()
        }
        await this.currentAudio.play()
      } catch (e) {
        console.error('TTS 播放失败:', e)
        this.cleanCache(text)
        this.prefetchTts()
        this.playNextTts()
      }
    },
    // 'TTS 全部播放完成'
    onAllTtsFinished () {
    // 例如:上报埋点、重新允许录音、显示提示、自动跳转页面 …
      this.$refs.jqChat && (this.$refs.jqChat.isPlaying = false)
      this.$refs.mrChat && (this.$refs.mrChat.isPlaying = false)
    },

    cleanCache (text) {
      const p = this.ttsCache.get(text)
      if (p) {
        p.then(b => URL.revokeObjectURL(URL.createObjectURL(b))).catch(() => {})
        this.ttsCache.delete(text)
      }
    },

    /* ======================================== */
    /* ============= 播放控制 ================= */
    /* ======================================== */
    stopTts () {
      if (this.currentAudio) {
        this.currentAudio.pause()
        this.currentAudio.onended = null
        this.currentAudio = null
      }
      this.ttsQueue = []
      this.isTtsPlaying = false
      this.textBuffer = ''
      this.ttsCache.forEach((_, t) => this.cleanCache(t))
      this.ttsCache.clear()
    },

    skipCurrentTts () {
      if (this.currentAudio) {
        this.currentAudio.pause()
        this.currentAudio.onended = null
        this.currentAudio = null
        this.playNextTts()
      }
    },

    toggleMute () {
      this.isMuted = !this.isMuted
      if (this.currentAudio) this.currentAudio.muted = this.isMuted
    },

    /* ======================================== */
    /* ============ plus 权限相关 ============= */
    /* ======================================== */
    requestAndroidPermission (plus) {
      const _this = this
      plus.android.requestPermissions(
        ['android.permission.RECORD_AUDIO'],
        function (res) {
          if (res.granted.indexOf('android.permission.RECORD_AUDIO') !== -1) {
            console.log('允许录音')
            // _this.getInitAudio()
          } else {
            console.log('请授予录音权限')
          }
        },
        function (err) {
          console.log('权限申请失败:' + err.message)
        }
      )
    },

    checkPlus () {
      this.isAppEnv = isApp()
      plusReady((plus) => {
        this.plusReady = !!plus
        if (plus) {
          // 安卓环境
          this.requestAndroidPermission(plus)
        } else {
          // h5环境
          this.getInitAudio()
        }
      })
    },

    async getInitAudio () {
      // 如果之前已经初始化过,先清掉
      if (this.rec) {
        this.rec && this.rec.destroy && this.rec.destroy()
        this.rec = null
      }
      if (this.audioCtx?.state !== 'closed') await this.audioCtx?.close()
      if (this.localStream) {
        this.localStream.getTracks().forEach(t => t.stop())
      }

      // plus 环境先弹系统权限
      if (this.plusReady && window.plus) {
        await new Promise((resolve, reject) => {
          plus.android.requestPermissions(
            ['android.permission.RECORD_AUDIO'],
            res => {
              res.deniedAlways.length
                ? reject(new Error('RECORD_AUDIO deniedAlways'))
                : resolve()
            },
            err => reject(new Error(err.message || 'requestPermissions fail'))
          )
        })
      }

      // 再取流
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
      this.localStream = stream
      this.audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 })
      this.rec = new Recorder(this.audioCtx, { numChannels: 1 })
      await this.rec.init(stream)
    }
  }
}

四、HBuilderX打包关键配置

  1. 打包模式选择

必须使用本地文件打包,不可使用在线HTTP资源:

111.png

  1. 权限配置清单

222.png 3. #### 音频流类型配置

"plugins" : {
    "AudioManager" : {
       "streamType" : "music"
     }
}

222.png