feat: 完善 OTG UAC 音频支持

This commit is contained in:
mofeng-git
2026-07-26 11:59:58 +08:00
parent f86cba6ce5
commit 5963dfa01a
46 changed files with 2875 additions and 1867 deletions

View File

@@ -1,161 +1,174 @@
import { ref } from 'vue'
import { computed, ref } from 'vue'
import {
microphoneSupportError,
normalizeMicrophoneError,
UacMicrophoneSession,
type UacMicrophoneErrorCode,
type UacTargetState,
} from '@/lib/uac-microphone'
export type MicrophoneTransferState = 'idle' | 'starting' | 'streaming' | 'stopping' | 'error'
export interface MicrophoneInputDevice {
deviceId: string
label: string
}
const WS_ENDPOINT = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/ws/uac-audio`
export function useMicrophone() {
const state = ref<MicrophoneTransferState>('idle')
const targetState = ref<UacTargetState>('idle')
const errorCode = ref<UacMicrophoneErrorCode | null>(microphoneSupportError())
const inputDevices = ref<MicrophoneInputDevice[]>([])
const selectedDeviceId = ref('')
const permissionGranted = ref(false)
const loadingDevices = ref(false)
let session: UacMicrophoneSession | null = null
const active = computed(() => state.value === 'streaming')
const busy = computed(() => state.value === 'starting' || state.value === 'stopping')
async function refreshInputDevices(requestPermission = false) {
const supportError = microphoneSupportError()
if (supportError) {
errorCode.value = supportError
return false
}
loadingDevices.value = true
let permissionStream: MediaStream | null = null
try {
if (requestPermission) {
permissionStream = await navigator.mediaDevices.getUserMedia({ audio: true })
permissionGranted.value = true
if (!selectedDeviceId.value) {
selectedDeviceId.value = permissionStream.getAudioTracks()[0]?.getSettings().deviceId ?? ''
}
}
const availableDevices = (await navigator.mediaDevices.enumerateDevices())
.filter(device => device.kind === 'audioinput' && device.deviceId)
permissionGranted.value = requestPermission
|| active.value
|| availableDevices.some(device => device.label)
const devices = availableDevices
.map((device, index) => ({
deviceId: device.deviceId,
label: device.label || `Microphone ${index + 1}`,
}))
inputDevices.value = devices
if (!devices.some(device => device.deviceId === selectedDeviceId.value)) {
selectedDeviceId.value = devices[0]?.deviceId ?? ''
}
errorCode.value = null
return true
} catch (error) {
const microphoneError = normalizeMicrophoneError(error)
errorCode.value = microphoneError.code
return false
} finally {
permissionStream?.getTracks().forEach(track => track.stop())
loadingDevices.value = false
}
}
async function start() {
if (busy.value || active.value) return
const supportError = microphoneSupportError()
if (supportError) {
errorCode.value = supportError
state.value = 'error'
return
}
state.value = 'starting'
targetState.value = 'waiting'
errorCode.value = null
const nextSession = new UacMicrophoneSession(
WS_ENDPOINT,
error => {
if (session !== nextSession) return
session = null
targetState.value = 'idle'
errorCode.value = error.code
state.value = 'error'
},
nextState => {
if (session !== nextSession) return
targetState.value = nextState
},
selectedDeviceId.value,
)
session = nextSession
try {
await nextSession.start()
if (session !== nextSession) {
await nextSession.stop()
return
}
permissionGranted.value = true
selectedDeviceId.value = nextSession.activeInputDeviceId || selectedDeviceId.value
state.value = 'streaming'
void refreshInputDevices()
} catch (error) {
if (session !== nextSession) return
session = null
targetState.value = 'idle'
const microphoneError = normalizeMicrophoneError(error)
errorCode.value = microphoneError.code
state.value = 'error'
}
}
async function stop() {
if (state.value === 'idle' || state.value === 'stopping') return
state.value = 'stopping'
const current = session
session = null
await current?.stop()
targetState.value = 'idle'
errorCode.value = microphoneSupportError()
state.value = 'idle'
}
async function toggle() {
if (active.value || state.value === 'starting') await stop()
else await start()
}
async function selectInputDevice(deviceId: string) {
if (deviceId === selectedDeviceId.value) return
const wasActive = active.value
if (wasActive) await stop()
selectedDeviceId.value = deviceId
if (wasActive) await start()
}
return {
state,
targetState,
active,
busy,
errorCode,
inputDevices,
selectedDeviceId,
permissionGranted,
loadingDevices,
refreshInputDevices,
selectInputDevice,
start,
stop,
toggle,
}
}
let instance: ReturnType<typeof useMicrophone> | null = null
export function getMicrophone() {
if (!instance) instance = useMicrophone()
return instance
}
const WS_BASE = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/ws/uac-audio`
// Opus: 48kHz stereo, 64kbps → ~8 KB/s vs raw PCM 192 KB/s (24× reduction)
const OPUS_CONFIG: AudioEncoderConfig = {
codec: 'opus',
sampleRate: 48000,
numberOfChannels: 2,
bitrate: 64000,
}
// 15-byte binary header matching server-side UAC_AUDIO_HEADER_SIZE
function buildHeader(msgType: number, durationMs: number, dataLen: number): Uint8Array {
const h = new Uint8Array(15)
const v = new DataView(h.buffer)
v.setUint8(0, msgType) // 0x03 = Opus
v.setUint32(1, 0, true) // timestamp (unused)
v.setUint16(5, durationMs, true)
v.setUint32(7, 0, true) // sequence
v.setUint32(11, dataLen, true)
return h
}
export function useMicrophone() {
const active = ref(false)
const error = ref<string | null>(null)
let ws: WebSocket | null = null
let stream: MediaStream | null = null
let encoder: AudioEncoder | null = null
let running = false
let frameCount = 0
let byteCount = 0
// ── AudioEncoder helper ─────────────────────────────────
function createEncoder(onOpusFrame: (data: Uint8Array, durMs: number) => void): AudioEncoder {
const enc = new AudioEncoder({
output: (chunk: EncodedAudioChunk) => {
const buf = new Uint8Array(chunk.byteLength)
chunk.copyTo(buf)
// Opus frame duration in microseconds → milliseconds
const durMs = Math.round(chunk.duration! / 1000)
onOpusFrame(buf, durMs)
},
error: (e: Error) => console.error('[mic] encoder error:', e),
})
enc.configure(OPUS_CONFIG)
return enc
}
// ── start / stop ────────────────────────────────────────
async function start() {
error.value = null
frameCount = 0
byteCount = 0
running = true
console.log('[mic] starting...')
try {
// WebSocket
ws = new WebSocket(WS_BASE)
ws.binaryType = 'arraybuffer'
const wsReady = new Promise<void>((resolve, reject) => {
ws!.onopen = () => { console.log('[mic] WS opened'); active.value = true; resolve() }
ws!.onerror = (ev) => { console.error('[mic] WS error:', ev); reject(new Error('WebSocket failed')) }
})
ws.onclose = (ev) => {
console.log('[mic] WS closed: code=%d frames=%d bytes=%d', ev.code, frameCount, byteCount)
active.value = false
running = false
}
// Microphone
console.log('[mic] getUserMedia...')
stream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: 48000, channelCount: 2, echoCancellation: false, noiseSuppression: false }
})
// AudioEncoder (WebCodecs) for Opus compression
encoder = createEncoder((opusData, durMs) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return
const header = buildHeader(0x03, durMs, opusData.length)
const msg = new Uint8Array(15 + opusData.length)
msg.set(header)
msg.set(opusData, 15)
ws.send(msg)
frameCount++
byteCount += msg.byteLength
if (frameCount % 50 === 0) {
console.debug('[mic] frame #%d: opus=%dB dur=%dms',
frameCount, opusData.length, durMs)
}
})
await wsReady
// ScriptProcessor → S16LE PCM → AudioData → AudioEncoder → Opus
const audioCtx = new AudioContext({ sampleRate: 48000 })
const source = audioCtx.createMediaStreamSource(stream)
const processor = audioCtx.createScriptProcessor(4096, 2, 2)
source.connect(processor)
processor.connect(audioCtx.destination)
processor.onaudioprocess = (e: AudioProcessingEvent) => {
if (!running || !encoder || encoder.state !== 'configured') return
if (!e.inputBuffer) return
const buf = e.inputBuffer as any
const left = buf.getChannelData(0) as Float32Array
const right = buf.getChannelData(1) as Float32Array
const samples = left.length
// Float32 → S16LE interleaved
const pcm = new Int16Array(samples * 2)
for (let i = 0; i < samples; i++) {
pcm[i * 2] = Math.max(-32768, Math.min(32767, Math.round((left[i] ?? 0) * 32767)))
pcm[i * 2 + 1] = Math.max(-32768, Math.min(32767, Math.round((right[i] ?? 0) * 32767)))
}
try {
const audioData = new AudioData({
format: 's16',
sampleRate: 48000,
numberOfFrames: samples,
numberOfChannels: 2,
timestamp: 0,
data: pcm.buffer,
})
encoder.encode(audioData)
audioData.close()
} catch (e) {
console.warn('[mic] AudioData/encode error:', e)
}
}
} catch (e) {
console.error('[mic] start error:', e)
error.value = e instanceof Error ? e.message : 'Failed to start microphone'
stop()
}
}
function stop() {
console.log('[mic] stop: frames=%d bytes=%d', frameCount, byteCount)
running = false
if (encoder) { encoder.close(); encoder = null }
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null }
if (ws) { ws.close(); ws = null }
active.value = false
}
function toggle() {
if (active.value) { stop() } else { start() }
}
return { active, error, start, stop, toggle }
}