Skip to content

Commit 63a8430

Browse files
committed
feat(stage-shared): make beat-sync store framework-agnostic as beat-detector
1 parent cfba54a commit 63a8430

File tree

5 files changed

+200
-1
lines changed

5 files changed

+200
-1
lines changed

cspell.config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ words:
176176
- ndarray
177177
- Neko
178178
- nekomeowww
179+
- nekopaw
179180
- neuri
180181
- Neuro
181182
- Neuro-sama

packages/stage-shared/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"typecheck": "tsc --noEmit"
2323
},
2424
"devDependencies": {
25-
"@electron-toolkit/preload": "^3.0.2"
25+
"@electron-toolkit/preload": "^3.0.2",
26+
"@nekopaw/tempora": "0.3.1-alpha.1"
2627
}
2728
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import type { Analyser, AnalyserBeatEvent, AnalyserWorkletParameters } from '@nekopaw/tempora'
2+
3+
import analyserWorklet from '@nekopaw/tempora/worklet?url'
4+
5+
import { startAnalyser as startTemporaAnalyser } from '@nekopaw/tempora'
6+
7+
import { StageEnvironment } from './environment'
8+
9+
export interface DetectorEventMap {
10+
stateChange: (isActive: boolean) => void
11+
beat: (e: AnalyserBeatEvent) => void
12+
}
13+
14+
interface Detector {
15+
start: (createSource: (context: AudioContext) => Promise<AudioNode>) => Promise<void>
16+
updateParameters: (params: Partial<AnalyserWorkletParameters>) => void
17+
startScreenCapture: () => Promise<void>
18+
stop: () => void
19+
on: <E extends keyof DetectorEventMap>(event: E, listener: DetectorEventMap[E]) => void
20+
off: <E extends keyof DetectorEventMap>(event: E, listener: DetectorEventMap[E]) => void
21+
readonly isActive: boolean
22+
readonly context: AudioContext | undefined
23+
readonly analyser: Analyser | undefined
24+
readonly source: AudioNode | undefined
25+
}
26+
27+
type CreateDetectorOptions = |
28+
{
29+
env: StageEnvironment.Tamagotchi
30+
enableLoopbackAudio: () => Promise<any>
31+
disableLoopbackAudio: () => Promise<any>
32+
}
33+
| {
34+
env: StageEnvironment.Web
35+
}
36+
37+
export function createDetector(options: CreateDetectorOptions): Detector {
38+
let context: AudioContext | undefined
39+
let analyser: Analyser | undefined
40+
let source: AudioNode | undefined
41+
let isActive = false
42+
43+
let stopSource: (() => void) | undefined
44+
45+
const listeners: { [K in keyof DetectorEventMap]: Array<(...args: any) => void> } = {
46+
stateChange: [],
47+
beat: [],
48+
}
49+
50+
const emit = <E extends keyof DetectorEventMap>(event: E, ...args: Parameters<DetectorEventMap[E]>) => {
51+
listeners[event].forEach(listener => listener(...args))
52+
}
53+
54+
const stop = () => {
55+
if (!isActive)
56+
return
57+
58+
isActive = false
59+
emit('stateChange', isActive)
60+
stopSource?.()
61+
stopSource = undefined
62+
63+
source?.disconnect()
64+
source = undefined
65+
66+
analyser?.stop()
67+
analyser = undefined
68+
69+
context?.close()
70+
context = undefined
71+
}
72+
73+
const start = async (createSource: (context: AudioContext) => Promise<AudioNode>) => {
74+
stop()
75+
76+
context = new AudioContext()
77+
analyser = await startTemporaAnalyser({
78+
context,
79+
worklet: analyserWorklet,
80+
listeners: {
81+
onBeat: e => emit('beat', e),
82+
},
83+
})
84+
85+
const node = await createSource(context)
86+
node.connect(analyser.workletNode)
87+
source = node
88+
89+
isActive = true
90+
emit('stateChange', isActive)
91+
}
92+
93+
const updateParameters = (params: Partial<AnalyserWorkletParameters>) => {
94+
analyser?.updateParameters(params)
95+
}
96+
97+
const startScreenCapture = async () => start(async (ctx) => {
98+
switch (options.env) {
99+
case StageEnvironment.Web: {
100+
const stream = await navigator.mediaDevices.getDisplayMedia({
101+
audio: {
102+
echoCancellation: false,
103+
noiseSuppression: false,
104+
autoGainControl: false,
105+
},
106+
video: true,
107+
})
108+
109+
if (stream.getAudioTracks().length === 0) {
110+
throw new Error('No audio track available in the stream')
111+
}
112+
113+
stream.getAudioTracks().forEach((track) => {
114+
let stopCalled = false
115+
track.addEventListener('ended', () => {
116+
if (stopCalled)
117+
return
118+
stopCalled = true
119+
stop()
120+
})
121+
})
122+
123+
const node = ctx.createMediaStreamSource(stream)
124+
stopSource = () => {
125+
stream.getTracks().forEach(track => track.stop())
126+
}
127+
128+
return node
129+
}
130+
case StageEnvironment.Tamagotchi: {
131+
await options.enableLoopbackAudio()
132+
133+
const stream = await navigator.mediaDevices.getDisplayMedia({
134+
video: true,
135+
audio: true,
136+
})
137+
138+
const videoTracks = stream.getVideoTracks()
139+
140+
videoTracks.forEach((track) => {
141+
track.stop()
142+
stream.removeTrack(track)
143+
})
144+
145+
const node = ctx.createMediaStreamSource(stream)
146+
stopSource = () => {
147+
stream.getTracks().forEach(track => track.stop())
148+
options.disableLoopbackAudio()
149+
}
150+
await options.disableLoopbackAudio()
151+
152+
return node
153+
}
154+
default:
155+
throw new Error('Failed to start screen capture: Unsupported environment')
156+
}
157+
})
158+
159+
return {
160+
start,
161+
updateParameters,
162+
startScreenCapture,
163+
stop,
164+
on: <E extends keyof DetectorEventMap>(event: E, listener: DetectorEventMap[E]) => {
165+
switch (event) {
166+
case 'beat':
167+
listeners.beat.push(listener)
168+
break
169+
default:
170+
throw new Error(`Unknown event: ${event}`)
171+
}
172+
},
173+
off: (event, listener) => {
174+
switch (event) {
175+
case 'beat': {
176+
const index = listeners.beat.indexOf(listener)
177+
if (index !== -1)
178+
listeners.beat.splice(index, 1)
179+
break
180+
}
181+
default:
182+
throw new Error(`Unknown event: ${event}`)
183+
}
184+
},
185+
186+
get isActive() { return isActive },
187+
get context() { return context },
188+
get analyser() { return analyser },
189+
get source() { return source },
190+
}
191+
}

packages/stage-shared/src/environment.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
export enum StageEnvironment {
2+
Web = 'web',
3+
Tamagotchi = 'tamagotchi',
4+
}
5+
16
export function isStageWeb(): boolean {
27
return !import.meta.env.RUNTIME_ENVIRONMENT || import.meta.env.RUNTIME_ENVIRONMENT === 'browser'
38
}

packages/stage-shared/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './beat-sync'
12
export * from './environment'
23
export * from './url'
34
export * from './window'

0 commit comments

Comments
 (0)