Add progress bar and seek controls to Motif player
- 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 <noreply@anthropic.com>
This commit is contained in:
+76
-2
@@ -284,6 +284,69 @@
|
|||||||
margin: 0;
|
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 {
|
.engine-selector {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
@@ -396,8 +459,8 @@
|
|||||||
<div class="motif-stage">
|
<div class="motif-stage">
|
||||||
<div class="motif-grid">
|
<div class="motif-grid">
|
||||||
<div>
|
<div>
|
||||||
<h4 style="margin:0; color:#ffaa44; text-shadow: 0 0 18px rgba(255,170,68,0.15);">Generate a variation</h4>
|
<h4 style="margin:0; color:#ffaa44; text-shadow: 0 0 18px rgba(255,170,68,0.15);">Procedural Synthesis</h4>
|
||||||
<p class="motif-kicker">Same MIDI, new energy. This is where “more ambient / more dancey / more ominous” will live.</p>
|
<p class="motif-kicker">Web Audio oscillators with dynamic filtering and envelopes.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="motif-actions">
|
<div class="motif-actions">
|
||||||
<button id="motifBtn" class="primary" disabled>Generate & Play</button>
|
<button id="motifBtn" class="primary" disabled>Generate & Play</button>
|
||||||
@@ -408,6 +471,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="motif-progress-container" id="motifProgressContainer" style="display: none;">
|
||||||
|
<div class="progress-time">
|
||||||
|
<span id="motifCurrentTime">0:00</span>
|
||||||
|
<span id="motifDuration">0:00</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar-wrapper">
|
||||||
|
<input type="range" id="motifProgressBar" class="progress-bar" min="0" max="100" value="0" step="0.1" />
|
||||||
|
<div class="progress-fill" id="motifProgressFill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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[] {
|
private generateSyntheticMIDI(songName: string): NoteEvent[] {
|
||||||
// Generate procedural MIDI based on song name hash
|
// Generate procedural MIDI based on song name hash
|
||||||
const hash = this.simpleHash(songName);
|
const hash = this.simpleHash(songName);
|
||||||
|
|||||||
+61
@@ -46,6 +46,12 @@ class MotifApp {
|
|||||||
private motifBtn!: HTMLButtonElement;
|
private motifBtn!: HTMLButtonElement;
|
||||||
private motifStopBtn!: HTMLButtonElement;
|
private motifStopBtn!: HTMLButtonElement;
|
||||||
private motifVolumeSlider!: HTMLInputElement;
|
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 nextResultBtn!: HTMLButtonElement;
|
||||||
|
|
||||||
@@ -99,6 +105,11 @@ class MotifApp {
|
|||||||
this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement;
|
this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement;
|
||||||
this.motifStopBtn = document.getElementById('motifStopBtn') as HTMLButtonElement;
|
this.motifStopBtn = document.getElementById('motifStopBtn') as HTMLButtonElement;
|
||||||
this.motifVolumeSlider = document.getElementById('motifVolume') as HTMLInputElement;
|
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;
|
this.nextResultBtn = document.getElementById('nextResultBtn') as HTMLButtonElement;
|
||||||
}
|
}
|
||||||
@@ -143,6 +154,10 @@ class MotifApp {
|
|||||||
const volume = parseFloat((e.target as HTMLInputElement).value);
|
const volume = parseFloat((e.target as HTMLInputElement).value);
|
||||||
this.motifEngine.setVolume(volume);
|
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());
|
this.nextResultBtn.addEventListener('click', () => this.handleNextResult());
|
||||||
}
|
}
|
||||||
@@ -361,6 +376,17 @@ class MotifApp {
|
|||||||
await this.motifEngine.play();
|
await this.motifEngine.play();
|
||||||
|
|
||||||
this.motifStopBtn.disabled = false;
|
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...');
|
this.updateStatus('Playing Motif synthesis...');
|
||||||
console.log('Motif playback started successfully');
|
console.log('Motif playback started successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -374,9 +400,44 @@ class MotifApp {
|
|||||||
this.motifEngine.stop();
|
this.motifEngine.stop();
|
||||||
this.motifBtn.disabled = false;
|
this.motifBtn.disabled = false;
|
||||||
this.motifStopBtn.disabled = true;
|
this.motifStopBtn.disabled = true;
|
||||||
|
this.stopMotifProgressUpdates();
|
||||||
this.updateStatus('Motif synthesis stopped.');
|
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 {
|
private handleNextResult(): void {
|
||||||
const nextIndex = (this.selectedResultIndex + 1) % this.searchResults.length;
|
const nextIndex = (this.selectedResultIndex + 1) % this.searchResults.length;
|
||||||
this.selectResult(nextIndex);
|
this.selectResult(nextIndex);
|
||||||
|
|||||||
@@ -71,6 +71,65 @@ export class SynthesisEngine {
|
|||||||
this.masterGain.gain.value = Math.max(0, Math.min(1, volume));
|
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 {
|
private createSynthLayer(role: Role): SynthLayer {
|
||||||
const gainNode = this.audioContext.createGain();
|
const gainNode = this.audioContext.createGain();
|
||||||
const filterNode = this.audioContext.createBiquadFilter();
|
const filterNode = this.audioContext.createBiquadFilter();
|
||||||
|
|||||||
Reference in New Issue
Block a user