feat(stage-tamagotchi,stage-pages,stage-shared,stage-ui): complete beat-sync integration
This commit is contained in:
@@ -134,6 +134,7 @@
|
||||
"@iconify-json/vscode-icons": "^1.2.34",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@intlify/unplugin-vue-i18n": "^11.0.1",
|
||||
"@nekopaw/tempora": "0.3.1-alpha.1",
|
||||
"@proj-airi/lobe-icons": "^1.0.14",
|
||||
"@proj-airi/stage-shared": "workspace:^",
|
||||
"@proj-airi/ui-transitions": "workspace:^",
|
||||
|
||||
@@ -3,9 +3,22 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { defineInvoke, defineInvokeHandler } from '@moeru/eventa'
|
||||
import { createContext } from '@moeru/eventa/adapters/electron/main'
|
||||
import { beatSyncRequestSignalBeat, beatSyncSignalBeat } from '@proj-airi/stage-shared/beat-sync/eventa'
|
||||
import {
|
||||
beatSyncBeatSignaledInvokeEventa,
|
||||
beatSyncGetStateInvokeEventa,
|
||||
beatSyncStateChangedInvokeEventa,
|
||||
beatSyncToggleInvokeEventa,
|
||||
beatSyncUpdateParametersInvokeEventa,
|
||||
} from '@proj-airi/stage-shared/beat-sync'
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
|
||||
import {
|
||||
beatSyncElectronChangeState,
|
||||
beatSyncElectronGetState,
|
||||
beatSyncElectronSignalBeat,
|
||||
beatSyncElectronToggle,
|
||||
beatSyncElectronUpdateParameters,
|
||||
} from '../../../shared/eventa'
|
||||
import { baseUrl, getElectronMainDirname, load } from '../../libs/electron/location'
|
||||
|
||||
export async function setupBeatSync() {
|
||||
@@ -16,16 +29,35 @@ export async function setupBeatSync() {
|
||||
sandbox: false,
|
||||
},
|
||||
})
|
||||
const context = createContext(ipcMain, window).context
|
||||
|
||||
// TODO(Makito): Revisit and improve
|
||||
// [main] -> [renderer] beat-sync
|
||||
const toggle = defineInvoke(context, beatSyncElectronToggle) as (enabled: boolean) => Promise<void> // TODO: Better type
|
||||
const getState = defineInvoke(context, beatSyncElectronGetState)
|
||||
const updateParameters = defineInvoke(context, beatSyncElectronUpdateParameters)
|
||||
|
||||
await load(window, baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'), 'beat-sync.html'))
|
||||
return {
|
||||
window,
|
||||
dispatchTo: (window: BrowserWindow) => {
|
||||
const context = createContext(ipcMain, window).context
|
||||
const signalBeat = defineInvoke(context, beatSyncSignalBeat)
|
||||
const removeHandler = defineInvokeHandler(context, beatSyncRequestSignalBeat, async e => signalBeat(e))
|
||||
window.on('closed', () => removeHandler())
|
||||
return removeHandler
|
||||
|
||||
const stateChanged = defineInvoke(context, beatSyncStateChangedInvokeEventa)
|
||||
const beatSignaled = defineInvoke(context, beatSyncBeatSignaledInvokeEventa)
|
||||
const removeHandlerFns = [
|
||||
// [renderer] beat-sync -> [main] -> [renderer] index (Events)
|
||||
defineInvokeHandler(context, beatSyncElectronChangeState, async e => stateChanged(e)),
|
||||
defineInvokeHandler(context, beatSyncElectronSignalBeat, async e => beatSignaled(e)),
|
||||
|
||||
// [renderer] index -> [main] -> [renderer] beat-sync (Functions)
|
||||
defineInvokeHandler(context, beatSyncToggleInvokeEventa, async enabled => toggle(enabled)),
|
||||
defineInvokeHandler(context, beatSyncGetStateInvokeEventa, async () => getState()),
|
||||
defineInvokeHandler(context, beatSyncUpdateParametersInvokeEventa, async params => updateParameters(params)),
|
||||
]
|
||||
const removeHandlers = () => removeHandlerFns.forEach(fn => fn())
|
||||
window.on('closed', () => removeHandlers())
|
||||
return removeHandlers
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { defineInvoke, defineInvokeHandler } from '@moeru/eventa'
|
||||
import { createContext } from '@moeru/eventa/adapters/electron/renderer'
|
||||
import { createBeatSyncDetector, StageEnvironment } from '@proj-airi/stage-shared'
|
||||
import { beatSyncRequestSignalBeat, beatSyncToggle } from '@proj-airi/stage-shared/beat-sync/eventa'
|
||||
import { StageEnvironment } from '@proj-airi/stage-shared'
|
||||
import { createBeatSyncDetector } from '@proj-airi/stage-shared/beat-sync/browser'
|
||||
|
||||
import {
|
||||
beatSyncElectronChangeState,
|
||||
beatSyncElectronGetState,
|
||||
beatSyncElectronSignalBeat,
|
||||
beatSyncElectronToggle,
|
||||
beatSyncElectronUpdateParameters,
|
||||
} from '../shared/eventa'
|
||||
|
||||
const { ipcRenderer } = window.electron
|
||||
|
||||
const context = createContext(ipcRenderer).context
|
||||
const requestSignalBeat = defineInvoke(context, beatSyncRequestSignalBeat)
|
||||
|
||||
// [renderer] beat-sync -> [main] -> [renderer] index
|
||||
const changeState = defineInvoke(context, beatSyncElectronChangeState)
|
||||
const signalBeat = defineInvoke(context, beatSyncElectronSignalBeat)
|
||||
|
||||
function enableLoopbackAudio() {
|
||||
// electron-audio-loopback currently registers this handler internally
|
||||
@@ -24,9 +35,16 @@ const detector = createBeatSyncDetector({
|
||||
disableLoopbackAudio,
|
||||
})
|
||||
|
||||
detector.on('beat', e => requestSignalBeat(e))
|
||||
detector.on('stateChange', state => changeState(state))
|
||||
detector.on('beat', (e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('[beat]', e) // This could be noisy.
|
||||
signalBeat(e)
|
||||
})
|
||||
|
||||
defineInvokeHandler(context, beatSyncToggle, async (enabled) => {
|
||||
defineInvokeHandler(context, beatSyncElectronToggle, async (enabled) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[toggle]', enabled)
|
||||
if (enabled) {
|
||||
detector.startScreenCapture()
|
||||
}
|
||||
@@ -34,3 +52,9 @@ defineInvokeHandler(context, beatSyncToggle, async (enabled) => {
|
||||
detector.stop()
|
||||
}
|
||||
})
|
||||
defineInvokeHandler(context, beatSyncElectronGetState, async () => detector.state)
|
||||
defineInvokeHandler(context, beatSyncElectronUpdateParameters, async (params) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[update-params]', params)
|
||||
detector.updateParameters(params)
|
||||
})
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { AnalyserBeatEvent, AnalyserWorkletParameters } from '@nekopaw/tempora'
|
||||
import type { BeatSyncDetectorState } from '@proj-airi/stage-shared/beat-sync'
|
||||
|
||||
import { defineEventa, defineInvokeEventa } from '@moeru/eventa'
|
||||
|
||||
export const electronStartTrackMousePosition = defineInvokeEventa('eventa:invoke:electron:start-tracking-mouse-position')
|
||||
@@ -71,4 +74,10 @@ export const widgetsRemoveEvent = defineEventa<{ id: string }>('eventa:event:ele
|
||||
export const widgetsClearEvent = defineEventa('eventa:event:electron:windows:widgets:clear')
|
||||
export const widgetsUpdateEvent = defineEventa<{ id: string, componentProps?: Record<string, any> }>('eventa:event:electron:windows:widgets:update')
|
||||
|
||||
export const beatSyncElectronSignalBeat = defineInvokeEventa<void, AnalyserBeatEvent>('eventa:event:electron:beat-sync:electron:signal-beat')
|
||||
export const beatSyncElectronChangeState = defineInvokeEventa<void, BeatSyncDetectorState>('eventa:event:electron:beat-sync:electron:change-state')
|
||||
export const beatSyncElectronGetState = defineInvokeEventa<BeatSyncDetectorState>('eventa:event:electron:beat-sync:electron:get-state')
|
||||
export const beatSyncElectronToggle = defineInvokeEventa<void, boolean>('eventa:event:electron:beat-sync:electron:toggle')
|
||||
export const beatSyncElectronUpdateParameters = defineInvokeEventa<void, Partial<AnalyserWorkletParameters>>('eventa:event:electron:beat-sync:electron:update-parameters')
|
||||
|
||||
export { electron } from './electron'
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@proj-airi/stage-shared": "workspace:^",
|
||||
"@types/three": "^0.181.0",
|
||||
"vue-tsc": "^3.1.4"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { AnalyserBeatEvent, AnalyserWorkletParameters } from '@nekopaw/tempora'
|
||||
import type { AnalyserWorkletParameters } from '@nekopaw/tempora'
|
||||
import type { BeatSyncDetectorState } from '@proj-airi/stage-shared/beat-sync'
|
||||
|
||||
import { DEFAULT_ANALYSER_WORKLET_PARAMS } from '@nekopaw/tempora'
|
||||
import {
|
||||
getBeatSyncState,
|
||||
listenBeatSyncBeatSignal,
|
||||
listenBeatSyncStateChange,
|
||||
toggleBeatSync,
|
||||
updateBeatSyncParameters,
|
||||
} from '@proj-airi/stage-shared/beat-sync/browser'
|
||||
import { Button } from '@proj-airi/stage-ui/components'
|
||||
import { useBeatSyncStore } from '@proj-airi/stage-ui/stores/beat-sync'
|
||||
import { FieldCheckbox, FieldRange } from '@proj-airi/ui'
|
||||
import { createTimeline } from 'animejs'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, toRaw, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const beatSyncStore = useBeatSyncStore()
|
||||
const state = ref<BeatSyncDetectorState>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const beatsHistory = ref<Array<{
|
||||
@@ -21,9 +29,7 @@ const beatsHistory = ref<Array<{
|
||||
|
||||
const parameters = ref<AnalyserWorkletParameters>({ ...DEFAULT_ANALYSER_WORKLET_PARAMS })
|
||||
|
||||
watchEffect(() => {
|
||||
beatSyncStore.updateParameters(parameters.value)
|
||||
})
|
||||
watch<AnalyserWorkletParameters>(parameters, newParameters => updateBeatSyncParameters(toRaw(newParameters)), { deep: true })
|
||||
|
||||
function normalizeEnergy(energy: number) {
|
||||
const base = 2
|
||||
@@ -32,19 +38,23 @@ function normalizeEnergy(energy: number) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const onBeat = ({ energy }: AnalyserBeatEvent) => {
|
||||
beatsHistory.value.unshift({
|
||||
id: nanoid(),
|
||||
energy,
|
||||
normalizedEnergy: normalizeEnergy(energy),
|
||||
})
|
||||
}
|
||||
getBeatSyncState().then(initialState => state.value = initialState)
|
||||
|
||||
beatSyncStore.on('beat', onBeat)
|
||||
const removeHandlerFns = [
|
||||
listenBeatSyncStateChange((newState) => {
|
||||
state.value = { ...newState }
|
||||
}),
|
||||
listenBeatSyncBeatSignal(({ energy }) => {
|
||||
beatsHistory.value.unshift({
|
||||
id: nanoid(),
|
||||
energy,
|
||||
normalizedEnergy: normalizeEnergy(energy),
|
||||
})
|
||||
}),
|
||||
]
|
||||
|
||||
onUnmounted(() => {
|
||||
beatSyncStore.off('beat', onBeat)
|
||||
})
|
||||
const removeHandlers = () => removeHandlerFns.forEach(fn => fn())
|
||||
onUnmounted(() => removeHandlers())
|
||||
})
|
||||
|
||||
function onRippleEnter(el: Element, done: () => void) {
|
||||
@@ -93,14 +103,14 @@ function resetDefaultParameters() {
|
||||
</div>
|
||||
|
||||
<div max-w-full flex="~ row gap-4 wrap">
|
||||
<template v-if="beatSyncStore.isActive">
|
||||
<Button @click="beatSyncStore.stop">
|
||||
<template v-if="state?.isActive">
|
||||
<Button @click="toggleBeatSync(false)">
|
||||
{{ t('settings.pages.modules.beat_sync.sections.audio_source.actions.stop') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Button @click="beatSyncStore.startFromScreenCapture">
|
||||
<Button @click="toggleBeatSync(true)">
|
||||
{{ t('settings.pages.modules.beat_sync.sections.audio_source.actions.start_screen_capture') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./beat-sync": "./src/beat-sync/index.ts",
|
||||
"./beat-sync/eventa": "./src/beat-sync/eventa.ts",
|
||||
"./beat-sync/browser": "./src/beat-sync/browser/index.ts",
|
||||
"./electron-renderer": "./src/electron-renderer.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import type { EventContext } from '@moeru/eventa'
|
||||
import type { Analyser, AnalyserBeatEvent, AnalyserWorkletParameters } from '@nekopaw/tempora'
|
||||
|
||||
import type { BeatSyncDetectorEventMap, BeatSyncDetectorState } from '../types'
|
||||
|
||||
import analyserWorklet from '@nekopaw/tempora/worklet?url'
|
||||
|
||||
import { defineInvoke, defineInvokeHandler } from '@moeru/eventa'
|
||||
import { startAnalyser as startTemporaAnalyser } from '@nekopaw/tempora'
|
||||
|
||||
import { isStageTamagotchi, isStageWeb, StageEnvironment } from '../../environment'
|
||||
import {
|
||||
beatSyncBeatSignaledInvokeEventa,
|
||||
beatSyncGetStateInvokeEventa,
|
||||
beatSyncStateChangedInvokeEventa,
|
||||
beatSyncToggleInvokeEventa,
|
||||
beatSyncUpdateParametersInvokeEventa,
|
||||
} from '../eventa'
|
||||
import {
|
||||
createContext,
|
||||
} from './eventa'
|
||||
|
||||
export interface BeatSyncDetector {
|
||||
start: (createSource: (context: AudioContext) => Promise<AudioNode>) => Promise<void>
|
||||
updateParameters: (params: Partial<AnalyserWorkletParameters>) => void
|
||||
startScreenCapture: () => Promise<void>
|
||||
stop: () => void
|
||||
on: <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => () => void
|
||||
off: <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => void
|
||||
readonly state: BeatSyncDetectorState
|
||||
readonly context: AudioContext | undefined
|
||||
readonly analyser: Analyser | undefined
|
||||
readonly source: AudioNode | undefined
|
||||
}
|
||||
|
||||
export type CreateBeatSyncDetectorOptions = |
|
||||
{
|
||||
env: StageEnvironment.Tamagotchi
|
||||
enableLoopbackAudio: () => Promise<any>
|
||||
disableLoopbackAudio: () => Promise<any>
|
||||
}
|
||||
| {
|
||||
env: StageEnvironment.Web
|
||||
}
|
||||
|
||||
export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions): BeatSyncDetector {
|
||||
let context: AudioContext | undefined
|
||||
let analyser: Analyser | undefined
|
||||
let source: AudioNode | undefined
|
||||
const state = {
|
||||
isActive: false,
|
||||
}
|
||||
|
||||
let stopSource: (() => void) | undefined
|
||||
|
||||
const listeners: { [K in keyof BeatSyncDetectorEventMap]: Array<(...args: any) => void> } = {
|
||||
stateChange: [],
|
||||
beat: [],
|
||||
}
|
||||
|
||||
const emit = <E extends keyof BeatSyncDetectorEventMap>(event: E, ...args: Parameters<BeatSyncDetectorEventMap[E]>) => {
|
||||
listeners[event].forEach(listener => listener(...args))
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
if (!state.isActive)
|
||||
return
|
||||
|
||||
state.isActive = false
|
||||
emit('stateChange', state)
|
||||
stopSource?.()
|
||||
stopSource = undefined
|
||||
|
||||
source?.disconnect()
|
||||
source = undefined
|
||||
|
||||
analyser?.stop()
|
||||
analyser = undefined
|
||||
|
||||
context?.close()
|
||||
context = undefined
|
||||
}
|
||||
|
||||
const start = async (createSource: (context: AudioContext) => Promise<AudioNode>) => {
|
||||
stop()
|
||||
|
||||
context = new AudioContext()
|
||||
analyser = await startTemporaAnalyser({
|
||||
context,
|
||||
worklet: analyserWorklet,
|
||||
listeners: {
|
||||
onBeat: e => emit('beat', e),
|
||||
},
|
||||
})
|
||||
|
||||
const node = await createSource(context)
|
||||
node.connect(analyser.workletNode)
|
||||
source = node
|
||||
|
||||
state.isActive = true
|
||||
emit('stateChange', state)
|
||||
}
|
||||
|
||||
const updateParameters = (params: Partial<AnalyserWorkletParameters>) => {
|
||||
analyser?.updateParameters(params)
|
||||
}
|
||||
|
||||
const startScreenCapture = async () => start(async (ctx) => {
|
||||
switch (options.env) {
|
||||
case StageEnvironment.Web: {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
audio: {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
},
|
||||
video: true,
|
||||
})
|
||||
|
||||
if (stream.getAudioTracks().length === 0) {
|
||||
throw new Error('No audio track available in the stream')
|
||||
}
|
||||
|
||||
stream.getAudioTracks().forEach((track) => {
|
||||
let stopCalled = false
|
||||
track.addEventListener('ended', () => {
|
||||
if (stopCalled)
|
||||
return
|
||||
stopCalled = true
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
const node = ctx.createMediaStreamSource(stream)
|
||||
stopSource = () => {
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
case StageEnvironment.Tamagotchi: {
|
||||
await options.enableLoopbackAudio()
|
||||
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: true,
|
||||
})
|
||||
|
||||
const videoTracks = stream.getVideoTracks()
|
||||
|
||||
videoTracks.forEach((track) => {
|
||||
track.stop()
|
||||
stream.removeTrack(track)
|
||||
})
|
||||
|
||||
const node = ctx.createMediaStreamSource(stream)
|
||||
stopSource = () => {
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
options.disableLoopbackAudio()
|
||||
}
|
||||
await options.disableLoopbackAudio()
|
||||
|
||||
return node
|
||||
}
|
||||
default:
|
||||
throw new Error('Failed to start screen capture: Unsupported environment')
|
||||
}
|
||||
})
|
||||
|
||||
const off = <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => {
|
||||
const listenerFns = listeners[event]
|
||||
if (!listenerFns) {
|
||||
throw new Error(`Unknown event: ${event}`)
|
||||
}
|
||||
|
||||
const index = listenerFns.indexOf(listener)
|
||||
if (index !== -1)
|
||||
listenerFns.splice(index, 1)
|
||||
}
|
||||
|
||||
const on = <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => {
|
||||
const listenerFns = listeners[event]
|
||||
if (!listenerFns) {
|
||||
throw new Error(`Unknown event: ${event}`)
|
||||
}
|
||||
listenerFns.push(listener)
|
||||
return () => off(event, listener)
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
updateParameters,
|
||||
startScreenCapture,
|
||||
stop,
|
||||
on,
|
||||
off,
|
||||
|
||||
get state() { return state },
|
||||
get context() { return context },
|
||||
get analyser() { return analyser },
|
||||
get source() { return source },
|
||||
}
|
||||
}
|
||||
|
||||
let detector: BeatSyncDetector | undefined
|
||||
function getDetector() {
|
||||
if (!isStageWeb())
|
||||
throw new Error('getDetector() is only available in Stage Web environment')
|
||||
|
||||
if (!detector)
|
||||
detector = createBeatSyncDetector({ env: StageEnvironment.Web })
|
||||
|
||||
return detector
|
||||
}
|
||||
|
||||
let context: EventContext<any, any> | undefined
|
||||
function getContext() {
|
||||
if (!context)
|
||||
context = createContext()
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export function toggleBeatSync(enabled: boolean) {
|
||||
if (isStageWeb()) {
|
||||
if (enabled) {
|
||||
return getDetector().startScreenCapture()
|
||||
}
|
||||
else {
|
||||
return getDetector().stop()
|
||||
}
|
||||
}
|
||||
|
||||
if (isStageTamagotchi()) {
|
||||
const toggleFn = defineInvoke(getContext(), beatSyncToggleInvokeEventa) as (enabled: boolean) => Promise<void> // TODO: Better type
|
||||
return toggleFn(enabled)
|
||||
}
|
||||
|
||||
throw new Error('Unknown environment for beatSyncToggle()')
|
||||
}
|
||||
|
||||
export async function getBeatSyncState() {
|
||||
if (isStageWeb()) {
|
||||
return getDetector().state
|
||||
}
|
||||
|
||||
if (isStageTamagotchi()) {
|
||||
return defineInvoke(getContext(), beatSyncGetStateInvokeEventa)()
|
||||
}
|
||||
|
||||
throw new Error('Unknown environment for getBeatSyncState()')
|
||||
}
|
||||
|
||||
export function updateBeatSyncParameters(params: Partial<AnalyserWorkletParameters>) {
|
||||
if (isStageWeb()) {
|
||||
return getDetector().updateParameters(params)
|
||||
}
|
||||
|
||||
if (isStageTamagotchi()) {
|
||||
return defineInvoke(getContext(), beatSyncUpdateParametersInvokeEventa)(params)
|
||||
}
|
||||
|
||||
throw new Error('Unknown environment for updateBeatSyncParameters()')
|
||||
}
|
||||
|
||||
export function listenBeatSyncStateChange(listener: (state: BeatSyncDetectorState) => void) {
|
||||
if (isStageWeb()) {
|
||||
return getDetector().on('stateChange', listener)
|
||||
}
|
||||
|
||||
if (isStageTamagotchi()) {
|
||||
return defineInvokeHandler(getContext(), beatSyncStateChangedInvokeEventa, listener)
|
||||
}
|
||||
|
||||
throw new Error('Unknown environment for listenBeatSyncStateChange()')
|
||||
}
|
||||
|
||||
export function listenBeatSyncBeatSignal(listener: (e: AnalyserBeatEvent) => void) {
|
||||
if (isStageWeb()) {
|
||||
return getDetector().on('beat', listener)
|
||||
}
|
||||
|
||||
if (isStageTamagotchi()) {
|
||||
return defineInvokeHandler(getContext(), beatSyncBeatSignaledInvokeEventa, listener)
|
||||
}
|
||||
|
||||
throw new Error('Unknown environment for listenBeatSyncBeatSignal()')
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createContext as createWebContext } from '@moeru/eventa'
|
||||
import { createContext as createElectronRendererContext } from '@moeru/eventa/adapters/electron/renderer'
|
||||
import { isElectronWindow } from '@proj-airi/stage-shared'
|
||||
|
||||
export function createContext() {
|
||||
if (isElectronWindow(window)) {
|
||||
return createElectronRendererContext(window.electron.ipcRenderer).context
|
||||
}
|
||||
else {
|
||||
return createWebContext()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './detector'
|
||||
export * from './eventa'
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { AnalyserBeatEvent } from '@nekopaw/tempora'
|
||||
import type { AnalyserBeatEvent, AnalyserWorkletParameters } from '@nekopaw/tempora'
|
||||
|
||||
import type { BeatSyncDetectorState } from './types'
|
||||
|
||||
import { defineInvokeEventa } from '@moeru/eventa'
|
||||
|
||||
export const beatSyncToggle = defineInvokeEventa<void, boolean>('eventa:event:electron:beat-sync:toggle')
|
||||
export const beatSyncRequestSignalBeat = defineInvokeEventa<void, AnalyserBeatEvent>('eventa:event:electron:beat-sync:request-signal-beat')
|
||||
export const beatSyncSignalBeat = defineInvokeEventa<void, AnalyserBeatEvent>('eventa:event:electron:beat-sync:signal-beat')
|
||||
// Functions
|
||||
export const beatSyncToggleInvokeEventa = defineInvokeEventa<void, boolean>('eventa:invoke:electron:beat-sync:toggle')
|
||||
export const beatSyncGetStateInvokeEventa = defineInvokeEventa<BeatSyncDetectorState>('eventa:invoke:electron:beat-sync:get-state')
|
||||
export const beatSyncUpdateParametersInvokeEventa = defineInvokeEventa<void, Partial<AnalyserWorkletParameters>>('eventa:event:electron:beat-sync:update-parameters')
|
||||
|
||||
// Events
|
||||
export const beatSyncStateChangedInvokeEventa = defineInvokeEventa<void, BeatSyncDetectorState>('eventa:event:electron:beat-sync:state-changed')
|
||||
export const beatSyncBeatSignaledInvokeEventa = defineInvokeEventa<void, AnalyserBeatEvent>('eventa:event:electron:beat-sync:beat-signaled')
|
||||
|
||||
@@ -1,191 +1,2 @@
|
||||
import type { Analyser, AnalyserBeatEvent, AnalyserWorkletParameters } from '@nekopaw/tempora'
|
||||
|
||||
import analyserWorklet from '@nekopaw/tempora/worklet?url'
|
||||
|
||||
import { startAnalyser as startTemporaAnalyser } from '@nekopaw/tempora'
|
||||
|
||||
import { StageEnvironment } from '../environment'
|
||||
|
||||
export interface BeatSyncDetectorEventMap {
|
||||
stateChange: (isActive: boolean) => void
|
||||
beat: (e: AnalyserBeatEvent) => void
|
||||
}
|
||||
|
||||
export interface BeatSyncDetector {
|
||||
start: (createSource: (context: AudioContext) => Promise<AudioNode>) => Promise<void>
|
||||
updateParameters: (params: Partial<AnalyserWorkletParameters>) => void
|
||||
startScreenCapture: () => Promise<void>
|
||||
stop: () => void
|
||||
on: <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => void
|
||||
off: <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => void
|
||||
readonly isActive: boolean
|
||||
readonly context: AudioContext | undefined
|
||||
readonly analyser: Analyser | undefined
|
||||
readonly source: AudioNode | undefined
|
||||
}
|
||||
|
||||
export type CreateBeatSyncDetectorOptions = |
|
||||
{
|
||||
env: StageEnvironment.Tamagotchi
|
||||
enableLoopbackAudio: () => Promise<any>
|
||||
disableLoopbackAudio: () => Promise<any>
|
||||
}
|
||||
| {
|
||||
env: StageEnvironment.Web
|
||||
}
|
||||
|
||||
export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions): BeatSyncDetector {
|
||||
let context: AudioContext | undefined
|
||||
let analyser: Analyser | undefined
|
||||
let source: AudioNode | undefined
|
||||
let isActive = false
|
||||
|
||||
let stopSource: (() => void) | undefined
|
||||
|
||||
const listeners: { [K in keyof BeatSyncDetectorEventMap]: Array<(...args: any) => void> } = {
|
||||
stateChange: [],
|
||||
beat: [],
|
||||
}
|
||||
|
||||
const emit = <E extends keyof BeatSyncDetectorEventMap>(event: E, ...args: Parameters<BeatSyncDetectorEventMap[E]>) => {
|
||||
listeners[event].forEach(listener => listener(...args))
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
if (!isActive)
|
||||
return
|
||||
|
||||
isActive = false
|
||||
emit('stateChange', isActive)
|
||||
stopSource?.()
|
||||
stopSource = undefined
|
||||
|
||||
source?.disconnect()
|
||||
source = undefined
|
||||
|
||||
analyser?.stop()
|
||||
analyser = undefined
|
||||
|
||||
context?.close()
|
||||
context = undefined
|
||||
}
|
||||
|
||||
const start = async (createSource: (context: AudioContext) => Promise<AudioNode>) => {
|
||||
stop()
|
||||
|
||||
context = new AudioContext()
|
||||
analyser = await startTemporaAnalyser({
|
||||
context,
|
||||
worklet: analyserWorklet,
|
||||
listeners: {
|
||||
onBeat: e => emit('beat', e),
|
||||
},
|
||||
})
|
||||
|
||||
const node = await createSource(context)
|
||||
node.connect(analyser.workletNode)
|
||||
source = node
|
||||
|
||||
isActive = true
|
||||
emit('stateChange', isActive)
|
||||
}
|
||||
|
||||
const updateParameters = (params: Partial<AnalyserWorkletParameters>) => {
|
||||
analyser?.updateParameters(params)
|
||||
}
|
||||
|
||||
const startScreenCapture = async () => start(async (ctx) => {
|
||||
switch (options.env) {
|
||||
case StageEnvironment.Web: {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
audio: {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
},
|
||||
video: true,
|
||||
})
|
||||
|
||||
if (stream.getAudioTracks().length === 0) {
|
||||
throw new Error('No audio track available in the stream')
|
||||
}
|
||||
|
||||
stream.getAudioTracks().forEach((track) => {
|
||||
let stopCalled = false
|
||||
track.addEventListener('ended', () => {
|
||||
if (stopCalled)
|
||||
return
|
||||
stopCalled = true
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
const node = ctx.createMediaStreamSource(stream)
|
||||
stopSource = () => {
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
case StageEnvironment.Tamagotchi: {
|
||||
await options.enableLoopbackAudio()
|
||||
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: true,
|
||||
})
|
||||
|
||||
const videoTracks = stream.getVideoTracks()
|
||||
|
||||
videoTracks.forEach((track) => {
|
||||
track.stop()
|
||||
stream.removeTrack(track)
|
||||
})
|
||||
|
||||
const node = ctx.createMediaStreamSource(stream)
|
||||
stopSource = () => {
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
options.disableLoopbackAudio()
|
||||
}
|
||||
await options.disableLoopbackAudio()
|
||||
|
||||
return node
|
||||
}
|
||||
default:
|
||||
throw new Error('Failed to start screen capture: Unsupported environment')
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
start,
|
||||
updateParameters,
|
||||
startScreenCapture,
|
||||
stop,
|
||||
on: <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => {
|
||||
switch (event) {
|
||||
case 'beat':
|
||||
listeners.beat.push(listener)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown event: ${event}`)
|
||||
}
|
||||
},
|
||||
off: <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => {
|
||||
switch (event) {
|
||||
case 'beat': {
|
||||
const index = listeners.beat.indexOf(listener)
|
||||
if (index !== -1)
|
||||
listeners.beat.splice(index, 1)
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown event: ${event}`)
|
||||
}
|
||||
},
|
||||
|
||||
get isActive() { return isActive },
|
||||
get context() { return context },
|
||||
get analyser() { return analyser },
|
||||
get source() { return source },
|
||||
}
|
||||
}
|
||||
export * from './eventa'
|
||||
export * from './types'
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { AnalyserBeatEvent } from '@nekopaw/tempora'
|
||||
|
||||
export interface BeatSyncDetectorState {
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface BeatSyncDetectorEventMap {
|
||||
stateChange: (state: BeatSyncDetectorState) => void
|
||||
beat: (e: AnalyserBeatEvent) => void
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './beat-sync'
|
||||
export * from './environment'
|
||||
export * from './url'
|
||||
export * from './window'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { Application } from '@pixi/app'
|
||||
import type { Cubism4InternalModel, InternalModel } from 'pixi-live2d-display/cubism4'
|
||||
|
||||
import { listenBeatSyncBeatSignal } from '@proj-airi/stage-shared/beat-sync/browser'
|
||||
import { useTheme } from '@proj-airi/ui'
|
||||
import { breakpointsTailwind, until, useBreakpoints, useDebounceFn } from '@vueuse/core'
|
||||
import { formatHex } from 'culori'
|
||||
@@ -12,7 +13,6 @@ import { computed, onMounted, onUnmounted, ref, shallowRef, toRef, watch } from
|
||||
|
||||
import { useLive2DIdleEyeFocus } from '../../../composables/live2d'
|
||||
import { Emotion, EmotionNeutralMotionName } from '../../../constants/emotions'
|
||||
import { useBeatSyncStore } from '../../../stores/beat-sync'
|
||||
import { useLive2d } from '../../../stores/live2d'
|
||||
import { useSettings } from '../../../stores/settings'
|
||||
|
||||
@@ -660,19 +660,14 @@ watch(focusAt, (value) => {
|
||||
model.value.focus(value.x, value.y)
|
||||
})
|
||||
|
||||
const beatSyncStore = useBeatSyncStore()
|
||||
|
||||
onMounted(() => {
|
||||
const onBeat = () => {
|
||||
beatSyncTargetY.value = Math.max(-5, Math.min(5, (beatSyncTargetY.value < 0 ? 10 : -10) * (0.5 + Math.random() * 0.3)))
|
||||
beatSyncTargetZ.value = Math.max(-5, Math.min(5, (beatSyncTargetZ.value < 0 ? 10 : -10) * (0.5 + Math.random() * 0.3)))
|
||||
}
|
||||
|
||||
beatSyncStore.on('beat', onBeat)
|
||||
|
||||
onUnmounted(() => {
|
||||
beatSyncStore.off('beat', onBeat)
|
||||
})
|
||||
const removeListener = listenBeatSyncBeatSignal(() => onBeat())
|
||||
onUnmounted(() => removeListener())
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { computed } from 'vue'
|
||||
import type { BeatSyncDetectorState } from '@proj-airi/stage-shared/beat-sync'
|
||||
|
||||
import { getBeatSyncState, listenBeatSyncStateChange } from '@proj-airi/stage-shared/beat-sync/browser'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useBeatSyncStore } from '../stores/beat-sync'
|
||||
import { useConsciousnessStore } from '../stores/modules/consciousness'
|
||||
import { useDiscordStore } from '../stores/modules/discord'
|
||||
import { useFactorioStore } from '../stores/modules/gaming-factorio'
|
||||
@@ -31,7 +33,7 @@ export function useModulesList() {
|
||||
const twitterStore = useTwitterStore()
|
||||
const minecraftStore = useMinecraftStore()
|
||||
const factorioStore = useFactorioStore()
|
||||
const beatSyncStore = useBeatSyncStore()
|
||||
const beatSyncState = ref<BeatSyncDetectorState>()
|
||||
|
||||
const modulesList = computed<Module[]>(() => [
|
||||
{
|
||||
@@ -138,7 +140,7 @@ export function useModulesList() {
|
||||
description: t('settings.pages.modules.beat_sync.description'),
|
||||
icon: 'i-solar:music-notes-bold-duotone',
|
||||
to: '/settings/modules/beat-sync',
|
||||
configured: beatSyncStore.isActive,
|
||||
configured: beatSyncState.value?.isActive ?? false,
|
||||
category: 'essential',
|
||||
},
|
||||
])
|
||||
@@ -161,6 +163,13 @@ export function useModulesList() {
|
||||
gaming: t('settings.pages.modules.categories.gaming'),
|
||||
}))
|
||||
|
||||
// TODO(Makito): We can make this a reactive value from a synthetic store.
|
||||
onMounted(() => {
|
||||
getBeatSyncState().then(initialState => beatSyncState.value = initialState)
|
||||
const removeListener = listenBeatSyncStateChange(newState => beatSyncState.value = { ...newState })
|
||||
onUnmounted(() => removeListener())
|
||||
})
|
||||
|
||||
return {
|
||||
modulesList,
|
||||
categorizedModules,
|
||||
|
||||
Generated
+6
@@ -567,6 +567,9 @@ importers:
|
||||
'@intlify/unplugin-vue-i18n':
|
||||
specifier: ^11.0.1
|
||||
version: 11.0.1(@vue/compiler-dom@3.5.25)(eslint@9.39.1(jiti@2.6.1))(rollup@4.53.2)(typescript@5.9.3)(vue-i18n@11.1.12(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))
|
||||
'@nekopaw/tempora':
|
||||
specifier: 0.3.1-alpha.1
|
||||
version: 0.3.1-alpha.1
|
||||
'@proj-airi/lobe-icons':
|
||||
specifier: ^1.0.14
|
||||
version: 1.0.14
|
||||
@@ -1328,6 +1331,9 @@ importers:
|
||||
specifier: ^4.6.3
|
||||
version: 4.6.3(vue@3.5.25(typescript@5.9.3))
|
||||
devDependencies:
|
||||
'@proj-airi/stage-shared':
|
||||
specifier: workspace:^
|
||||
version: link:../stage-shared
|
||||
'@types/three':
|
||||
specifier: ^0.181.0
|
||||
version: 0.181.0
|
||||
|
||||
Reference in New Issue
Block a user