Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"oxlint": "^1.59.0",
"posthog-js": "catalog:",
"publint": "^0.3.18",
"realtime-bpm-analyzer": "^3.0.0",
"rollup": "^4.60.1",
"simple-git-hooks": "^2.13.1",
"sponsorkit": "^17.1.0",
Expand Down
46 changes: 28 additions & 18 deletions packages/audio/src/audio-context/processor.worklet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ class ResamplingAudioWorkletProcessor extends AudioWorkletProcessor {

this.options = {
inputSampleRate: options.processorOptions?.inputSampleRate || 44100,
outputSampleRate: options.processorOptions?.outputSampleRate || 16000,
outputSampleRate: options.processorOptions?.outputSampleRate || 44100,
channels: options.processorOptions?.channels || 1,
converterType: options.processorOptions?.converterType || ConverterType.SRC_SINC_MEDIUM_QUALITY,
bufferSize: options.processorOptions?.bufferSize || 4096,
bufferSize: options.processorOptions?.bufferSize || 16384,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Increasing the bufferSize to 16384 increases the memory allocated for inputBuffer and outputBuffer (lines 38-39), but these buffers are not utilized in the process method. If they are indeed redundant, it is recommended to remove them and revert this change to minimize memory overhead.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the increase in necessary due to the increase in sample rate, which in turn allows for a broader spectrum to be recognized by the BPM detector

}

this.bufferSize = this.options.bufferSize
Expand Down Expand Up @@ -89,31 +89,44 @@ class ResamplingAudioWorkletProcessor extends AudioWorkletProcessor {
await this.initializeConverter()
}
}

private passThrough(input: Float32Array[], output: Float32Array[]) {
for (let channel = 0; channel < output.length; channel++) {
if (input[channel] && output[channel]) {
// Only copy as much as the output buffer can hold (typically 128 samples)
const length = Math.min(input[channel].length, output[channel].length)
output[channel].set(input[channel].subarray(0, length))

// Zero out any remaining space in output if input was shorter
if (length < output[channel].length) {
output[channel].fill(0, length)
}
}
}
}

process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
const input = inputs[0]
const output = outputs[0]

if (!this.isInitialized || !this.converter || !input.length) {
// Pass through if not ready
for (let channel = 0; channel < output.length; channel++) {
if (input[channel]) {
output[channel].set(input[channel])
}
}
this.passThrough(input, output)
return true
}

