Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Copy link
Owner

@ptnghia-j ptnghia-j Nov 18, 2025

Choose a reason for hiding this comment

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

@Slavezax Wondering why you remove the backend requirements from the backend? Will manually run and install each package be better?

Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,5 @@ scripts/utilities

__tests__/
__test-results__/
/python_backend
python_backend/requirements.txt
242 changes: 242 additions & 0 deletions public/worklets/metronome-processor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/**
* MetronomeProcessor - AudioWorklet for precise metronome timing
* Runs in a separate thread, immune to UI interactions and main thread blocking
*/
class MetronomeProcessor extends AudioWorkletProcessor {
constructor() {
super();

// State management
this.isPlaying = false;
this.nextClickTime = 0;
this.bpm = 120;
this.clickDuration = 0.06; // 60ms clicks
this.volume = 1.0;
this.volumeBoost = 3.0;
this.beatInterval = 60 / this.bpm;

// Timing and synchronization
this.startTime = 0;
this.pauseTime = 0;
this.currentBeatIndex = 0;
this.beats = [];
this.useBeatsArray = false;

// Click sound generation parameters
this.clickFrequency = 1200;
this.clickAttack = 0.002; // 2ms attack
this.clickDecay = 25; // Exponential decay factor

// Handle messages from main thread
this.port.onmessage = (event) => {
const { type, data } = event.data;

switch (type) {
case 'start':
this.handleStart(data);
break;
case 'stop':
this.handleStop();
break;
case 'seek':
this.handleSeek(data);
break;
case 'updateBPM':
this.updateBPM(data.bpm);
break;
case 'updateVolume':
this.updateVolume(data.volume);
break;
case 'setBeats':
this.setBeats(data.beats);
break;
case 'updateTime':
this.updateCurrentTime(data.time);
break;
}
};
}

handleStart(data) {
this.isPlaying = true;
this.startTime = currentTime;
this.pauseTime = 0;

if (data.bpm) this.bpm = data.bpm;
if (data.volume !== undefined) this.volume = data.volume;
if (data.beats) this.setBeats(data.beats);
if (data.currentTime !== undefined) {
this.seekToTime(data.currentTime);
} else {
this.currentBeatIndex = 0;
this.calculateNextClickTime();
}

this.beatInterval = 60 / this.bpm;

// Send confirmation
this.port.postMessage({ type: 'started' });
}

handleStop() {
this.isPlaying = false;
this.pauseTime = currentTime;
this.port.postMessage({ type: 'stopped' });
}

handleSeek(data) {
if (data.time !== undefined) {
this.seekToTime(data.time);
}
}

seekToTime(targetTime) {
if (this.useBeatsArray && this.beats.length > 0) {
// Find the next beat after the target time
this.currentBeatIndex = 0;
for (let i = 0; i < this.beats.length; i++) {
if (this.beats[i] > targetTime) {
this.currentBeatIndex = i;
break;
}
}

if (this.currentBeatIndex < this.beats.length) {
this.nextClickTime = this.beats[this.currentBeatIndex];
} else {
this.nextClickTime = Infinity; // No more beats
}
} else {
// Calculate beat position based on BPM
const beatsSinceStart = Math.floor(targetTime / this.beatInterval);
this.currentBeatIndex = beatsSinceStart;
this.nextClickTime = (beatsSinceStart + 1) * this.beatInterval;
}
}

updateBPM(bpm) {
if (bpm > 0 && bpm <= 300) {
this.bpm = bpm;
this.beatInterval = 60 / this.bpm;

if (!this.useBeatsArray) {
this.calculateNextClickTime();
}
}
}

updateVolume(volume) {
this.volume = Math.max(0, Math.min(1, volume));
}

setBeats(beats) {
if (Array.isArray(beats) && beats.length > 0) {
// Filter out null values and ensure all are numbers
this.beats = beats.filter(b => typeof b === 'number' && !isNaN(b));
this.useBeatsArray = this.beats.length > 0;

if (this.useBeatsArray) {
this.currentBeatIndex = 0;
this.calculateNextClickTime();
}
} else {
this.beats = [];
this.useBeatsArray = false;
}
}

updateCurrentTime(time) {
// Synchronize with external time source
if (this.isPlaying) {
this.seekToTime(time);
}
}

calculateNextClickTime() {
if (this.useBeatsArray && this.beats.length > 0) {
if (this.currentBeatIndex < this.beats.length) {
this.nextClickTime = this.beats[this.currentBeatIndex];
} else {
this.nextClickTime = Infinity; // No more beats
}
} else if (this.beatInterval > 0) {
this.nextClickTime = (this.currentBeatIndex + 1) * this.beatInterval;
}
}

generateClick(output, startSample, numSamples) {
const effectiveVolume = this.volume * this.volumeBoost;
const attackSamples = Math.floor(this.clickAttack * sampleRate);
const clickSamples = Math.floor(this.clickDuration * sampleRate);

for (let channel = 0; channel < output.length; channel++) {
const outputChannel = output[channel];

for (let i = 0; i < numSamples && i < clickSamples; i++) {
const sampleIndex = startSample + i;
const t = sampleIndex / sampleRate;

// Generate click sound (sine wave)
const phase = 2 * Math.PI * this.clickFrequency * t;
const sample = Math.sin(phase);

// Apply envelope
let envelope;
if (sampleIndex < attackSamples) {
// Attack phase
envelope = sampleIndex / attackSamples;
} else {
// Decay phase
envelope = Math.exp(-t * this.clickDecay);
}

outputChannel[i] = sample * envelope * effectiveVolume * 0.5; // 0.5 to prevent clipping
}
}

// Send click event to main thread
this.port.postMessage({
type: 'click',
beatIndex: this.currentBeatIndex,
time: this.nextClickTime
});

// Move to next beat
this.currentBeatIndex++;
this.calculateNextClickTime();
}

process(inputs, outputs, parameters) {
if (!this.isPlaying || outputs.length === 0) {
return true; // Keep processor alive
}

const output = outputs[0];
const numSamples = output[0].length;

// Clear output buffer first
for (let channel = 0; channel < output.length; channel++) {
output[channel].fill(0);
}

// Check if we need to generate a click in this render quantum
const quantumDuration = numSamples / sampleRate;
const quantumStartTime = currentTime;
const quantumEndTime = quantumStartTime + quantumDuration;

// Check if next click falls within this quantum
if (this.nextClickTime >= quantumStartTime && this.nextClickTime < quantumEndTime) {
// Calculate sample position for the click
const clickOffsetTime = this.nextClickTime - quantumStartTime;
const clickStartSample = Math.floor(clickOffsetTime * sampleRate);

// Generate the click
this.generateClick(output, clickStartSample, numSamples - clickStartSample);
}

return true; // Keep processor alive
}
}

