-
-
-
-
-
-
diff --git a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue
index 4eefce42c..71598fd40 100644
--- a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue
+++ b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue
@@ -31,7 +31,6 @@ import { useTranscriptions } from '../../composables/use-transcriptions'
import { BackgroundDialogPicker } from '../Backgrounds'
const { isDark, toggleDark } = useTheme()
-const hearingDialogOpen = ref(false)
const chatOrchestrator = useChatOrchestratorStore()
const chatSession = useChatSessionStore()
const chatStream = useChatStreamStore()
@@ -59,24 +58,25 @@ const { themeColorsHueDynamic } = storeToRefs(useSettings())
const { viewControlsEnabled: l2dViewCtrlEnabled } = useL2dViewControl()
const { viewControlsEnabled: threeViewCtrlEnabled } = useThreeViewControl()
const settingsAudioDevice = useSettingsAudioDevice()
-const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(settingsAudioDevice)
+const { enabled, stream } = storeToRefs(settingsAudioDevice)
const { ingest, onAfterMessageComposed } = chatOrchestrator
const { t } = useI18n()
const { audioContext } = useAudioContext()
-const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer()
+const { startAnalyzer, stopAnalyzer } = useAudioAnalyzer()
let analyzerSource: MediaStreamAudioSourceNode | undefined
function isMobileDevice() {
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
}
-const { isListening } = useTranscriptions(
+const { isListening, startStreamingTranscription, stopStreamingTranscription } = useTranscriptions(
{
messageInputRef: messageInput,
sendMessage: handleSend,
isStageTamagotchi,
},
)
+const toggleTranscription = () => isListening.value ? stopStreamingTranscription() : startStreamingTranscription()
async function handleSubmit() {
if (!isMobileDevice()) {
@@ -115,14 +115,14 @@ function teardownAnalyzer() {
try {
analyzerSource?.disconnect()
}
- catch {}
+ catch { }
analyzerSource = undefined
stopAnalyzer()
}
async function setupAnalyzer() {
teardownAnalyzer()
- if (!hearingDialogOpen.value || !enabled.value || !stream.value)
+ if (!enabled.value || !stream.value)
return
if (audioContext.state === 'suspended')
await audioContext.resume()
@@ -137,12 +137,6 @@ watch([enabled, stream], () => {
setupAnalyzer()
}, { immediate: true })
-watch(hearingDialogOpen, (value) => {
- if (value) {
- settingsAudioDevice.askPermission()
- }
-})
-
onAfterMessageComposed(async () => {
})
@@ -196,11 +190,9 @@ onMounted(() => {
{
onAfterMessageComposed(async () => {
})
-const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer()
-const normalizedVolume = computed(() => Math.min(1, Math.max(0, (volumeLevel.value ?? 0) / 100)))
+const { startAnalyzer, stopAnalyzer } = useAudioAnalyzer()
let analyzerSource: MediaStreamAudioSourceNode | undefined
function teardownAnalyzer() {
@@ -294,7 +293,6 @@ watch(sendMode, () => {
v-model:auto-send="autoSendEnabled"
:transcription="isListening"
:granted="true"
- :volume-level="normalizedVolume"
@toggle-transcription="() => isListening ? stopStreamingTranscription() : startStreamingTranscription()"
/>
diff --git a/packages/stage-layouts/src/composables/use-transcriptions.ts b/packages/stage-layouts/src/composables/use-transcriptions.ts
index b22cf5c7c..1a94e602d 100644
--- a/packages/stage-layouts/src/composables/use-transcriptions.ts
+++ b/packages/stage-layouts/src/composables/use-transcriptions.ts
@@ -17,13 +17,14 @@ export function useTranscriptions(options: TranscriptionOptions) {
const { messageInputRef: messageInput, sendMessage, isStageTamagotchi } = options
const hearingStore = useHearingStore()
+ const audioDeviceSettingsStore = useSettingsAudioDevice()
const hearingPipeline = useHearingSpeechInputPipeline()
const { transcribeForMediaStream, stopStreamingTranscription } = hearingPipeline
const { supportsStreamInput } = storeToRefs(hearingPipeline)
const { configured: hearingConfigured, autoSendEnabled, autoSendDelay } = storeToRefs(hearingStore)
- const { enabled: hearingEnabled, stream } = storeToRefs(useSettingsAudioDevice())
+ const { enabled: hearingEnabled, stream } = storeToRefs(audioDeviceSettingsStore)
const providersStore = useProvidersStore()
- const { askPermission, startStream } = useSettingsAudioDevice()
+ const { askPermission, startStream } = audioDeviceSettingsStore
const isListening = ref(false)
diff --git a/packages/stage-ui-live2d/package.json b/packages/stage-ui-live2d/package.json
index 120b1e8e1..835f42563 100644
--- a/packages/stage-ui-live2d/package.json
+++ b/packages/stage-ui-live2d/package.json
@@ -69,6 +69,7 @@
"devDependencies": {
"@types/culori": "^4.0.1",
"@types/three": "^0.184.0",
+ "vitest": "catalog:",
"vue-tsc": "^3.2.6"
}
}
diff --git a/packages/stage-ui-live2d/src/components/ViewControls/SliderControls.vue b/packages/stage-ui-live2d/src/components/ViewControls/SliderControls.vue
index 5db802c4e..5a1979a02 100644
--- a/packages/stage-ui-live2d/src/components/ViewControls/SliderControls.vue
+++ b/packages/stage-ui-live2d/src/components/ViewControls/SliderControls.vue
@@ -1,10 +1,31 @@
@@ -42,8 +49,8 @@ onMounted(() => screenSafeArea.update())
Hearing Input
toggleTranscription?.()"
/>
@@ -73,7 +80,8 @@ onMounted(() => screenSafeArea.update())
/>
toggleTranscription?.()"
/>
diff --git a/packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config.vue b/packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config.vue
index 0a8004151..9d8a5f3de 100644
--- a/packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config.vue
+++ b/packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config.vue
@@ -3,20 +3,21 @@ import { Button, Callout, FieldCombobox } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
+import { useAudioAnalyzer, useAudioDevice } from '../../../../composables'
import { useSettingsAudioDevice } from '../../../../stores'
const props = withDefaults(defineProps<{
- granted?: boolean
- volumeLevel?: number
+ granted?: boolean // permission status on OS level
transcription?: boolean
}>(), {
granted: false,
- volumeLevel: 0,
})
const emit = defineEmits(['toggleTranscription'])
-
-const { enabled, selectedAudioInput, audioInputs } = storeToRefs(useSettingsAudioDevice())
+const deviceStore = useSettingsAudioDevice()
+const { enabled, selectedAudioInput } = storeToRefs(deviceStore)
+const { audioInputs, permissionGranted, askPermission } = useAudioDevice()
+const { volumeLevel } = useAudioAnalyzer()
const autoSend = defineModel('autoSend')
const ringEnabledClass = computed(() => enabled.value
@@ -27,8 +28,10 @@ const ringEnabledClass = computed(() => enabled.value
function toggleHearingEnabled() {
if (enabled.value)
return enabled.value = false
- if (!enabled.value && selectedAudioInput.value !== '')
- enabled.value = true
+ if (selectedAudioInput.value !== '' && permissionGranted.value)
+ return enabled.value = true
+ if (!permissionGranted.value)
+ return askPermission().then(() => { enabled.value = permissionGranted.value })
}
@@ -40,17 +43,17 @@ function toggleHearingEnabled() {
diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/preview-stage.vue b/packages/stage-ui/src/components/scenarios/settings/model-settings/preview-stage.vue
index fd62c80ec..9fcdba336 100644
--- a/packages/stage-ui/src/components/scenarios/settings/model-settings/preview-stage.vue
+++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/preview-stage.vue
@@ -5,7 +5,7 @@ import { Live2DScene } from '@proj-airi/stage-ui-live2d'
import { ThreeScene, useModelStore } from '@proj-airi/stage-ui-three'
import { useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
-import { computed, ref, watch } from 'vue'
+import { computed, provide, ref, watch } from 'vue'
import { useSettings } from '../../../../stores/settings'
import {
@@ -30,6 +30,8 @@ const vrmSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>(
const live2dComponentState = ref<'pending' | 'loading' | 'mounted'>('pending')
const vrmPreviewStageInstanceId = `model-settings-preview-stage:${Math.random().toString(36).slice(2, 10)}`
+provide('previewStage', true)
+
const {
live2dDisableFocus,
stageModelSelected,
diff --git a/packages/stage-ui/src/composables/audio/audio-analyzer.ts b/packages/stage-ui/src/composables/audio/audio-analyzer.ts
index 5e1ff8116..244279591 100644
--- a/packages/stage-ui/src/composables/audio/audio-analyzer.ts
+++ b/packages/stage-ui/src/composables/audio/audio-analyzer.ts
@@ -1,5 +1,10 @@
import { onUnmounted, ref } from 'vue'
+const amplification = 3 // Amplification factor for volume visualization
+
+const volumeLevel = ref(0) // 0-100
+const error = ref()
+
export function useAudioAnalyzer() {
const analyzer = ref()
const dataArray = ref>()
@@ -7,11 +12,6 @@ export function useAudioAnalyzer() {
const onAnalyzerUpdateHooks = ref void | Promise>>([])
- const volumeLevel = ref(0) // 0-100
- const error = ref()
-
- const amplification = 3 // Amplification factor for volume visualization
-
function onAnalyzerUpdate(callback: (volumeLevel: number) => void | Promise) {
onAnalyzerUpdateHooks.value.push(callback)
return () => {
diff --git a/packages/stage-ui/src/composables/audio/audio-device.ts b/packages/stage-ui/src/composables/audio/audio-device.ts
index d7674a704..f0c1f6eb5 100644
--- a/packages/stage-ui/src/composables/audio/audio-device.ts
+++ b/packages/stage-ui/src/composables/audio/audio-device.ts
@@ -2,9 +2,8 @@ import { useDevicesList, useUserMedia } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
export function useAudioDevice(requestPermission: boolean = false) {
- const devices = useDevicesList({ constraints: { audio: true }, requestPermissions: requestPermission })
- const audioInputs = computed(() => devices.audioInputs.value)
- const selectedAudioInput = ref(devices.audioInputs.value.find(device => device.deviceId === 'default')?.deviceId || '')
+ const { audioInputs, permissionGranted, ensurePermissions } = useDevicesList({ constraints: { audio: true }, requestPermissions: requestPermission })
+ const selectedAudioInput = ref(audioInputs.value.find(device => device.deviceId === 'default')?.deviceId || '')
const deviceConstraints = computed(() => ({
audio: {
deviceId: { exact: selectedAudioInput.value },
@@ -16,13 +15,13 @@ export function useAudioDevice(requestPermission: boolean = false) {
const { stream, stop: stopStream, start: startStream } = useUserMedia({ constraints: deviceConstraints, enabled: false, autoSwitch: true })
watch(audioInputs, () => {
- if (!selectedAudioInput.value && audioInputs.value.length > 0) {
+ if (selectedAudioInput.value === '' && audioInputs.value.length > 0) {
selectedAudioInput.value = audioInputs.value.find(input => input.deviceId === 'default')?.deviceId || audioInputs.value[0].deviceId
}
})
function askPermission() {
- return devices.ensurePermissions()
+ return ensurePermissions()
.then(() => nextTick())
.then(() => {
if (audioInputs.value.length > 0 && !selectedAudioInput.value) {
@@ -40,6 +39,7 @@ export function useAudioDevice(requestPermission: boolean = false) {
selectedAudioInput,
stream,
deviceConstraints,
+ permissionGranted,
askPermission,
startStream,
diff --git a/packages/stage-ui/src/stores/settings/audio-device.ts b/packages/stage-ui/src/stores/settings/audio-device.ts
index 31d6ec566..8ee31f1b9 100644
--- a/packages/stage-ui/src/stores/settings/audio-device.ts
+++ b/packages/stage-ui/src/stores/settings/audio-device.ts
@@ -4,17 +4,19 @@ import { watch } from 'vue'
import { useAudioDevice } from '../../composables/audio'
+let microphonePermissionStatus: PermissionStatus
+
export const useSettingsAudioDevice = defineStore('settings-audio-devices', () => {
const { audioInputs, deviceConstraints, selectedAudioInput: selectedAudioInputNonPersist, startStream, stopStream, stream, askPermission } = useAudioDevice()
const selectedAudioInputPersist = useLocalStorageManualReset('settings/audio/input', selectedAudioInputNonPersist.value)
- const selectedAudioInputEnabledPersist = useLocalStorageManualReset('settings/audio/input/enabled', false)
+ const audioInputEnabled = useLocalStorageManualReset('settings/audio/input/enabled', false)
watch(selectedAudioInputPersist, (newValue) => {
selectedAudioInputNonPersist.value = newValue
})
- watch(selectedAudioInputEnabledPersist, (val) => {
+ watch(audioInputEnabled, (val) => {
if (val) {
startStream()
}
@@ -23,14 +25,27 @@ export const useSettingsAudioDevice = defineStore('settings-audio-devices', () =
}
})
+ // permissionGranted from vueuse does not track revocation yet.
+ // implement it manually.
+ try {
+ navigator?.permissions?.query({ name: 'microphone' }).then((status) => {
+ microphonePermissionStatus = status // existing one cleaned up by GC
+ status.onchange = () => {
+ if (status.state === 'denied' || status.state === 'prompt')
+ audioInputEnabled.value = false
+ }
+ })
+ }
+ catch (e) { console.info(`Unable to track microphone permission: ${e}`) }
+ void microphonePermissionStatus // suppress unused variable lint
function initialize() {
const hasSelectedInput = selectedAudioInputPersist.value
&& audioInputs.value.some(device => device.deviceId === selectedAudioInputPersist.value)
- if (selectedAudioInputEnabledPersist.value && hasSelectedInput) {
+ if (audioInputEnabled.value && hasSelectedInput) {
startStream()
}
- if (selectedAudioInputNonPersist.value && !selectedAudioInputEnabledPersist.value) {
+ if (selectedAudioInputNonPersist.value && !audioInputEnabled.value) {
selectedAudioInputPersist.value = selectedAudioInputNonPersist.value
}
}
@@ -38,7 +53,7 @@ export const useSettingsAudioDevice = defineStore('settings-audio-devices', () =
function resetState() {
selectedAudioInputPersist.reset()
selectedAudioInputNonPersist.value = ''
- selectedAudioInputEnabledPersist.reset()
+ audioInputEnabled.reset()
stopStream()
}
@@ -46,7 +61,7 @@ export const useSettingsAudioDevice = defineStore('settings-audio-devices', () =
audioInputs,
deviceConstraints,
selectedAudioInput: selectedAudioInputPersist,
- enabled: selectedAudioInputEnabledPersist,
+ enabled: audioInputEnabled,
stream,
diff --git a/packages/ui/src/components/form/range/round-range.vue b/packages/ui/src/components/form/range/round-range.vue
index 0794afb52..50285fe5a 100644
--- a/packages/ui/src/components/form/range/round-range.vue
+++ b/packages/ui/src/components/form/range/round-range.vue
@@ -64,11 +64,13 @@ function handleInput(e: Event) {
}
function onWheelInput(ev: WheelEvent) {
- let valueAfter = 0
+ if (ev.deltaY === 0)
+ return
+ let valueAfter = modelValue.value
if (ev.deltaY < 0)
- valueAfter = modelValue.value += props.step * (shiftPressed.value ? 50 : 1)
+ valueAfter += props.step * (shiftPressed.value ? 50 : 1)
if (ev.deltaY > 0)
- valueAfter = modelValue.value -= props.step * (shiftPressed.value ? 50 : 1)
+ valueAfter -= props.step * (shiftPressed.value ? 50 : 1)
modelValue.value = Math.min(Math.max(valueAfter, props.min), props.max)
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f16016804..3dca83df3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3661,6 +3661,9 @@ importers:
'@types/three':
specifier: ^0.184.0
version: 0.184.0
+ vitest:
+ specifier: 'catalog:'
+ version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vue-tsc:
specifier: ^3.2.6
version: 3.2.6(typescript@5.9.3)
@@ -3725,6 +3728,9 @@ importers:
'@types/three':
specifier: ^0.184.0
version: 0.184.0
+ vitest:
+ specifier: 'catalog:'
+ version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vue-tsc:
specifier: ^3.2.6
version: 3.2.6(typescript@5.9.3)