mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 18:44:25 +08:00
feat: 完善 OTG UAC 音频支持
This commit is contained in:
26
web/package-lock.json
generated
26
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
110
web/src/components/MicrophoneTransferControls.vue
Normal file
110
web/src/components/MicrophoneTransferControls.vue
Normal 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>
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '关闭',
|
||||
|
||||
328
web/src/lib/uac-microphone.ts
Normal file
328
web/src/lib/uac-microphone.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user