突破内网HTTPS限制:音频采集与播放完整解决方案
背景与挑战
在前端开发中,调用浏览器音频API(包括录音和语音播放)通常要求页面运行在HTTPS环境下,这是现代浏览器的安全策略。然而,内部网络环境中,部署HTTPS面临以下实际困难:
- 证书部署复杂:内网证书需要自建CA或申请专用证书
- 用户体验差:首次访问时需手动信任证书,操作繁琐
- 维护成本高:证书更新和续期带来额外运维负担
解决方案概述
本方案通过本地化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()
二、音频权限获取策略
-
双环境适配机制
- 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语音合成优化
-
预缓存池设计
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打包关键配置
-
打包模式选择
必须使用本地文件打包,不可使用在线HTTP资源:
-
权限配置清单
3. #### 音频流类型配置
"plugins" : {
"AudioManager" : {
"streamType" : "music"
}
}