Skip to content

Commit ddf9627

Browse files
committed
feat(stage-web): voice & microphone for mobile web
1 parent 309a958 commit ddf9627

File tree

5 files changed

+213
-3
lines changed

5 files changed

+213
-3
lines changed

apps/stage-web/src/components/Layouts/InteractiveArea.vue

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ChatProvider } from '@xsai-ext/shared-providers'
44
import WhisperWorker from '@proj-airi/stage-ui/libs/workers/worker?worker&url'
55
66
import { toWAVBase64 } from '@proj-airi/audio'
7+
import { HearingConfigDialog } from '@proj-airi/stage-ui/components'
78
import { useMicVAD, useWhisper } from '@proj-airi/stage-ui/composables'
89
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
910
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
@@ -17,10 +18,11 @@ import { onMounted, ref, watch } from 'vue'
1718
import { useI18n } from 'vue-i18n'
1819
1920
import ChatHistory from '../Widgets/ChatHistory.vue'
21+
import IndicatorMicVolume from '../Widgets/IndicatorMicVolume.vue'
2022
2123
const messageInput = ref('')
2224
const listening = ref(false)
23-
const showMicrophoneSelect = ref(false)
25+
const hearingDialogOpen = ref(false)
2426
const isComposing = ref(false)
2527
2628
const providersStore = useProvidersStore()
@@ -116,7 +118,7 @@ watch(enabled, async (value) => {
116118
}
117119
})
118120
119-
watch(showMicrophoneSelect, async (value) => {
121+
watch(hearingDialogOpen, async (value) => {
120122
if (value) {
121123
await askPermission()
122124
}
@@ -164,6 +166,23 @@ onAfterMessageComposed(async () => {
164166
@compositionstart="isComposing = true"
165167
@compositionend="isComposing = false"
166168
/>
169+
170+
<HearingConfigDialog v-model:show="hearingDialogOpen" :overlay-dim="true" :overlay-blur="true">
171+
<button
172+
class="max-h-[10lh] min-h-[1lh]"
173+
bg="neutral-100 dark:neutral-800"
174+
text="lg neutral-500 dark:neutral-400"
175+
:class="{ 'ring-2 ring-primary-400/60 ring-offset-2 dark:ring-offset-neutral-900': listening }"
176+
flex items-center justify-center rounded-md p-2 outline-none
177+
transition="colors duration-200, transform duration-100" active:scale-95
178+
:title="t('settings.hearing.title')"
179+
>
180+
<Transition name="fade" mode="out-in">
181+
<IndicatorMicVolume v-if="enabled" />
182+
<div v-else class="i-ph:microphone-slash" />
183+
</Transition>
184+
</button>
185+
</HearingConfigDialog>
167186
</div>
168187
</div>
169188
</div>

apps/stage-web/src/components/Layouts/MobileInteractiveArea.vue

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
import type { ChatProvider } from '@xsai-ext/shared-providers'
33
4+
import { HearingConfigDialog } from '@proj-airi/stage-ui/components'
45
import { useMicVAD } from '@proj-airi/stage-ui/composables'
56
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
67
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
@@ -19,6 +20,7 @@ import ActionViewControls from './InteractiveArea/Actions/ViewControls.vue'
1920
import ViewControlInputs from './ViewControls/Inputs.vue'
2021
2122
const isDark = useDark({ disableTransition: false })
23+
const hearingDialogOpen = ref(false)
2224
2325
const viewControlsActiveMode = ref<'x' | 'y' | 'z' | 'scale'>('scale')
2426
const viewControlsInputsRef = useTemplateRef<InstanceType<typeof ViewControlInputs>>('viewControlsInputs')
@@ -140,6 +142,19 @@ onMounted(() => {
140142
<div translate-y="[-100%]" absolute right-0 w-full px-3 pb-3 font-sans>
141143
<div flex="~ col" w-full gap-1>
142144
<ActionAbout />
145+
<HearingConfigDialog v-model:show="hearingDialogOpen">
146+
<button
147+
border="2 solid neutral-100/60 dark:neutral-800/30"
148+
bg="neutral-50/70 dark:neutral-800/70"
149+
w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md
150+
title="Hearing"
151+
>
152+
<Transition name="fade" mode="out-in">
153+
<div v-if="enabled" i-solar:microphone-3-bold-duotone size-5 text="neutral-500 dark:neutral-400" />
154+
<div v-else i-solar:microphone-3-outline size-5 text="neutral-500 dark:neutral-400" />
155+
</Transition>
156+
</button>
157+
</HearingConfigDialog>
143158
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Theme" @click="isDark = !isDark">
144159
<Transition name="fade" mode="out-in">
145160
<div v-if="isDark" i-solar:moon-outline size-5 text="neutral-500 dark:neutral-400" />
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<script setup lang="ts">
2+
import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
3+
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
4+
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
5+
import { storeToRefs } from 'pinia'
6+
import { computed, onMounted, onUnmounted, watch } from 'vue'
7+
8+
const props = withDefaults(defineProps<{ colorClass?: string }>(), { colorClass: 'text-primary-500 dark:text-primary-200' })
9+
const settingsAudio = useSettingsAudioDevice()
10+
const { stream, enabled } = storeToRefs(settingsAudio)
11+
12+
const { audioContext } = storeToRefs(useAudioContext())
13+
const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer()
14+
15+
let source: MediaStreamAudioSourceNode | undefined
16+
17+
const normalized = computed(() => Math.min(1, (volumeLevel.value ?? 0) / 100))
18+
19+
function teardown() {
20+
try {
21+
source?.disconnect()
22+
}
23+
catch {
24+
// ignore
25+
}
26+
27+
source = undefined
28+
stopAnalyzer()
29+
}
30+
31+
async function setup() {
32+
teardown()
33+
if (!enabled.value || !stream.value)
34+
return
35+
36+
const ctx = audioContext.value
37+
if (ctx.state === 'suspended')
38+
await ctx.resume()
39+
const analyser = startAnalyzer(ctx)
40+
if (!analyser)
41+
return
42+
source = ctx.createMediaStreamSource(stream.value)
43+
source.connect(analyser)
44+
}
45+
46+
onMounted(() => {
47+
watch([enabled, stream], () => setup(), { immediate: true })
48+
})
49+
50+
onUnmounted(() => teardown())
51+
</script>
52+
53+
<template>
54+
<div :class="['flex items-center justify-center', props.colorClass]">
55+
<svg width="24" height="24" viewBox="0 0 256 256" aria-hidden="true">
56+
<defs>
57+
<linearGradient id="micLevel" x1="0%" y1="0%" x2="0%" y2="100%">
58+
<stop offset="0%" stop-color="currentColor" stop-opacity="0" />
59+
<stop :offset="`${100 - Math.round(normalized * 100)}%`" stop-color="currentColor" stop-opacity="0" />
60+
<stop :offset="`${100 - Math.round(normalized * 100)}%`" stop-color="currentColor" stop-opacity="0.95" />
61+
<stop offset="100%" stop-color="currentColor" stop-opacity="0.95" />
62+
</linearGradient>
63+
</defs>
64+
<path
65+
fill="url(#micLevel)"
66+
d="M128 176a48.05 48.05 0 0 0 48-48V64a48 48 0 0 0-96 0v64a48.05 48.05 0 0 0 48 48M96 64a32 32 0 0 1 64 0v64a32 32 0 0 1-64 0Zm40 143.6V240a8 8 0 0 1-16 0v-32.4A80.11 80.11 0 0 1 48 128a8 8 0 0 1 16 0a64 64 0 0 0 128 0a8 8 0 0 1 16 0a80.11 80.11 0 0 1-72 79.6"
67+
/>
68+
<path
69+
fill="none"
70+
stroke="currentColor"
71+
stroke-opacity="1"
72+
stroke-width="2"
73+
d="M128 176a48.05 48.05 0 0 0 48-48V64a48 48 0 0 0-96 0v64a48.05 48.05 0 0 0 48 48M96 64a32 32 0 0 1 64 0v64a32 32 0 0 1-64 0Zm40 143.6V240a8 8 0 0 1-16 0v-32.4A80.11 80.11 0 0 1 48 128a8 8 0 0 1 16 0a64 64 0 0 0 128 0a8 8 0 0 1 16 0a80.11 80.11 0 0 1-72 79.6"
74+
/>
75+
</svg>
76+
</div>
77+
</template>
78+

apps/stage-web/src/pages/index.vue

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
<script setup lang="ts">
2+
import type { ChatProvider } from '@xsai-ext/shared-providers'
3+
4+
import workletUrl from '@proj-airi/stage-ui/workers/vad/process.worklet?worker&url'
5+
26
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
7+
import { useAudioRecorder } from '@proj-airi/stage-ui/composables/audio/audio-recorder'
8+
import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
9+
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
310
import { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
11+
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
12+
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
13+
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
14+
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
415
import { breakpointsTailwind, useBreakpoints, useDark, useMouse } from '@vueuse/core'
516
import { storeToRefs } from 'pinia'
6-
import { onMounted, ref, watch } from 'vue'
17+
import { onMounted, onUnmounted, ref, watch } from 'vue'
718
819
import Cross from '../components/Backgrounds/Cross.vue'
920
import Header from '../components/Layouts/Header.vue'
@@ -29,6 +40,91 @@ const isMobile = breakpoints.smaller('md')
2940
const { updateThemeColor } = useThemeColor(themeColorFromPropertyOf('.widgets.top-widgets .colored-area', 'background-color'))
3041
watch(dark, () => updateThemeColor(), { immediate: true })
3142
onMounted(() => updateThemeColor())
43+
44+
// Audio + transcription pipeline (mirrors stage-tamagotchi)
45+
const settingsAudioDeviceStore = useSettingsAudioDevice()
46+
const { stream, enabled } = storeToRefs(settingsAudioDeviceStore)
47+
const { startRecord, stopRecord, onStopRecord } = useAudioRecorder(stream)
48+
const { transcribeForRecording } = useHearingSpeechInputPipeline()
49+
const providersStore = useProvidersStore()
50+
const consciousnessStore = useConsciousnessStore()
51+
const { activeProvider: activeChatProvider, activeModel: activeChatModel } = storeToRefs(consciousnessStore)
52+
const chatStore = useChatStore()
53+
54+
const {
55+
init: initVAD,
56+
dispose: disposeVAD,
57+
start: startVAD,
58+
loaded: vadLoaded,
59+
} = useVAD(workletUrl, {
60+
threshold: ref(0.6),
61+
onSpeechStart: () => startRecord(),
62+
onSpeechEnd: () => stopRecord(),
63+
})
64+
65+
let stopOnStopRecord: (() => void) | undefined
66+
67+
async function startAudioInteraction() {
68+
try {
69+
await initVAD()
70+
if (stream.value)
71+
await startVAD(stream.value)
72+
73+
// Hook once
74+
stopOnStopRecord = onStopRecord(async (recording) => {
75+
const text = await transcribeForRecording(recording)
76+
if (!text || !text.trim())
77+
return
78+
79+
try {
80+
const provider = await providersStore.getProviderInstance(activeChatProvider.value)
81+
if (!provider || !activeChatModel.value)
82+
return
83+
84+
await chatStore.send(text, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
85+
}
86+
catch (err) {
87+
console.error('Failed to send chat from voice:', err)
88+
}
89+
})
90+
}
91+
catch (e) {
92+
console.error('Audio interaction init failed:', e)
93+
}
94+
}
95+
96+
function stopAudioInteraction() {
97+
try {
98+
stopOnStopRecord?.()
99+
stopOnStopRecord = undefined
100+
disposeVAD()
101+
}
102+
catch {}
103+
}
104+
105+
watch(enabled, async (val) => {
106+
if (val) {
107+
await startAudioInteraction()
108+
}
109+
else {
110+
stopAudioInteraction()
111+
}
112+
}, { immediate: true })
113+
114+
onUnmounted(() => {
115+
stopAudioInteraction()
116+
})
117+
118+
watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
119+
if (enabled.value && loaded && s) {
120+
try {
121+
await startVAD(s)
122+
}
123+
catch (e) {
124+
console.error('Failed to start VAD with stream:', e)
125+
}
126+
}
127+
})
32128
</script>
33129

34130
<template>

cspell.config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,13 @@ words:
8989
- DuckDBWASMQuery
9090
- eastasia
9191
- elevenlabs
92+
- elfutils
9293
- Equirectangular
9394
- esaxx
9495
- eventa
9596
- Factorio
9697
- feaxios
98+
- Flathub
9799
- flexsearch
98100
- formkit
99101
- frontmatter

0 commit comments

Comments
 (0)