From 30316149149a2e1f13fc56e7f2ecc185284b4a9f Mon Sep 17 00:00:00 2001 From: b1rdmania <102524336+b1rdmania@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:17:02 +0000 Subject: [PATCH] Add progress bar and seek controls to Motif player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add interactive progress bar with visual fill indicator - Show current time and total duration - Enable seeking/scrubbing through the synthesis - Improve Motif player UX with cleaner layout - Add progress tracking that updates 10x per second - Format time display as MM:SS - Progress bar appears when playback starts - Seeking updates event indices for smooth playback 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- index.html | 78 +++++++++++++++++++++++++++++++- src/core/MotifEngine.ts | 27 +++++++++++ src/main.ts | 63 +++++++++++++++++++++++++- src/synthesis/SynthesisEngine.ts | 59 ++++++++++++++++++++++++ 4 files changed, 224 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index 79eef0b..14bedab 100644 --- a/index.html +++ b/index.html @@ -284,6 +284,69 @@ margin: 0; } + .motif-progress-container { + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid rgba(255, 170, 68, 0.15); + } + .progress-time { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + font-size: 0.85em; + color: #8f8f8f; + } + .progress-bar-wrapper { + position: relative; + height: 32px; + display: flex; + align-items: center; + } + .progress-bar { + width: 100%; + height: 8px; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + outline: none; + cursor: pointer; + position: relative; + z-index: 2; + } + .progress-bar::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #ffaa44; + cursor: pointer; + box-shadow: 0 0 10px rgba(255, 170, 68, 0.5); + } + .progress-bar::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #ffaa44; + cursor: pointer; + border: none; + box-shadow: 0 0 10px rgba(255, 170, 68, 0.5); + } + .progress-fill { + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + height: 8px; + background: linear-gradient(90deg, #ffaa44, rgba(255, 170, 68, 0.6)); + border-radius: 4px; + pointer-events: none; + z-index: 1; + width: 0%; + transition: width 0.1s linear; + } + .engine-selector { margin-top: 10px; font-size: 0.9em; @@ -396,8 +459,8 @@
-

Generate a variation

-

Same MIDI, new energy. This is where “more ambient / more dancey / more ominous” will live.

+

Procedural Synthesis

+

Web Audio oscillators with dynamic filtering and envelopes.

@@ -408,6 +471,17 @@
+ + diff --git a/src/core/MotifEngine.ts b/src/core/MotifEngine.ts index f953ff7..8c87e3f 100644 --- a/src/core/MotifEngine.ts +++ b/src/core/MotifEngine.ts @@ -134,6 +134,33 @@ export class MotifEngine { } } + seek(progress: number): void { + if (this.synthesisEngine) { + this.synthesisEngine.seek(progress); + } + } + + getProgress(): number { + if (this.synthesisEngine) { + return this.synthesisEngine.getProgress(); + } + return 0; + } + + getCurrentTime(): number { + if (this.synthesisEngine) { + return this.synthesisEngine.getCurrentTime(); + } + return 0; + } + + getDuration(): number { + if (this.synthesisEngine) { + return this.synthesisEngine.getDuration(); + } + return 0; + } + private generateSyntheticMIDI(songName: string): NoteEvent[] { // Generate procedural MIDI based on song name hash const hash = this.simpleHash(songName); diff --git a/src/main.ts b/src/main.ts index 591914f..88dd780 100644 --- a/src/main.ts +++ b/src/main.ts @@ -46,9 +46,15 @@ class MotifApp { private motifBtn!: HTMLButtonElement; private motifStopBtn!: HTMLButtonElement; private motifVolumeSlider!: HTMLInputElement; + private motifProgressContainer!: HTMLElement; + private motifProgressBar!: HTMLInputElement; + private motifProgressFill!: HTMLElement; + private motifCurrentTime!: HTMLElement; + private motifDuration!: HTMLElement; + private motifProgressInterval: number | null = null; private nextResultBtn!: HTMLButtonElement; - + private searchResults: any[] = []; private selectedResultIndex = 0; private currentMIDI: { events: NoteEvent[], metadata: any } | null = null; @@ -99,6 +105,11 @@ class MotifApp { this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement; this.motifStopBtn = document.getElementById('motifStopBtn') as HTMLButtonElement; this.motifVolumeSlider = document.getElementById('motifVolume') as HTMLInputElement; + this.motifProgressContainer = document.getElementById('motifProgressContainer')!; + this.motifProgressBar = document.getElementById('motifProgressBar') as HTMLInputElement; + this.motifProgressFill = document.getElementById('motifProgressFill')!; + this.motifCurrentTime = document.getElementById('motifCurrentTime')!; + this.motifDuration = document.getElementById('motifDuration')!; this.nextResultBtn = document.getElementById('nextResultBtn') as HTMLButtonElement; } @@ -143,6 +154,10 @@ class MotifApp { const volume = parseFloat((e.target as HTMLInputElement).value); this.motifEngine.setVolume(volume); }); + this.motifProgressBar.addEventListener('input', (e) => { + const progress = parseFloat((e.target as HTMLInputElement).value) / 100; + this.handleMotifSeek(progress); + }); this.nextResultBtn.addEventListener('click', () => this.handleNextResult()); } @@ -361,6 +376,17 @@ class MotifApp { await this.motifEngine.play(); this.motifStopBtn.disabled = false; + + // Show progress bar and set duration + this.motifProgressContainer.style.display = 'block'; + const duration = this.motifEngine.getDuration(); + this.motifDuration.textContent = this.formatTime(duration); + this.motifProgressBar.value = '0'; + this.motifProgressFill.style.width = '0%'; + + // Start progress updates + this.startMotifProgressUpdates(); + this.updateStatus('Playing Motif synthesis...'); console.log('Motif playback started successfully'); } catch (error) { @@ -374,9 +400,44 @@ class MotifApp { this.motifEngine.stop(); this.motifBtn.disabled = false; this.motifStopBtn.disabled = true; + this.stopMotifProgressUpdates(); this.updateStatus('Motif synthesis stopped.'); } + private handleMotifSeek(progress: number): void { + this.motifEngine.seek(progress); + this.updateMotifProgress(); + } + + private startMotifProgressUpdates(): void { + this.stopMotifProgressUpdates(); + this.motifProgressInterval = window.setInterval(() => { + this.updateMotifProgress(); + }, 100); // Update 10 times per second + } + + private stopMotifProgressUpdates(): void { + if (this.motifProgressInterval !== null) { + clearInterval(this.motifProgressInterval); + this.motifProgressInterval = null; + } + } + + private updateMotifProgress(): void { + const progress = this.motifEngine.getProgress(); + const currentTime = this.motifEngine.getCurrentTime(); + + this.motifProgressBar.value = (progress * 100).toString(); + this.motifProgressFill.style.width = `${progress * 100}%`; + this.motifCurrentTime.textContent = this.formatTime(currentTime); + } + + private formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + } + private handleNextResult(): void { const nextIndex = (this.selectedResultIndex + 1) % this.searchResults.length; this.selectResult(nextIndex); diff --git a/src/synthesis/SynthesisEngine.ts b/src/synthesis/SynthesisEngine.ts index d2f5c0f..91d7a4e 100644 --- a/src/synthesis/SynthesisEngine.ts +++ b/src/synthesis/SynthesisEngine.ts @@ -71,6 +71,65 @@ export class SynthesisEngine { this.masterGain.gain.value = Math.max(0, Math.min(1, volume)); } + seek(progress: number): void { + if (!this.isPlaying) return; + + // Calculate the new start time based on progress + const duration = this.getDuration(); + const targetTime = progress * duration; + + // Adjust startTime to effectively seek to the target position + this.startTime = this.audioContext.currentTime - targetTime; + + // Reset event indices to the appropriate position + for (const [role, assignment] of this.roleAssignments) { + const events = assignment.events; + if (events.length > 0) { + // Find the first event after the target time + let index = 0; + while (index < events.length && events[index].time < targetTime) { + index++; + } + this.nextEventIndex.set(role, index); + } + } + } + + getProgress(): number { + if (!this.isPlaying) return 0; + + const duration = this.getDuration(); + if (duration === 0) return 0; + + const currentTime = this.audioContext.currentTime - this.startTime; + return Math.max(0, Math.min(1, currentTime / duration)); + } + + getCurrentTime(): number { + if (!this.isPlaying) return 0; + return Math.max(0, this.audioContext.currentTime - this.startTime); + } + + getDuration(): number { + let maxDuration = 0; + + for (const assignment of this.roleAssignments.values()) { + if (assignment.events.length > 0) { + const lastEvent = assignment.events[assignment.events.length - 1]; + const eventEnd = lastEvent.time + lastEvent.duration; + maxDuration = Math.max(maxDuration, eventEnd); + } + + if (assignment.chords.length > 0) { + const lastChord = assignment.chords[assignment.chords.length - 1]; + const chordEnd = lastChord.time + lastChord.duration; + maxDuration = Math.max(maxDuration, chordEnd); + } + } + + return maxDuration; + } + private createSynthLayer(role: Role): SynthLayer { const gainNode = this.audioContext.createGain(); const filterNode = this.audioContext.createBiquadFilter();