// Register the processor
registerProcessor('metronome-processor', MetronomeProcessor);
Comment on lines +1 to +242

This comment was marked as off-topic.

7 changes: 5 additions & 2 deletions python_backend/blueprints/beats/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def detect_beats():
- audio_path: Alternative to file, path to an existing audio file on the server
- detector: 'beat-transformer', 'madmom', 'librosa', or 'auto' (default)
- force: Set to 'true' to force using requested detector even for large files
- stems_folder: Optional path to folder containing pre-rendered stems (for beat-transformer)

Returns:
- JSON with beat and downbeat information
Expand Down Expand Up @@ -71,7 +72,8 @@ def detect_beats():
result = beat_service.detect_beats(
file_path=file_path,
detector=params['detector'],
force=params['force']
force=params['force'],
stems_folder=params.get('stems_folder')
)
else:
# Use provided audio path
Expand All @@ -83,7 +85,8 @@ def detect_beats():
result = beat_service.detect_beats(
file_path=file_path,
detector=params['detector'],
force=params['force']
force=params['force'],
stems_folder=params.get('stems_folder')
)

# Return result
Expand Down
6 changes: 5 additions & 1 deletion python_backend/blueprints/beats/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,14 @@ def validate_beat_detection_request() -> Tuple[bool, Optional[str], Optional[Fil
if file and file.filename == '':
return False, "No file selected", None, {}

# Get stems_folder parameter if provided
stems_folder = request.form.get('stems_folder')
Comment on lines +45 to +46
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

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

The stems_folder parameter from user input is not validated before being used as a file path. This could lead to path traversal vulnerabilities or access to unauthorized directories. Add validation to ensure:

  1. The path doesn't contain directory traversal sequences (../)
  2. The path is within an allowed directory/whitelist
  3. The directory exists and is readable

Example validation:

if stems_folder:
    # Validate path doesn't contain traversal attempts
    if '..' in stems_folder or not os.path.isabs(stems_folder):
        return False, "Invalid stems folder path", None, {}
    # Additional checks as needed

Copilot uses AI. Check for mistakes.

params = {
'detector': detector,
'force': force,
'audio_path': audio_path
'audio_path': audio_path,
'stems_folder': stems_folder
}

return True, None, file, params
Expand Down
Loading
Loading