mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-13 19:14:24 +08:00
feat: 完善 OTG UAC 音频支持
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user