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

26
web/package-lock.json generated
View File

@@ -13,7 +13,6 @@
"clsx": "^2.1.1",
"lucide-vue-next": "^0.556.0",
"opus-decoder": "^0.7.11",
"opus-media-recorder": "^0.8.0",
"pinia": "^3.0.4",
"qrcode.vue": "^3.10.0",
"reka-ui": "^2.10.1",
@@ -1941,12 +1940,6 @@
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT"
},
"node_modules/detect-browser": {
"version": "4.8.0",
"resolved": "https://registry.npmmirror.com/detect-browser/-/detect-browser-4.8.0.tgz",
"integrity": "sha512-f4h2dFgzHUIpjpBLjhnDIteXv8VQiUm8XzAuzQtYUqECX/eKh67ykuiVoyb7Db7a0PUSmJa3OGXStG0CbQFUVw==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2048,15 +2041,6 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/event-target-shim": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-3.0.2.tgz",
"integrity": "sha512-HK5GhnEAkm7fLy249GtF7DIuYmjLm85Ft6ssj7DhVl8Tx/z9+v0W6aiIVUdT4AXWGYy5Fc+s6gqBI49Bf0LejQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
@@ -2488,16 +2472,6 @@
"url": "https://github.com/sponsors/eshaz"
}
},
"node_modules/opus-media-recorder": {
"version": "0.8.0",
"resolved": "https://registry.npmmirror.com/opus-media-recorder/-/opus-media-recorder-0.8.0.tgz",
"integrity": "sha512-AIvJMpnJqZ18dFAU7Amtt5cZZp8oPzDoAOtobdTcLzwVNm/j815+GJmBupBzBZGBa4L940TEulm7Uu4tGOYDGQ==",
"license": "MIT",
"dependencies": {
"detect-browser": "^4.1.0",
"event-target-shim": "^3.0.2"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",

View File

@@ -14,7 +14,6 @@
"clsx": "^2.1.1",
"lucide-vue-next": "^0.556.0",
"opus-decoder": "^0.7.11",
"opus-media-recorder": "^0.8.0",
"pinia": "^3.0.4",
"qrcode.vue": "^3.10.0",
"reka-ui": "^2.10.1",

View File

@@ -55,6 +55,14 @@ function getToastKey(endpoint: string, config?: ApiRequestConfig): string {
function getErrorMessage(data: unknown, fallback: string): string {
if (data && typeof data === 'object') {
const code = (data as any).code
const keyByCode: Record<string, string> = {
MSD_MEDIUM_REMOVAL_PREVENTED: 'msd.errors.mediumRemovalPrevented',
MSD_DISCONNECT_FAILED: 'msd.errors.disconnectFailed',
}
const key = typeof code === 'string' ? keyByCode[code] : undefined
if (key && hasTranslation(key)) return t(key)
const message = (data as any).message
if (typeof message === 'string' && message.trim()) return localizeBackendErrorMessage(message)
}

View File

@@ -3,7 +3,6 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useSystemStore } from '@/stores/system'
import { getMicrophone } from '@/composables/useMicrophone'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import {
@@ -32,7 +31,6 @@ import {
} from '@/components/ui/sheet'
import {
ClipboardPaste,
Mic,
HardDrive,
Keyboard,
Settings,
@@ -75,7 +73,6 @@ const props = defineProps<{
const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg')
const showPasteText = computed(() => props.showPasteText !== false)
const showMic = computed(() => props.showMic === true)
const mic = getMicrophone()
const emit = defineEmits<{
@@ -295,7 +292,10 @@ const hasRightOverflow = computed(() => {
/>
<!-- Audio Config - Always visible -->
<AudioConfigPopover v-model:open="audioPopoverOpen" />
<AudioConfigPopover
v-model:open="audioPopoverOpen"
:microphone-enabled="showMic"
/>
<!-- HID Config - Always visible -->
<HidConfigPopover
@@ -357,23 +357,6 @@ const hasRightOverflow = computed(() => {
</Popover>
</div>
<!-- Mic button -->
<div v-if="showMic">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"
:class="mic.active.value ? 'text-destructive' : mic.error.value ? 'text-yellow-500' : ''"
@click="mic.toggle()">
<Mic class="size-4" :class="mic.active.value ? 'animate-pulse' : ''" />
<span>{{ mic.active.value ? '关闭' : '麦克风' }}</span>
<span v-if="mic.error.value" class="text-[10px]">{{ mic.error.value }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{{ mic.error.value ? mic.error.value : (mic.active.value ? '停止传声' : '开始传声') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</ButtonGroup>
<!-- Right side buttons -->

View File

@@ -1,21 +1,25 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Loader2, RefreshCw, Volume2 } from 'lucide-vue-next'
import { audioApi, configApi } from '@/api'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Slider } from '@/components/ui/slider'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Volume2, RefreshCw, Loader2 } from 'lucide-vue-next'
import { audioApi, configApi } from '@/api'
import { Separator } from '@/components/ui/separator'
import { Slider } from '@/components/ui/slider'
import MicrophoneTransferControls from '@/components/MicrophoneTransferControls.vue'
import { getMicrophone } from '@/composables/useMicrophone'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
import { useConfigStore } from '@/stores/config'
import { useSystemStore } from '@/stores/system'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
interface AudioDevice {
name: string
@@ -24,18 +28,26 @@ interface AudioDevice {
const props = defineProps<{
open: boolean
microphoneEnabled?: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(event: 'update:open', value: boolean): void
}>()
const { t } = useI18n()
const configStore = useConfigStore()
const systemStore = useSystemStore()
const unifiedAudio = getUnifiedAudio()
const microphone = getMicrophone()
const localVolume = ref([unifiedAudio.volume.value * 100])
const devices = ref<AudioDevice[]>([])
const loadingDevices = ref(false)
const applying = ref(false)
const audioEnabled = ref(false)
const selectedDevice = ref('')
const selectedQuality = ref<'voice' | 'balanced' | 'high'>('balanced')
async function handleVolumeChange(value: number[] | undefined) {
if (!value || value.length === 0 || value[0] === undefined) return
@@ -45,29 +57,20 @@ async function handleVolumeChange(value: number[] | undefined) {
localVolume.value = value
if (newVolume > 0 && systemStore.audio?.streaming && !unifiedAudio.connected.value) {
console.log('[Audio] User adjusted volume, connecting unified audio')
try {
await unifiedAudio.connect()
} catch (e) {
console.info('[Audio] Connect failed:', e)
} catch (error) {
console.info('[Audio] Connect failed:', error)
}
}
}
const devices = ref<AudioDevice[]>([])
const loadingDevices = ref(false)
const applying = ref(false)
const audioEnabled = ref(false)
const selectedDevice = ref('')
const selectedQuality = ref<'voice' | 'balanced' | 'high'>('balanced')
async function loadDevices() {
loadingDevices.value = true
try {
const result = await configApi.listDevices()
devices.value = result.audio
} catch (e) {
} catch {
console.info('[AudioConfig] Failed to load devices')
} finally {
loadingDevices.value = false
@@ -96,194 +99,192 @@ async function applyConfig() {
})
if (audioEnabled.value && selectedDevice.value) {
try {
if (localVolume.value[0] === 0) {
localVolume.value = [100]
unifiedAudio.setVolume(1)
}
await audioApi.start()
} catch (startError) {
console.info('[AudioConfig] Audio start failed:', startError)
if (localVolume.value[0] === 0) {
localVolume.value = [100]
unifiedAudio.setVolume(1)
}
await audioApi.start()
} else if (!audioEnabled.value) {
localVolume.value = [0]
unifiedAudio.setVolume(0)
try {
await audioApi.stop()
} catch {
}
await audioApi.stop()
unifiedAudio.disconnect()
}
} catch (e) {
console.info('[AudioConfig] Failed to apply config:', e)
toast.success(t('common.success'))
} catch (error) {
toast.error(t('common.error'), {
description: error instanceof Error ? error.message : String(error),
})
} finally {
applying.value = false
}
}
watch(() => props.open, (isOpen) => {
watch(() => props.open, isOpen => {
if (!isOpen) return
if (devices.value.length === 0) {
loadDevices()
void loadDevices()
}
if (props.microphoneEnabled) {
void microphone.refreshInputDevices()
}
configStore.refreshAudio()
.then(() => {
initializeFromCurrent()
})
.catch(() => {
initializeFromCurrent()
})
.then(initializeFromCurrent)
.catch(initializeFromCurrent)
})
onUnmounted(() => {
void microphone.stop()
})
</script>
<template>
<Popover :open="open" @update:open="emit('update:open', $event)">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
<Button
variant="ghost"
size="sm"
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2"
>
<Volume2 class="size-3.5 sm:size-4" />
<span class="hidden sm:inline">{{ t('actionbar.audioConfig') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
<div class="space-y-3">
<h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4>
<Separator />
<!-- Playback Control (immediate effect) -->
<div class="space-y-3">
<h5 class="text-xs font-medium text-muted-foreground">
{{ t('actionbar.playbackControl') }}
</h5>
<template v-if="props.microphoneEnabled">
<MicrophoneTransferControls />
<Separator />
</template>
<!-- Playback volume and capture configuration form one section. -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">
{{ t('actionbar.playbackControl') }}
</h5>
<Button
variant="ghost"
size="icon-xs"
:disabled="loadingDevices"
@click="loadDevices"
>
<RefreshCw :class="['size-3.5', loadingDevices && 'animate-spin']" />
</Button>
</div>
<!-- Volume -->
<div class="space-y-2">
<div class="flex justify-between items-center">
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.volume') }}</Label>
<span class="text-xs font-mono">{{ Math.round(localVolume[0] ?? 0) }}%</span>
<span class="font-mono text-xs">{{ Math.round(localVolume[0] ?? 0) }}%</span>
</div>
<div class="flex items-center gap-2">
<Volume2 class="size-3.5 text-muted-foreground opacity-50" />
<Slider
:model-value="localVolume"
@update:model-value="handleVolumeChange"
:min="0"
:max="100"
:step="1"
:disabled="!systemStore.audio?.streaming"
class="flex-1"
@update:model-value="handleVolumeChange"
/>
<Volume2 class="size-3.5 text-muted-foreground" />
</div>
</div>
</div>
<!-- Device Settings (requires apply) -->
<Separator />
<div class="space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">
{{ t('actionbar.audioDeviceSettings') }}
</h5>
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.audioEnabled') }}</Label>
<div class="flex gap-2">
<Button
variant="ghost"
size="icon-xs"
:disabled="loadingDevices"
@click="loadDevices"
:variant="audioEnabled ? 'default' : 'outline'"
size="sm"
class="flex-1 text-xs"
@click="audioEnabled = true"
>
<RefreshCw :class="['size-3.5', loadingDevices && 'animate-spin']" />
{{ t('common.enabled') }}
</Button>
<Button
:variant="!audioEnabled ? 'default' : 'outline'"
size="sm"
class="flex-1 text-xs"
@click="audioEnabled = false"
>
{{ t('common.disabled') }}
</Button>
</div>
<!-- Enable Audio -->
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.audioEnabled') }}</Label>
<div class="flex gap-2">
<Button
:variant="audioEnabled ? 'default' : 'outline'"
size="sm"
class="flex-1 h-8 text-xs"
@click="audioEnabled = true"
>
{{ t('common.enabled') }}
</Button>
<Button
:variant="!audioEnabled ? 'default' : 'outline'"
size="sm"
class="flex-1 h-8 text-xs"
@click="audioEnabled = false"
>
{{ t('common.disabled') }}
</Button>
</div>
</div>
<!-- Device Selection -->
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.audioDevice') }}</Label>
<NativeSelect
:model-value="selectedDevice"
@update:model-value="(v) => selectedDevice = v as string"
:disabled="loadingDevices || devices.length === 0"
size="sm" class="w-full text-xs"
>
<NativeSelectOption value="">{{ t('actionbar.selectAudioDevice') }}</NativeSelectOption>
<NativeSelectOption
v-for="device in devices"
:key="device.name"
:value="device.name"
class="text-xs"
>
{{ device.description || device.name }}
</NativeSelectOption>
</NativeSelect>
</div>
<!-- Audio Quality -->
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.audioQuality') }}</Label>
<div class="flex gap-1">
<Button
:variant="selectedQuality === 'voice' ? 'default' : 'outline'"
size="sm"
class="flex-1 h-8 text-xs"
@click="selectedQuality = 'voice'"
>
{{ t('actionbar.qualityVoice') }} 32k
</Button>
<Button
:variant="selectedQuality === 'balanced' ? 'default' : 'outline'"
size="sm"
class="flex-1 h-8 text-xs"
@click="selectedQuality = 'balanced'"
>
{{ t('actionbar.qualityBalanced') }} 64k
</Button>
<Button
:variant="selectedQuality === 'high' ? 'default' : 'outline'"
size="sm"
class="flex-1 h-8 text-xs"
@click="selectedQuality = 'high'"
>
{{ t('actionbar.qualityHigh') }} 128k
</Button>
</div>
</div>
<!-- Apply Button -->
<Button
class="w-full h-8 text-xs"
:disabled="applying"
@click="applyConfig"
>
<Loader2 v-if="applying" class="size-3.5 mr-1.5 animate-spin" />
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button>
</div>
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.audioDevice') }}</Label>
<NativeSelect
:model-value="selectedDevice"
:disabled="loadingDevices || devices.length === 0"
size="sm"
class="w-full text-xs"
@update:model-value="selectedDevice = $event as string"
>
<NativeSelectOption value="">{{ t('actionbar.selectAudioDevice') }}</NativeSelectOption>
<NativeSelectOption
v-for="device in devices"
:key="device.name"
:value="device.name"
class="text-xs"
>
{{ device.description || device.name }}
</NativeSelectOption>
</NativeSelect>
</div>
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.audioQuality') }}</Label>
<div class="flex gap-1">
<Button
:variant="selectedQuality === 'voice' ? 'default' : 'outline'"
size="sm"
class="flex-1 text-xs"
@click="selectedQuality = 'voice'"
>
{{ t('actionbar.qualityVoice') }} 32k
</Button>
<Button
:variant="selectedQuality === 'balanced' ? 'default' : 'outline'"
size="sm"
class="flex-1 text-xs"
@click="selectedQuality = 'balanced'"
>
{{ t('actionbar.qualityBalanced') }} 64k
</Button>
<Button
:variant="selectedQuality === 'high' ? 'default' : 'outline'"
size="sm"
class="flex-1 text-xs"
@click="selectedQuality = 'high'"
>
{{ t('actionbar.qualityHigh') }} 128k
</Button>
</div>
</div>
<Button
size="sm"
class="w-full text-xs"
:disabled="applying"
@click="applyConfig"
>
<Loader2 v-if="applying" class="size-3.5 animate-spin" />
{{ applying ? t('actionbar.applying') : t('common.apply') }}
</Button>
</div>
</div>
</PopoverContent>
</Popover>

View File

@@ -19,7 +19,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { MousePointer, Move, Loader2, RefreshCw } from 'lucide-vue-next'
import HelpTooltip from '@/components/HelpTooltip.vue'
import { configApi } from '@/api'
@@ -343,22 +342,26 @@ watch(() => props.open, (isOpen) => {
<!-- Device Path (OTG or CH9329) -->
<div v-if="hidBackend !== HidBackend.None" class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.devicePath') }}</Label>
<NativeSelect
<Select
:model-value="devicePath"
@update:model-value="handleDevicePathChange"
:disabled="availableDevicePaths.length === 0"
size="sm" class="w-full text-xs"
>
<NativeSelectOption value="">{{ t('actionbar.selectDevice') }}</NativeSelectOption>
<NativeSelectOption
<SelectTrigger size="sm" class="w-full text-xs">
<SelectValue :placeholder="t('actionbar.selectDevice')" />
</SelectTrigger>
<SelectContent class="max-w-[min(360px,calc(100vw-2rem))]">
<SelectItem
v-for="device in availableDevicePaths"
:key="device.path"
:value="device.path"
:text-value="device.name"
class="text-xs"
>
{{ device.name }}
</NativeSelectOption>
</NativeSelect>
<span class="block min-w-0 truncate" :title="device.name">{{ device.name }}</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Baudrate (CH9329 only) -->

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Loader2, Mic, MicOff, RefreshCw } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { getMicrophone } from '@/composables/useMicrophone'
const { t } = useI18n()
const microphone = getMicrophone()
const stateText = computed(() => t(`actionbar.micState.${microphone.state.value}`))
const errorText = computed(() => {
const code = microphone.errorCode.value
return code ? t(`actionbar.micError.${code}`) : ''
})
function showError() {
if (errorText.value) toast.error(errorText.value)
}
async function toggle() {
await microphone.toggle()
showError()
}
async function requestPermission() {
const granted = await microphone.refreshInputDevices(true)
if (!granted) showError()
}
async function selectDevice(value: unknown) {
await microphone.selectInputDevice(String(value ?? ''))
showError()
}
</script>
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">
{{ t('actionbar.microphoneTransfer') }}
</h5>
<Button
v-if="microphone.permissionGranted.value"
variant="ghost"
size="icon-xs"
:aria-label="t('common.refresh')"
:disabled="microphone.loadingDevices.value || microphone.busy.value"
@click="microphone.refreshInputDevices()"
>
<RefreshCw :class="['size-3.5', microphone.loadingDevices.value && 'animate-spin']" />
</Button>
</div>
<Button
v-if="!microphone.permissionGranted.value"
variant="outline"
size="sm"
class="w-full"
:disabled="microphone.loadingDevices.value || microphone.busy.value"
@click="requestPermission"
>
<Loader2 v-if="microphone.loadingDevices.value" class="animate-spin" />
<Mic v-else />
{{ t('actionbar.grantMicrophonePermission') }}
</Button>
<div v-else>
<NativeSelect
:model-value="microphone.selectedDeviceId.value"
:disabled="microphone.loadingDevices.value || microphone.busy.value || microphone.inputDevices.value.length === 0"
size="sm"
class="w-full text-xs"
@update:model-value="selectDevice"
>
<NativeSelectOption v-if="microphone.inputDevices.value.length === 0" value="">
{{ t('actionbar.noMicrophoneDevices') }}
</NativeSelectOption>
<NativeSelectOption
v-for="device in microphone.inputDevices.value"
:key="device.deviceId"
:value="device.deviceId"
class="text-xs"
>
{{ device.label }}
</NativeSelectOption>
</NativeSelect>
</div>
<Button
:variant="microphone.active.value ? 'destructive' : 'default'"
size="sm"
class="w-full"
:disabled="microphone.busy.value"
@click="toggle"
>
<Loader2 v-if="microphone.busy.value" class="animate-spin" />
<MicOff v-else-if="microphone.active.value" />
<Mic v-else />
{{ microphone.busy.value
? stateText
: microphone.active.value
? t('actionbar.micStop')
: t('actionbar.micStart') }}
</Button>
</div>
</template>

View File

@@ -5,7 +5,6 @@ import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import {
Popover,
PopoverContent,
@@ -318,6 +317,16 @@ const selectedFormatInfo = computed(() =>
availableFormatOptions.value.find(format => format.format === selectedFormat.value) ?? null
)
const selectedDeviceInfo = computed(() =>
devices.value.find(device => device.path === selectedDevice.value) ?? null
)
const selectedResolutionInfo = computed(() =>
availableResolutions.value.find(
resolution => `${resolution.width}x${resolution.height}` === selectedResolution.value,
) ?? null
)
const selectedCodecInfo = computed(() => {
const codec = availableCodecs.value.find(c => c.id === props.videoMode)
return codec || null
@@ -744,22 +753,32 @@ watch(
<!-- Device Selection -->
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.videoDevice') }}</Label>
<NativeSelect
<Select
:model-value="selectedDevice"
@update:model-value="handleDeviceChange"
:disabled="loadingDevices || devices.length === 0"
size="sm" class="w-full text-xs"
>
<NativeSelectOption value="">{{ loadingDevices ? t('common.loading') : t('actionbar.selectDevice') }}</NativeSelectOption>
<NativeSelectOption
<SelectTrigger size="sm" class="w-full text-xs">
<span v-if="selectedDeviceInfo" class="min-w-0 truncate">
{{ formatVideoDeviceLabel(selectedDeviceInfo) }}
</span>
<span v-else class="text-muted-foreground">
{{ loadingDevices ? t('common.loading') : t('actionbar.selectDevice') }}
</span>
</SelectTrigger>
<SelectContent class="max-w-[min(360px,calc(100vw-2rem))]">
<SelectItem
v-for="device in devices"
:key="device.path"
:value="device.path"
class="text-xs"
>
{{ formatVideoDeviceLabel(device) }}
</NativeSelectOption>
</NativeSelect>
<span class="block min-w-0 truncate" :title="formatVideoDeviceLabel(device)">
{{ formatVideoDeviceLabel(device) }}
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Format Selection -->
@@ -825,43 +844,55 @@ watch(
<!-- Resolution Selection -->
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.videoResolution') }}</Label>
<NativeSelect
<Select
:model-value="selectedResolution"
@update:model-value="handleResolutionChange"
:disabled="!selectedFormat || availableResolutions.length === 0"
size="sm" class="w-full text-xs"
>
<NativeSelectOption value="">{{ t('actionbar.selectResolution') }}</NativeSelectOption>
<NativeSelectOption
<SelectTrigger size="sm" class="w-full text-xs">
<span v-if="selectedResolutionInfo">
{{ selectedResolutionInfo.width }} × {{ selectedResolutionInfo.height }}
</span>
<span v-else class="text-muted-foreground">{{ t('actionbar.selectResolution') }}</span>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="res in availableResolutions"
:key="`${res.width}x${res.height}`"
:value="`${res.width}x${res.height}`"
class="text-xs"
>
{{ res.width }} x {{ res.height }}
</NativeSelectOption>
</NativeSelect>
{{ res.width }} × {{ res.height }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- FPS Selection -->
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.videoFps') }}</Label>
<NativeSelect
<Select
:model-value="String(selectedFps)"
@update:model-value="handleFpsChange"
:disabled="!selectedResolution || availableFps.length === 0"
size="sm" class="w-full text-xs"
>
<NativeSelectOption value="">{{ t('actionbar.selectFps') }}</NativeSelectOption>
<NativeSelectOption
<SelectTrigger size="sm" class="w-full text-xs">
<span v-if="selectedResolution && availableFps.includes(selectedFps)">
{{ formatFpsLabel(selectedFps) }}
</span>
<span v-else class="text-muted-foreground">{{ t('actionbar.selectFps') }}</span>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="fps in availableFps"
:key="fps"
:value="String(fps)"
class="text-xs"
>
{{ formatFpsLabel(fps) }}
</NativeSelectOption>
</NativeSelect>
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Apply Button -->

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 }
}

View File

@@ -92,6 +92,27 @@ export default {
},
actionbar: {
paste: 'Paste Text',
micStart: 'Start Transfer',
micStop: 'Stop Transfer',
microphoneTransfer: 'Audio Transfer Settings',
grantMicrophonePermission: 'Allow Microphone Access',
noMicrophoneDevices: 'No audio input devices found',
micState: {
idle: 'Idle',
starting: 'Starting',
streaming: 'Streaming',
stopping: 'Stopping',
error: 'Error',
},
micError: {
'secure-context-required': 'Browsers only allow microphone access on HTTPS pages. Open the console over HTTPS.',
unsupported: 'This browser cannot encode microphone audio with WebCodecs. Use a browser with WebCodecs support.',
'permission-denied': 'Microphone access was denied. Allow it in your browser site permissions.',
'device-unavailable': 'The selected microphone is unavailable. Check the device connection or choose another input.',
'connection-failed': 'Could not connect to USB microphone transfer. Check that UAC is enabled and no other session is active.',
'encoder-failed': 'The browser audio encoder failed and transfer was stopped.',
unknown: 'Microphone transfer failed to start. Check the device and browser permissions.',
},
virtualMedia: 'Virtual Media',
virtualMediaTip: 'Manage virtual media devices',
power: 'Power',
@@ -142,11 +163,11 @@ export default {
relative: 'Relative',
applying: 'Applying...',
audioConfig: 'Audio',
playbackControl: 'Playback',
playbackControl: 'Audio Playback Settings',
volume: 'Volume',
audioDeviceSettings: 'Device Settings',
audioEnabled: 'Audio',
audioDevice: 'Device',
audioDevice: 'Audio Input Device',
audioQuality: 'Quality',
qualityVoice: 'Voice',
qualityBalanced: 'Balanced',
@@ -414,6 +435,10 @@ export default {
mediaCount: 'Media {count}/{capacity}',
mediaSlotsFull: 'Media slots are full; no more media can be mounted',
reenumerating: 'USB is re-enumerating',
errors: {
mediumRemovalPrevented: 'The controlled computer is using this virtual medium and has prevented its removal. Eject or unmount it on the controlled computer, then try again.',
disconnectFailed: 'Virtual media could not be disconnected. Please try again or check the system logs.',
},
},
settings: {
title: 'Settings',
@@ -655,7 +680,6 @@ export default {
otgNetworkNone: 'None',
otgNetworkInterfacesLoadFailed: 'Failed to load bridge interfaces',
uacMic: 'USB Microphone',
uacMicDesc: 'Creates a virtual USB microphone on the target machine. Audio from your browser is streamed to the target.',
otgDescriptor: 'USB Device Descriptor',
vendorId: 'Vendor ID (VID)',
productId: 'Product ID (PID)',
@@ -890,6 +914,17 @@ export default {
networkError: 'Network Error',
disconnected: 'Disconnected',
hidUnavailable: 'HID Unavailable',
audioPlayback: 'Audio Playback',
audioTransfer: 'Audio Transfer',
playbackActive: 'Playing',
playbackStopped: 'Stopped',
transferIdle: 'Not Transmitting',
transferStarting: 'Starting',
transferWaiting: 'Waiting for Target',
transferActive: 'Transmitting',
transferStalled: 'Target Not Receiving',
transferStopping: 'Stopping',
transferError: 'Transfer Error',
quality: 'Quality',
streaming: 'Streaming',
off: 'Off',

View File

@@ -94,6 +94,26 @@ export default {
paste: '粘贴文本',
micStart: '开始传声',
micStop: '停止传声',
microphoneTransfer: '音频传声设置',
grantMicrophonePermission: '允许访问麦克风',
noMicrophoneDevices: '未发现音频输入设备',
micState: {
idle: '未传输',
starting: '启动中',
streaming: '传输中',
stopping: '停止中',
error: '异常',
},
micError: {
'secure-context-required': '浏览器仅允许 HTTPS 页面访问麦克风,请通过 HTTPS 打开控制台。',
unsupported: '当前浏览器不支持 Opus 麦克风传输,请使用支持 WebCodecs 的浏览器。',
'permission-denied': '麦克风权限被拒绝,请在浏览器站点权限中允许访问。',
'device-unavailable': '所选麦克风不可用,请检查设备连接或选择其他输入设备。',
'connection-failed': '无法连接 USB 麦克风传输服务,请确认 UAC 已启用且没有其他会话占用。',
'encoder-failed': '浏览器音频编码器异常,传输已停止。',
unknown: '麦克风传输启动失败,请检查设备和浏览器权限。',
},
virtualMedia: '虚拟媒体',
virtualMediaTip: '管理虚拟媒体设备',
power: '电源',
keyboard: '虚拟键盘',
@@ -143,11 +163,11 @@ export default {
relative: '相对定位',
applying: '应用中...',
audioConfig: '音频',
playbackControl: '播放控制',
playbackControl: '音频播放设置',
volume: '音量',
audioDeviceSettings: '设备配置',
audioEnabled: '启用音频',
audioDevice: '音频设备',
audioDevice: '音频输入设备',
audioQuality: '音频质量',
qualityVoice: '语音',
qualityBalanced: '均衡',
@@ -414,6 +434,10 @@ export default {
mediaCount: '介质 {count}/{capacity}',
mediaSlotsFull: '介质槽已满,无法挂载更多介质',
reenumerating: 'USB 正在重新枚举',
errors: {
mediumRemovalPrevented: '被控机正在使用该虚拟介质,并拒绝移除。请先在被控机中弹出或卸载该介质,然后重试。',
disconnectFailed: '虚拟介质断开失败,请重试或检查系统日志。',
},
},
settings: {
title: '系统设置',
@@ -655,7 +679,6 @@ export default {
otgNetworkNone: '无',
otgNetworkInterfacesLoadFailed: '无法获取桥接网卡列表',
uacMic: 'USB 麦克风',
uacMicDesc: '启用后目标机将看到一个 USB 麦克风设备,音频从浏览器传入',
otgDescriptor: 'USB 设备描述符',
vendorId: '厂商 ID (VID)',
productId: '产品 ID (PID)',
@@ -890,6 +913,17 @@ export default {
networkError: '网络错误',
disconnected: '已断开',
hidUnavailable: 'HID不可用',
audioPlayback: '音频播放',
audioTransfer: '音频传声',
playbackActive: '播放中',
playbackStopped: '已停止',
transferIdle: '未传声',
transferStarting: '正在启动',
transferWaiting: '等待目标机',
transferActive: '传声中',
transferStalled: '目标机未接收',
transferStopping: '正在停止',
transferError: '传声异常',
quality: '质量',
streaming: '传输中',
off: '关闭',

View File

@@ -0,0 +1,328 @@
const SAMPLE_RATE = 48_000
const CHANNELS = 2
const OPUS_BITRATE = 64_000
// Keep capture delivery close to Opus' 20 ms packet cadence instead of
// releasing several packets in an 85 ms burst.
const PROCESSOR_BUFFER_SIZE = 1024
const SOCKET_OPEN_TIMEOUT_MS = 8_000
const MAX_SOCKET_BUFFER_BYTES = 256 * 1024
const MAX_ENCODER_QUEUE_SIZE = 4
const OPUS_CONFIG: AudioEncoderConfig = {
codec: 'opus',
sampleRate: SAMPLE_RATE,
numberOfChannels: CHANNELS,
bitrate: OPUS_BITRATE,
}
export type UacMicrophoneErrorCode =
| 'secure-context-required'
| 'unsupported'
| 'permission-denied'
| 'device-unavailable'
| 'connection-failed'
| 'encoder-failed'
| 'unknown'
export type UacTargetState = 'idle' | 'waiting' | 'active' | 'stalled'
export class UacMicrophoneError extends Error {
constructor(
public readonly code: UacMicrophoneErrorCode,
message: string,
) {
super(message)
this.name = 'UacMicrophoneError'
}
}
export function microphoneSupportError(): UacMicrophoneErrorCode | null {
if (!window.isSecureContext) return 'secure-context-required'
if (!navigator.mediaDevices?.getUserMedia || typeof AudioEncoder === 'undefined' || typeof AudioData === 'undefined') {
return 'unsupported'
}
return null
}
function buildAudioMessage(chunk: EncodedAudioChunk): Uint8Array {
const message = new Uint8Array(15 + chunk.byteLength)
const header = new DataView(message.buffer, 0, 15)
header.setUint8(0, 0x03)
header.setUint32(1, Math.round(chunk.timestamp / 1000), true)
header.setUint16(5, Math.round((chunk.duration ?? 0) / 1000), true)
header.setUint32(7, 0, true)
header.setUint32(11, chunk.byteLength, true)
chunk.copyTo(message.subarray(15))
return message
}
function isUacTargetState(value: unknown): value is UacTargetState {
return value === 'idle' || value === 'waiting' || value === 'active' || value === 'stalled'
}
function openSocket(
url: string,
onCreated: (socket: WebSocket) => void,
onTargetState: (state: UacTargetState) => void,
): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const socket = new WebSocket(url)
onCreated(socket)
socket.binaryType = 'arraybuffer'
socket.onmessage = event => {
if (typeof event.data !== 'string') return
try {
const message = JSON.parse(event.data) as { type?: unknown, state?: unknown }
if (message.type === 'uac_status' && isUacTargetState(message.state)) {
onTargetState(message.state)
}
} catch {
// Ignore control messages from incompatible server versions.
}
}
let settled = false
const timeout = window.setTimeout(() => {
if (settled) return
settled = true
socket.close()
reject(new UacMicrophoneError('connection-failed', 'WebSocket connection timed out'))
}, SOCKET_OPEN_TIMEOUT_MS)
socket.onopen = () => {
if (settled) return
settled = true
window.clearTimeout(timeout)
resolve(socket)
}
socket.onerror = () => {
if (settled) return
settled = true
window.clearTimeout(timeout)
reject(new UacMicrophoneError('connection-failed', 'WebSocket connection failed'))
}
socket.onclose = () => {
if (settled) return
settled = true
window.clearTimeout(timeout)
reject(new UacMicrophoneError('connection-failed', 'WebSocket was rejected'))
}
})
}
export function normalizeMicrophoneError(error: unknown): UacMicrophoneError {
if (error instanceof UacMicrophoneError) return error
if (error instanceof DOMException) {
if (error.name === 'NotAllowedError' || error.name === 'SecurityError') {
return new UacMicrophoneError('permission-denied', error.message)
}
if (error.name === 'NotFoundError' || error.name === 'OverconstrainedError' || error.name === 'NotReadableError') {
return new UacMicrophoneError('device-unavailable', error.message)
}
}
return new UacMicrophoneError(
'unknown',
error instanceof Error ? error.message : String(error),
)
}
export class UacMicrophoneSession {
private socket: WebSocket | null = null
private stream: MediaStream | null = null
private encoder: AudioEncoder | null = null
private context: AudioContext | null = null
private source: MediaStreamAudioSourceNode | null = null
private processor: ScriptProcessorNode | null = null
private sink: MediaStreamAudioDestinationNode | null = null
private stopping = false
private stopped = false
private stopPromise: Promise<void> | null = null
private encodedFrames = 0
private inputFrames = 0
constructor(
private readonly endpoint: string,
private readonly onUnexpectedEnd: (error: UacMicrophoneError) => void,
private readonly onTargetState: (state: UacTargetState) => void,
private readonly inputDeviceId = '',
) {}
get activeInputDeviceId(): string {
return this.stream?.getAudioTracks()[0]?.getSettings().deviceId ?? ''
}
async start(): Promise<void> {
const supportError = microphoneSupportError()
if (supportError) {
throw new UacMicrophoneError(supportError, 'Required browser audio APIs are unavailable')
}
try {
const support = await AudioEncoder.isConfigSupported(OPUS_CONFIG)
if (!support.supported) {
throw new UacMicrophoneError('unsupported', 'The browser does not support Opus encoding')
}
if (this.stopping) return
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
...(this.inputDeviceId ? { deviceId: { exact: this.inputDeviceId } } : {}),
sampleRate: SAMPLE_RATE,
channelCount: CHANNELS,
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
})
if (this.stopping) {
stream.getTracks().forEach(track => track.stop())
return
}
this.stream = stream
const socket = await openSocket(this.endpoint, pendingSocket => {
this.socket = pendingSocket
}, this.onTargetState)
if (this.stopping) {
socket.close()
return
}
socket.onclose = () => this.handleUnexpectedEnd('UAC audio connection closed')
socket.onerror = () => this.handleUnexpectedEnd('UAC audio connection failed')
this.encoder = new AudioEncoder({
output: chunk => this.sendEncodedChunk(chunk),
error: error => this.handleUnexpectedEnd(error.message, 'encoder-failed'),
})
this.encoder.configure(OPUS_CONFIG)
this.context = new AudioContext({ sampleRate: SAMPLE_RATE })
await this.context.resume()
if (this.stopping) return
this.source = this.context.createMediaStreamSource(this.stream)
this.processor = this.context.createScriptProcessor(PROCESSOR_BUFFER_SIZE, CHANNELS, CHANNELS)
this.sink = this.context.createMediaStreamDestination()
this.processor.onaudioprocess = event => this.encodeInput(event.inputBuffer)
this.source.connect(this.processor)
// A silent MediaStream destination keeps ScriptProcessor active without
// routing microphone input to the user's speakers.
this.processor.connect(this.sink)
} catch (error) {
await this.stop()
throw normalizeMicrophoneError(error)
}
}
stop(): Promise<void> {
if (this.stopPromise) return this.stopPromise
this.stopPromise = this.stopResources()
return this.stopPromise
}
private encodeInput(input: AudioBuffer) {
if (this.stopping || this.encoder?.state !== 'configured') return
if (this.encoder.encodeQueueSize > MAX_ENCODER_QUEUE_SIZE) return
const frames = input.length
const left = input.getChannelData(0)
const right = input.numberOfChannels > 1 ? input.getChannelData(1) : left
const pcm = new Int16Array(frames * CHANNELS)
for (let i = 0; i < frames; i += 1) {
pcm[i * CHANNELS] = Math.round(Math.max(-1, Math.min(1, left[i] ?? 0)) * 32767)
pcm[i * CHANNELS + 1] = Math.round(Math.max(-1, Math.min(1, right[i] ?? 0)) * 32767)
}
const timestamp = Math.round(this.inputFrames * 1_000_000 / SAMPLE_RATE)
this.inputFrames += frames
const audioData = new AudioData({
format: 's16',
sampleRate: SAMPLE_RATE,
numberOfFrames: frames,
numberOfChannels: CHANNELS,
timestamp,
data: pcm,
})
try {
this.encoder.encode(audioData)
} finally {
audioData.close()
}
}
private sendEncodedChunk(chunk: EncodedAudioChunk) {
const socket = this.socket
if (this.stopping || socket?.readyState !== WebSocket.OPEN) return
if (socket.bufferedAmount >= MAX_SOCKET_BUFFER_BYTES) return
const message = buildAudioMessage(chunk)
new DataView(message.buffer).setUint32(7, this.encodedFrames, true)
this.encodedFrames = (this.encodedFrames + 1) >>> 0
socket.send(message)
}
private handleUnexpectedEnd(message: string, code: UacMicrophoneErrorCode = 'connection-failed') {
if (this.stopping || this.stopped) return
void this.stop().finally(() => {
this.onUnexpectedEnd(new UacMicrophoneError(code, message))
})
}
private async stopResources() {
this.stopping = true
if (this.processor) {
this.processor.onaudioprocess = null
this.processor.disconnect()
this.processor = null
}
this.source?.disconnect()
this.source = null
this.sink?.disconnect()
this.sink = null
this.stream?.getTracks().forEach(track => track.stop())
this.stream = null
const encoder = this.encoder
this.encoder = null
if (encoder && encoder.state !== 'closed') {
try {
if (encoder.state === 'configured') await encoder.flush()
} catch {
// The encoder may reject flush after a device or socket failure.
}
try {
encoder.close()
} catch {
// An asynchronous encoder error may already have closed it.
}
}
const socket = this.socket
this.socket = null
if (socket) {
if (socket.readyState === WebSocket.CONNECTING) {
// Keep the connection promise handlers installed so closing a pending
// socket also settles start().
socket.close()
} else {
socket.onclose = null
socket.onerror = null
socket.onmessage = null
if (socket.readyState === WebSocket.OPEN) socket.close()
}
}
const context = this.context
this.context = null
if (context && context.state !== 'closed') {
try {
await context.close()
} catch {
// A context that failed during startup may already be unusable.
}
}
this.stopped = true
}
}

View File

@@ -14,6 +14,7 @@ import { useComputerUseSocket, type ComputerUseServerMessage } from '@/composabl
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
import { useTheme } from '@/composables/useTheme'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
import { getMicrophone } from '@/composables/useMicrophone'
import { streamApi, hidApi, atxApi, atxConfigApi, authApi, computerUseApi, uacApi } from '@/api'
import type { ComputerUseScreenshot, ComputerUseSession } from '@/api'
import { CanonicalKey, HidBackend } from '@/types/generated'
@@ -76,6 +77,11 @@ const { connected: wsConnected, networkError: wsNetworkError } = useWebSocket()
const hidWs = useHidWebSocket()
const webrtc = useWebRTC()
const unifiedAudio = getUnifiedAudio()
const microphone = getMicrophone()
const uacEnabled = ref(false)
const microphoneTransferEnabled = computed(() => (
uacEnabled.value && configStore.hid?.backend === HidBackend.Otg
))
const videoSession = useVideoSession()
const consoleEvents = useConsoleEvents({
@@ -468,14 +474,6 @@ const hidDetails = computed<StatusDetail[]>(() => {
return details
})
const audioStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
const audio = systemStore.audio
if (!audio?.available) return 'disconnected'
if (audio.error) return 'error'
if (audio.streaming) return 'connected'
return 'disconnected'
})
function translateAudioQuality(quality: string | undefined): string {
if (!quality) return t('common.unknown')
const qualityLower = quality.toLowerCase()
@@ -485,26 +483,81 @@ function translateAudioQuality(quality: string | undefined): string {
return quality
}
const microphoneTransferStatus = computed<{
text: string
card: 'connected' | 'connecting' | 'disconnected' | 'error'
detail?: StatusDetail['status']
}>(() => {
switch (microphone.state.value) {
case 'starting':
return { text: t('statusCard.transferStarting'), card: 'connecting', detail: 'warning' }
case 'streaming':
if (microphone.targetState.value === 'active') {
return { text: t('statusCard.transferActive'), card: 'connected', detail: 'ok' }
}
if (microphone.targetState.value === 'stalled') {
return { text: t('statusCard.transferStalled'), card: 'connecting', detail: 'warning' }
}
return { text: t('statusCard.transferWaiting'), card: 'connecting', detail: 'warning' }
case 'stopping':
return { text: t('statusCard.transferStopping'), card: 'connecting', detail: 'warning' }
case 'error':
return { text: t('statusCard.transferError'), card: 'error', detail: 'error' }
case 'idle':
default:
return { text: t('statusCard.transferIdle'), card: 'disconnected' }
}
})
const audioStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
const transfer = microphoneTransferEnabled.value ? microphoneTransferStatus.value : null
if (systemStore.audio?.error || transfer?.card === 'error') return 'error'
if (transfer?.card === 'connecting') return 'connecting'
if (systemStore.audio?.streaming || transfer?.card === 'connected') return 'connected'
return 'disconnected'
})
const audioQuickInfo = computed(() => {
const audio = systemStore.audio
if (!audio?.available) return ''
if (audio.streaming) return translateAudioQuality(audio.quality)
return t('statusCard.off')
const playback = audio?.streaming
? t('statusCard.playbackActive')
: t('statusCard.playbackStopped')
return microphoneTransferEnabled.value
? `${playback} · ${microphoneTransferStatus.value.text}`
: playback
})
const audioErrorMessage = computed(() => {
return systemStore.audio?.error || ''
if (systemStore.audio?.error) return systemStore.audio.error
if (!microphoneTransferEnabled.value) return ''
const code = microphone.errorCode.value
if (microphone.state.value === 'error' && code) {
return t(`actionbar.micError.${code}`)
}
return ''
})
const audioDetails = computed<StatusDetail[]>(() => {
const audio = systemStore.audio
if (!audio) return []
return [
{ label: t('statusCard.device'), value: audio.device || t('statusCard.defaultDevice') },
{ label: t('statusCard.quality'), value: translateAudioQuality(audio.quality) },
{ label: t('statusCard.streaming'), value: audio.streaming ? t('common.yes') : t('common.no'), status: audio.streaming ? 'ok' : undefined },
const details: StatusDetail[] = [
{
label: t('statusCard.audioPlayback'),
value: audio?.streaming ? t('statusCard.playbackActive') : t('statusCard.playbackStopped'),
status: audio?.error ? 'error' : audio?.streaming ? 'ok' : undefined,
},
]
if (microphoneTransferEnabled.value) {
details.push({
label: t('statusCard.audioTransfer'),
value: microphoneTransferStatus.value.text,
status: microphoneTransferStatus.value.detail,
})
}
details.push(
{ label: t('statusCard.device'), value: audio?.device || t('statusCard.defaultDevice') },
{ label: t('statusCard.quality'), value: translateAudioQuality(audio?.quality) },
)
return details
})
const msdStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
@@ -2924,6 +2977,7 @@ async function activateConsoleView() {
function deactivateConsoleView() {
isConsoleActive.value = false
void microphone.stop()
handleBlur()
exitPointerLock()
unregisterInteractionListeners()
@@ -2961,14 +3015,21 @@ function handleToggleMouseMode() {
}
}
const uacEnabled = ref(false)
async function refreshUacAvailability() {
try {
const uacConfig = await uacApi.get()
uacEnabled.value = uacConfig.enabled
} catch {
uacEnabled.value = false
}
}
watch(microphoneTransferEnabled, enabled => {
if (!enabled) void microphone.stop()
})
onMounted(async () => {
// Check if UAC is enabled (show mic button only if USB mic is available)
try {
const uacCfg = await uacApi.get()
uacEnabled.value = uacCfg.enabled
} catch { /* ignore */ }
await refreshUacAvailability()
consoleEvents.subscribe()
@@ -3009,6 +3070,7 @@ onMounted(async () => {
})
onActivated(() => {
void refreshUacAvailability()
void activateConsoleView()
})
@@ -3096,7 +3158,6 @@ onUnmounted(() => {
:details="videoDetails"
/>
<StatusCard
v-if="systemStore.audio?.available"
:title="t('statusCard.audio')"
type="audio"
:status="audioStatus"
@@ -3174,7 +3235,7 @@ onUnmounted(() => {
:show-terminal="showTerminal"
:show-computer-use="showComputerUse"
:show-paste-text="showPasteText"
:show-mic="uacEnabled"
:show-mic="microphoneTransferEnabled"
@toggle-fullscreen="toggleFullscreen"
@toggle-stats="openStatsSheet"
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"

View File

@@ -3376,10 +3376,7 @@ watch(isWindows, () => {
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.uacMic') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.uacMicDesc') }}</p>
</div>
<Label>{{ t('settings.uacMic') }}</Label>
<Switch v-model="config.uac_enabled" />
</div>
</div>