try {
// Process each channel
for (let channel = 0; channel < Math.min(input.length, this.options.channels); channel++) {
const inputData = input[channel]

if (inputData && inputData.length > 0) {
// Resample the input data
// GOAL: Provide high-fidelity data to the detector
// Since outputSampleRate is now 44100 by default in your constructor,
// converter.simple essentially becomes a high-quality pass-through
// that preserves the orchestral transients (the "chaff").
const resampledData = this.converter.simple(inputData)

// Send resampled data to main thread
// We keep the message structure EXACTLY as it was.
// The detector receives higher quality audio to "absorb".
this.port.postMessage({
type: 'audioData',
channel,
Expand All @@ -123,15 +136,12 @@ class ResamplingAudioWorkletProcessor extends AudioWorkletProcessor {
timestamp: currentTime,
})

// Copy to output (you might want to buffer this properly for different sample rates)
// Copy to hardware output
if (output[channel]) {
const copyLength = Math.min(resampledData.length, output[channel].length)
for (let i = 0; i < copyLength; i++) {
output[channel][i] = resampledData[i]
}
// Zero-pad remaining
for (let i = copyLength; i < output[channel].length; i++) {
output[channel][i] = 0
output[channel].set(resampledData.subarray(0, copyLength))
if (copyLength < output[channel].length) {
output[channel].fill(0, copyLength)
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/stage-shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@moeru/eventa": "catalog:",
"@nekopaw/tempora": "catalog:",
"@proj-airi/electron-screen-capture": "workspace:^",
"@types/audioworklet": "catalog:"
"@types/audioworklet": "catalog:",
"realtime-bpm-analyzer": "^3.3.0",
}
}
95 changes: 94 additions & 1 deletion packages/stage-shared/src/beat-sync/detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,13 @@ import {
createContext,
} from './eventa'

// Add to the top of detector.ts
import { RealTimeBpmAnalyzer } from 'realtime-bpm-analyzer';
Comment thread
MintKeyphase marked this conversation as resolved.

export const inputAnalyserFFTSize = 1024



export interface BeatSyncDetector {
start: (createSource: (context: AudioContext) => Promise<AudioNode>) => Promise<void>
updateParameters: (params: Partial<AnalyserWorkletParameters>) => void
Expand Down Expand Up @@ -54,12 +59,45 @@ export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions):

let inputAnalyserNode: AnalyserNode | undefined
let inputAnalyserBuffer: Uint8Array<ArrayBuffer> | undefined
let beatInterval: any | undefined
const rhythmAnalyzer = new (RealTimeBpmAnalyzer as any)({
continuousAnalysis: true,
stabilizationTime: 1000,
importSharedOptions: true,
sampleRate: 44100
})

const listeners: { [K in keyof BeatSyncDetectorEventMap]: Array<(...args: any) => void> } = {
stateChange: [],
beat: [],
}

const syncMetronome = (bpm: number, isLocked: boolean) => {
if (beatInterval) clearInterval(beatInterval)

const ms = (60 / bpm) * 1000
const intervalInSeconds = 60 / bpm

beatInterval = setInterval(() => {
// Calculate real energy from the current buffer so jumps stay reactive
let currentEnergy = 0
if (inputAnalyserNode && inputAnalyserBuffer) {
inputAnalyserNode.getByteFrequencyData(inputAnalyserBuffer)
// Simple RMS-like average of the current frequency buffer
const sum = inputAnalyserBuffer.reduce((a, b) => a + b, 0)
currentEnergy = sum / inputAnalyserBuffer.length / 255
}

const beatEvent: AnalyserBeatEvent = {
energy: currentEnergy || 0.5, // Fallback to 0.5 if no audio
interval: intervalInSeconds // Time since last beat in seconds
}

emit('beat', beatEvent)
}, ms)
}


const emit = <E extends keyof BeatSyncDetectorEventMap>(event: E, ...args: Parameters<BeatSyncDetectorEventMap[E]>) => {
listeners[event].forEach(listener => listener(...args))
}
Expand All @@ -79,6 +117,9 @@ export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions):
inputAnalyserBuffer = undefined
}

clearInterval(beatInterval)
beatInterval = undefined

source?.disconnect()
source = undefined

Expand All @@ -97,10 +138,31 @@ export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions):
context,
worklet: analyserWorklet,
listeners: {
onBeat: e => emit('beat', e),
onBeat: () => {},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep forwarding TempoRA beat callbacks

Changing onBeat to a no-op removes the existing beat signal path from startTemporaAnalyser, so beat listeners now depend entirely on the new realtime-BPM path producing lock messages. In scenarios where that path is delayed or fails (e.g., no compatible audioData messages, analyzer error), beat sync emits nothing and appears broken, whereas the previous implementation still delivered beat events.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fallback emits 40 BPM beat events

},
})



analyser.workletNode.port.onmessage = (e) => {
if (e.data.type === 'audioData') {
// According to docs/v3, we pass a single options object.
(rhythmAnalyzer as any).analyzeChunck({
channelData: e.data.data,
audioSampleRate: e.data.outputSampleRate,
bufferSize: e.data.data.length,
postMessage: (message: any) => {
// The library emits 'BPM' or 'BPM_STABLE' in the .message property
const isBpmEvent = message.message === 'BPM' || message.message === 'BPM_STABLE';

if (isBpmEvent && message.data.bpm) {
lockBpm = message.data.bpm;
syncMetronome(lockBpm, true);
}
}
});
}
};
const node = await createSource(context)

inputAnalyserNode = context.createAnalyser()
Expand Down Expand Up @@ -220,8 +282,38 @@ export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions):
inputAnalyserNode?.getByteFrequencyData(inputAnalyserBuffer!)
return inputAnalyserBuffer!
}


};

interface BpmEvent extends Event {
detail: {
bpm: number;
threshold: number;
uncertainty: number;
};
}

(rhythmAnalyzer as any).onNext = (bpm: number, threshold: number) => {
lockBpm = bpm;
if (lockBpm > 0) {
syncMetronome(lockBpm, true);
}
};
// ----------------------

return {
start: async () => {
// ✅ START THE FALLBACK ONLY WHEN START IS CALLED
syncMetronome(40, false);

state.isActive = true;
// ... rest of your port.onmessage and wiring logic ...
},
stop: () => {
state.isActive = false;
// Ensure you stop the timer here too!
}
start,
updateParameters,
startScreenCapture,
Expand Down Expand Up @@ -333,3 +425,4 @@ export async function getBeatSyncInputByteFrequencyData() {

throw new Error('Unknown environment for getBeatSyncInputByteFrequencyData()')
}

Loading