From 26d66f329c37154fa1e949837f7862b2e9c06e52 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Tue, 20 Jan 2026 23:30:33 -0700 Subject: [PATCH] Add song structure generation and fix volume controls - Fix volume sliders by tracking currentVolume separately from mute state - Add song structure system with intro, verse, bridge, chorus, outro sections - Implement automatic section transitions during playback based on duration - Add 30-second loading bar with progress animation during beat generation - Update instrument box colors (grey drums, red bass, green piano, baby blue brass) - Add custom trumpet SVG icon for brass section - Set duration slider max to 3 minutes - Fix TypeScript type errors in audio engine classes Co-Authored-By: Claude Opus 4.5 --- components/lofi-generator/LayerBox.tsx | 54 +++--- components/lofi-generator/LofiGenerator.tsx | 101 +++++++++- hooks/useAudioEngine.ts | 1 + lib/audio/ambientLayer.ts | 17 +- lib/audio/audioEngine.ts | 102 ++++++++++ lib/audio/bassEngine.ts | 17 +- lib/audio/brassEngine.ts | 19 +- lib/audio/chordEngine.ts | 12 +- lib/audio/drumMachine.ts | 22 ++- lib/audio/patterns.ts | 6 +- lib/audio/pianoEngine.ts | 16 +- lib/audio/songStructure.ts | 197 ++++++++++++++++++++ types/audio.ts | 4 + 13 files changed, 494 insertions(+), 74 deletions(-) create mode 100644 lib/audio/songStructure.ts diff --git a/components/lofi-generator/LayerBox.tsx b/components/lofi-generator/LayerBox.tsx index 69e636e..ea26685 100644 --- a/components/lofi-generator/LayerBox.tsx +++ b/components/lofi-generator/LayerBox.tsx @@ -13,8 +13,8 @@ import { Volume2, VolumeX } from 'lucide-react'; import { ReactNode } from 'react'; interface InstrumentOption { - value: string; - label: string; + readonly value: string; + readonly label: string; } interface LayerBoxProps { @@ -23,19 +23,37 @@ interface LayerBoxProps { volume: number; muted: boolean; instrument: string; - instrumentOptions: InstrumentOption[]; + instrumentOptions: readonly InstrumentOption[]; onVolumeChange: (volume: number) => void; onToggleMute: () => void; onInstrumentChange: (instrument: string) => void; - accentColor: 'orange' | 'pink' | 'purple' | 'blue' | 'green' | 'yellow'; + accentColor: 'grey' | 'red' | 'green' | 'babyblue' | 'pink' | 'purple'; } const accentStyles = { - orange: { - border: 'border-lofi-orange/30 hover:border-lofi-orange/50', - bg: 'bg-lofi-orange/5', - icon: 'text-lofi-orange', - slider: '[&_[data-slot=slider-range]]:bg-lofi-orange [&_[data-slot=slider-thumb]]:border-lofi-orange', + grey: { + border: 'border-slate-400/30 hover:border-slate-400/50', + bg: 'bg-slate-300/10', + icon: 'text-slate-400', + slider: '[&_[data-slot=slider-range]]:bg-slate-400 [&_[data-slot=slider-thumb]]:border-slate-400', + }, + red: { + border: 'border-rose-400/30 hover:border-rose-400/50', + bg: 'bg-rose-400/8', + icon: 'text-rose-400', + slider: '[&_[data-slot=slider-range]]:bg-rose-400 [&_[data-slot=slider-thumb]]:border-rose-400', + }, + green: { + border: 'border-emerald-400/30 hover:border-emerald-400/50', + bg: 'bg-gradient-to-r from-emerald-400/10 to-white/5', + icon: 'text-emerald-400', + slider: '[&_[data-slot=slider-range]]:bg-emerald-400 [&_[data-slot=slider-thumb]]:border-emerald-400', + }, + babyblue: { + border: 'border-sky-300/30 hover:border-sky-300/50', + bg: 'bg-sky-300/8', + icon: 'text-sky-300', + slider: '[&_[data-slot=slider-range]]:bg-sky-300 [&_[data-slot=slider-thumb]]:border-sky-300', }, pink: { border: 'border-lofi-pink/30 hover:border-lofi-pink/50', @@ -49,24 +67,6 @@ const accentStyles = { icon: 'text-lofi-purple', slider: '[&_[data-slot=slider-range]]:bg-lofi-purple [&_[data-slot=slider-thumb]]:border-lofi-purple', }, - blue: { - border: 'border-blue-400/30 hover:border-blue-400/50', - bg: 'bg-blue-400/5', - icon: 'text-blue-400', - slider: '[&_[data-slot=slider-range]]:bg-blue-400 [&_[data-slot=slider-thumb]]:border-blue-400', - }, - green: { - border: 'border-emerald-400/30 hover:border-emerald-400/50', - bg: 'bg-emerald-400/5', - icon: 'text-emerald-400', - slider: '[&_[data-slot=slider-range]]:bg-emerald-400 [&_[data-slot=slider-thumb]]:border-emerald-400', - }, - yellow: { - border: 'border-yellow-400/30 hover:border-yellow-400/50', - bg: 'bg-yellow-400/5', - icon: 'text-yellow-400', - slider: '[&_[data-slot=slider-range]]:bg-yellow-400 [&_[data-slot=slider-thumb]]:border-yellow-400', - }, }; export function LayerBox({ diff --git a/components/lofi-generator/LofiGenerator.tsx b/components/lofi-generator/LofiGenerator.tsx index 1bd15b8..64cebd4 100644 --- a/components/lofi-generator/LofiGenerator.tsx +++ b/components/lofi-generator/LofiGenerator.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState } from 'react'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { @@ -19,7 +20,6 @@ import { Pause, Shuffle, Drum, - Music, Cloud, Guitar, Waves, @@ -27,6 +27,27 @@ import { } from 'lucide-react'; import { Genre, GENRE_CONFIG, INSTRUMENT_OPTIONS } from '@/types/audio'; +// Custom Trumpet icon component +function TrumpetIcon({ className }: { className?: string }) { + return ( + + + + + + + + ); +} + export function LofiGenerator() { const { state, @@ -43,6 +64,9 @@ export function LofiGenerator() { setSwing, } = useAudioEngine(); + const [isGenerating, setIsGenerating] = useState(false); + const [generationProgress, setGenerationProgress] = useState(0); + const genres: { value: Genre; label: string }[] = [ { value: 'hiphop', label: 'Hip Hop' }, { value: 'classical', label: 'Classical' }, @@ -50,6 +74,38 @@ export function LofiGenerator() { { value: 'pop', label: 'Pop' }, ]; + const handleGenerateBeat = async () => { + setIsGenerating(true); + setGenerationProgress(0); + + // Simulate 30-second generation with intermittent progress + const totalDuration = 30000; // 30 seconds + const updateInterval = 100; // Update every 100ms + const steps = totalDuration / updateInterval; + let currentProgress = 0; + + const progressInterval = setInterval(() => { + // Intermittent progress - sometimes jumps, sometimes slow + const jump = Math.random() > 0.7 ? Math.random() * 5 : Math.random() * 2; + currentProgress = Math.min(currentProgress + (100 / steps) + jump, 99); + setGenerationProgress(currentProgress); + }, updateInterval); + + // Actually generate the beat + await generateNewBeat(); + + // Wait for the full 30 seconds + await new Promise((resolve) => setTimeout(resolve, totalDuration)); + + clearInterval(progressInterval); + setGenerationProgress(100); + + setTimeout(() => { + setIsGenerating(false); + setGenerationProgress(0); + }, 500); + }; + return (
{/* Modern Header */} @@ -80,7 +136,7 @@ export function LofiGenerator() { value={[state.duration]} onValueChange={([v]) => setDuration(v)} min={1} - max={10} + max={3} step={1} className="flex-1" /> @@ -113,6 +169,7 @@ export function LofiGenerator() {
+ {/* Generation Progress Bar */} + {isGenerating && ( +
+
+ Generating beat with song structure... + {Math.round(generationProgress)}% +
+
+
+
+

+ Creating intro, verse, bridge, chorus, and outro... +

+
+ )} + + {/* Current Section Indicator */} + {state.isPlaying && !isGenerating && ( +
+ Now Playing: + + {state.currentSection} + +
+ )} + {/* Visualizer */} @@ -202,7 +289,7 @@ export function LofiGenerator() { onVolumeChange={(v) => setLayerVolume('drums', v)} onToggleMute={() => toggleMute('drums')} onInstrumentChange={(v) => setInstrument('drums', v)} - accentColor="orange" + accentColor="grey" /> setLayerVolume('bass', v)} onToggleMute={() => toggleMute('bass')} onInstrumentChange={(v) => setInstrument('bass', v)} - accentColor="blue" + accentColor="red" /> } + icon={} volume={state.volumes.brass} muted={state.muted.brass} instrument={state.instruments.brass} @@ -241,7 +328,7 @@ export function LofiGenerator() { onVolumeChange={(v) => setLayerVolume('brass', v)} onToggleMute={() => toggleMute('brass')} onInstrumentChange={(v) => setInstrument('brass', v)} - accentColor="yellow" + accentColor="babyblue" /> { + const eventId = Tone.getTransport().schedule((time) => { + Tone.getDraw().schedule(() => { + this.applySection(section.type, section.instruments); + }, time); + }, startTime); + this.scheduledEvents.push(eventId); + }); + + // Set initial section + if (timings.length > 0) { + this.applySection(timings[0].section.type, timings[0].section.instruments); + } + + this.notifyStateChange(); + } + + private clearScheduledEvents(): void { + this.scheduledEvents.forEach(eventId => { + Tone.getTransport().clear(eventId); + }); + this.scheduledEvents = []; + } + + private applySection(sectionType: SectionType, instruments: { + drums: { active: boolean; volume: number }; + bass: { active: boolean; volume: number }; + brass: { active: boolean; volume: number }; + piano: { active: boolean; volume: number }; + chords: { active: boolean; volume: number }; + ambient: { active: boolean; volume: number }; + }): void { + this.currentSection = sectionType; + this.state.currentSection = sectionType; + + // Apply instrument volumes and mute states for this section + // Use smooth transitions for a professional sound + const layers: LayerName[] = ['drums', 'bass', 'brass', 'piano', 'chords', 'ambient']; + + layers.forEach(layer => { + const config = instruments[layer]; + + // Set mute state + this.state.muted[layer] = !config.active; + + // Set volume (scaled by user's master settings) + const userMasterScale = this.state.volumes.master; + const targetVolume = config.active ? config.volume * userMasterScale : 0; + + switch (layer) { + case 'drums': + this.drumMachine?.mute(!config.active); + if (config.active) this.drumMachine?.setVolume(targetVolume); + break; + case 'bass': + this.bassEngine?.mute(!config.active); + if (config.active) this.bassEngine?.setVolume(targetVolume); + break; + case 'brass': + this.brassEngine?.mute(!config.active); + if (config.active) this.brassEngine?.setVolume(targetVolume); + break; + case 'piano': + this.pianoEngine?.mute(!config.active); + if (config.active) this.pianoEngine?.setVolume(targetVolume); + break; + case 'chords': + this.chordEngine?.mute(!config.active); + if (config.active) this.chordEngine?.setVolume(targetVolume); + break; + case 'ambient': + this.ambientLayer?.mute(!config.active); + if (config.active) this.ambientLayer?.setVolume(targetVolume); + break; + } + }); + + // Notify callbacks + this.callbacks.onSectionChange?.(sectionType); this.notifyStateChange(); } @@ -348,6 +447,7 @@ class AudioEngine { dispose(): void { this.stop(); + this.clearScheduledEvents(); this.drumMachine?.dispose(); this.chordEngine?.dispose(); this.ambientLayer?.dispose(); @@ -369,9 +469,11 @@ class AudioEngine { this.masterCompressor = null; this.masterLimiter = null; this.masterReverb = null; + this.songStructure = null; this.state.isInitialized = false; this.state.isPlaying = false; + this.state.currentSection = 'intro'; AudioEngine.instance = null; } } diff --git a/lib/audio/bassEngine.ts b/lib/audio/bassEngine.ts index 5223419..6a05b9d 100644 --- a/lib/audio/bassEngine.ts +++ b/lib/audio/bassEngine.ts @@ -10,9 +10,11 @@ export class BassEngine { private filter: Tone.Filter; private currentType: BassType = 'synth'; private genre: Genre = 'hiphop'; + private currentVolume: number = 0.6; + private isMuted: boolean = false; constructor(destination: Tone.InputNode) { - this.output = new Tone.Gain(0.6); + this.output = new Tone.Gain(this.currentVolume); this.filter = new Tone.Filter({ frequency: 800, type: 'lowpass', @@ -31,7 +33,7 @@ export class BassEngine { this.synth.dispose(); } - const configs: Record> = { + const configs: Record = { synth: { oscillator: { type: 'sawtooth' }, envelope: { attack: 0.01, decay: 0.3, sustain: 0.4, release: 0.2 }, @@ -54,7 +56,8 @@ export class BassEngine { }, }; - this.synth = new Tone.MonoSynth(configs[type]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.synth = new Tone.MonoSynth(configs[type] as any); this.synth.volume.value = -6; this.synth.connect(this.filter); this.currentType = type; @@ -101,11 +104,15 @@ export class BassEngine { } setVolume(volume: number): void { - this.output.gain.rampTo(volume, 0.1); + this.currentVolume = volume; + if (!this.isMuted) { + this.output.gain.rampTo(volume, 0.1); + } } mute(muted: boolean): void { - this.output.gain.rampTo(muted ? 0 : 0.6, 0.1); + this.isMuted = muted; + this.output.gain.rampTo(muted ? 0 : this.currentVolume, 0.1); } dispose(): void { diff --git a/lib/audio/brassEngine.ts b/lib/audio/brassEngine.ts index 898542b..daad59a 100644 --- a/lib/audio/brassEngine.ts +++ b/lib/audio/brassEngine.ts @@ -11,9 +11,11 @@ export class BrassEngine { private reverb: Tone.Reverb; private currentType: BrassType = 'trumpet'; private genre: Genre = 'hiphop'; + private currentVolume: number = 0.4; + private isMuted: boolean = false; constructor(destination: Tone.InputNode) { - this.output = new Tone.Gain(0.4); + this.output = new Tone.Gain(this.currentVolume); this.filter = new Tone.Filter({ frequency: 3000, type: 'lowpass', @@ -37,7 +39,7 @@ export class BrassEngine { this.synth.dispose(); } - const configs: Record> = { + const configs: Record = { trumpet: { oscillator: { type: 'sawtooth' }, envelope: { attack: 0.05, decay: 0.2, sustain: 0.6, release: 0.3 }, @@ -47,7 +49,7 @@ export class BrassEngine { envelope: { attack: 0.1, decay: 0.3, sustain: 0.7, release: 0.5 }, }, 'synth-brass': { - oscillator: { type: 'fatsawtooth', spread: 20, count: 3 } as Tone.OmniOscillatorOptions, + oscillator: { type: 'fatsawtooth', spread: 20, count: 3 }, envelope: { attack: 0.02, decay: 0.3, sustain: 0.5, release: 0.2 }, }, orchestra: { @@ -56,7 +58,8 @@ export class BrassEngine { }, }; - this.synth = new Tone.Synth(configs[type]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.synth = new Tone.Synth(configs[type] as any); this.synth.volume.value = -8; this.synth.connect(this.filter); this.currentType = type; @@ -103,11 +106,15 @@ export class BrassEngine { } setVolume(volume: number): void { - this.output.gain.rampTo(volume, 0.1); + this.currentVolume = volume; + if (!this.isMuted) { + this.output.gain.rampTo(volume, 0.1); + } } mute(muted: boolean): void { - this.output.gain.rampTo(muted ? 0 : 0.4, 0.1); + this.isMuted = muted; + this.output.gain.rampTo(muted ? 0 : this.currentVolume, 0.1); } dispose(): void { diff --git a/lib/audio/chordEngine.ts b/lib/audio/chordEngine.ts index 675a85b..102f80f 100644 --- a/lib/audio/chordEngine.ts +++ b/lib/audio/chordEngine.ts @@ -12,9 +12,11 @@ export class ChordEngine { private chorus: Tone.Chorus; private currentType: ChordType = 'pad'; private genre: Genre = 'hiphop'; + private currentVolume: number = 0.6; + private isMuted: boolean = false; constructor(destination: Tone.InputNode) { - this.output = new Tone.Gain(0.6); + this.output = new Tone.Gain(this.currentVolume); this.filter = new Tone.Filter({ frequency: 2000, @@ -143,11 +145,15 @@ export class ChordEngine { } setVolume(volume: number): void { - this.output.gain.rampTo(volume, 0.1); + this.currentVolume = volume; + if (!this.isMuted) { + this.output.gain.rampTo(volume, 0.1); + } } mute(muted: boolean): void { - this.output.gain.rampTo(muted ? 0 : 0.6, 0.1); + this.isMuted = muted; + this.output.gain.rampTo(muted ? 0 : this.currentVolume, 0.1); } setFilterFrequency(freq: number): void { diff --git a/lib/audio/drumMachine.ts b/lib/audio/drumMachine.ts index 5379ff2..016ad4c 100644 --- a/lib/audio/drumMachine.ts +++ b/lib/audio/drumMachine.ts @@ -16,9 +16,11 @@ export class DrumMachine { private lowpass: Tone.Filter; private currentKit: DrumKit = 'acoustic'; private genre: Genre = 'hiphop'; + private currentVolume: number = 0.8; + private isMuted: boolean = false; constructor(destination: Tone.InputNode) { - this.output = new Tone.Gain(0.8); + this.output = new Tone.Gain(this.currentVolume); this.lowpass = new Tone.Filter({ frequency: 8000, type: 'lowpass', @@ -33,7 +35,6 @@ export class DrumMachine { } private createKit(kit: DrumKit): void { - // Dispose existing instruments this.kick?.dispose(); this.snare?.dispose(); this.hihat?.dispose(); @@ -43,7 +44,7 @@ export class DrumMachine { this.openhatFilter?.dispose(); const kitConfigs: Record; + kick: object; snareFilter: number; hihatFilter: number; snareDecay: number; @@ -81,14 +82,13 @@ export class DrumMachine { const config = kitConfigs[kit]; - // Kick drum + // eslint-disable-next-line @typescript-eslint/no-explicit-any this.kick = new Tone.MembraneSynth({ - ...config.kick, + ...(config.kick as any), oscillator: { type: 'sine' }, }); this.kick.connect(this.lowpass); - // Snare this.snareFilter = new Tone.Filter({ frequency: config.snareFilter, type: 'bandpass', @@ -101,7 +101,6 @@ export class DrumMachine { this.snare.connect(this.snareFilter); this.snareFilter.connect(this.lowpass); - // Closed hi-hat this.hihatFilter = new Tone.Filter({ frequency: config.hihatFilter, type: 'highpass', @@ -113,7 +112,6 @@ export class DrumMachine { this.hihat.connect(this.hihatFilter); this.hihatFilter.connect(this.lowpass); - // Open hi-hat this.openhatFilter = new Tone.Filter({ frequency: config.hihatFilter - 2000, type: 'highpass', @@ -182,11 +180,15 @@ export class DrumMachine { } setVolume(volume: number): void { - this.output.gain.rampTo(volume, 0.1); + this.currentVolume = volume; + if (!this.isMuted) { + this.output.gain.rampTo(volume, 0.1); + } } mute(muted: boolean): void { - this.output.gain.rampTo(muted ? 0 : 0.8, 0.1); + this.isMuted = muted; + this.output.gain.rampTo(muted ? 0 : this.currentVolume, 0.1); } getPattern(): DrumPattern { diff --git a/lib/audio/patterns.ts b/lib/audio/patterns.ts index 1b5a548..3044bfe 100644 --- a/lib/audio/patterns.ts +++ b/lib/audio/patterns.ts @@ -153,7 +153,7 @@ export const chordProgressions: Record = { }; // Bass patterns by genre -export const bassPatterns: Record = { +export const bassPatterns: Record = { hiphop: [ ['C2', null, null, null, 'C2', null, 'G1', null, 'C2', null, null, null, 'E2', null, null, null], ['A1', null, null, 'A1', null, null, 'G1', null, 'A1', null, null, null, 'E1', null, 'G1', null], @@ -173,7 +173,7 @@ export const bassPatterns: Record = { }; // Brass melody patterns by genre -export const brassPatterns: Record = { +export const brassPatterns: Record = { hiphop: [ ['C4', null, null, null, 'E4', null, null, null, 'G4', null, null, null, 'E4', null, null, null], [null, null, 'D4', null, null, null, 'F4', null, null, null, 'A4', null, null, null, 'G4', null], @@ -193,7 +193,7 @@ export const brassPatterns: Record = { }; // Piano patterns by genre -export const pianoPatterns: Record = { +export const pianoPatterns: Record = { hiphop: [ [['C4', 'E4', 'G4'], null, null, null, ['D4', 'F4', 'A4'], null, null, null, ['E4', 'G4', 'B4'], null, null, null, ['D4', 'F4', 'A4'], null, null, null], [['A3', 'C4', 'E4'], null, ['A3', 'C4', 'E4'], null, null, null, ['G3', 'B3', 'D4'], null, ['G3', 'B3', 'D4'], null, null, null, ['F3', 'A3', 'C4'], null, null, null], diff --git a/lib/audio/pianoEngine.ts b/lib/audio/pianoEngine.ts index 8b6c56e..b453035 100644 --- a/lib/audio/pianoEngine.ts +++ b/lib/audio/pianoEngine.ts @@ -11,9 +11,11 @@ export class PianoEngine { private reverb: Tone.Reverb; private currentType: PianoType = 'grand'; private genre: Genre = 'hiphop'; + private currentVolume: number = 0.5; + private isMuted: boolean = false; constructor(destination: Tone.InputNode) { - this.output = new Tone.Gain(0.5); + this.output = new Tone.Gain(this.currentVolume); this.filter = new Tone.Filter({ frequency: 5000, type: 'lowpass', @@ -37,7 +39,7 @@ export class PianoEngine { this.synth.dispose(); } - const configs: Record = { + const configs: Record = { grand: { synth: Tone.Synth, options: { @@ -74,7 +76,7 @@ export class PianoEngine { const config = configs[type]; // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.synth = new Tone.PolySynth(config.synth as any, config.options); + this.synth = new Tone.PolySynth(config.synth as any, config.options as any); this.synth.volume.value = -10; this.synth.connect(this.filter); this.currentType = type; @@ -121,11 +123,15 @@ export class PianoEngine { } setVolume(volume: number): void { - this.output.gain.rampTo(volume, 0.1); + this.currentVolume = volume; + if (!this.isMuted) { + this.output.gain.rampTo(volume, 0.1); + } } mute(muted: boolean): void { - this.output.gain.rampTo(muted ? 0 : 0.5, 0.1); + this.isMuted = muted; + this.output.gain.rampTo(muted ? 0 : this.currentVolume, 0.1); } dispose(): void { diff --git a/lib/audio/songStructure.ts b/lib/audio/songStructure.ts new file mode 100644 index 0000000..eac8c8a --- /dev/null +++ b/lib/audio/songStructure.ts @@ -0,0 +1,197 @@ +import { Genre, SectionType } from '@/types/audio'; + +export interface SongSection { + type: SectionType; + bars: number; + instruments: { + drums: { active: boolean; volume: number }; + bass: { active: boolean; volume: number }; + brass: { active: boolean; volume: number }; + piano: { active: boolean; volume: number }; + chords: { active: boolean; volume: number }; + ambient: { active: boolean; volume: number }; + }; +} + +export interface SongStructure { + sections: SongSection[]; + totalBars: number; +} + +// Genre-specific section configurations +const sectionConfigs: Record = { + intro: { + instruments: { + drums: { active: false, volume: 0 }, + bass: { active: false, volume: 0 }, + brass: { active: false, volume: 0 }, + piano: { active: true, volume: 0.3 }, + chords: { active: true, volume: 0.4 }, + ambient: { active: true, volume: 0.6 }, + }, + }, + verse: { + instruments: { + drums: { active: true, volume: 0.6 }, + bass: { active: true, volume: 0.5 }, + brass: { active: false, volume: 0 }, + piano: { active: true, volume: 0.4 }, + chords: { active: true, volume: 0.5 }, + ambient: { active: true, volume: 0.4 }, + }, + }, + bridge: { + instruments: { + drums: { active: true, volume: 0.4 }, + bass: { active: true, volume: 0.4 }, + brass: { active: true, volume: 0.3 }, + piano: { active: false, volume: 0 }, + chords: { active: true, volume: 0.6 }, + ambient: { active: true, volume: 0.5 }, + }, + }, + chorus: { + instruments: { + drums: { active: true, volume: 0.8 }, + bass: { active: true, volume: 0.7 }, + brass: { active: true, volume: 0.5 }, + piano: { active: true, volume: 0.5 }, + chords: { active: true, volume: 0.7 }, + ambient: { active: true, volume: 0.3 }, + }, + }, + outro: { + instruments: { + drums: { active: true, volume: 0.3 }, + bass: { active: true, volume: 0.3 }, + brass: { active: false, volume: 0 }, + piano: { active: true, volume: 0.4 }, + chords: { active: true, volume: 0.5 }, + ambient: { active: true, volume: 0.7 }, + }, + }, +}; + +// Generate song structure based on duration (in minutes) +export function generateSongStructure(durationMinutes: number, bpm: number, genre: Genre): SongStructure { + // Calculate total bars based on duration and BPM + // 4 beats per bar, so bars = (minutes * bpm) / 4 + const totalBars = Math.floor((durationMinutes * bpm) / 4); + + // Create a standard song structure that fits within the total bars + const sections: SongSection[] = []; + + if (durationMinutes === 1) { + // Short format: Intro -> Verse -> Chorus -> Outro + const introBars = Math.floor(totalBars * 0.15); + const verseBars = Math.floor(totalBars * 0.35); + const chorusBars = Math.floor(totalBars * 0.35); + const outroBars = totalBars - introBars - verseBars - chorusBars; + + sections.push({ type: 'intro', bars: introBars, instruments: { ...sectionConfigs.intro.instruments } }); + sections.push({ type: 'verse', bars: verseBars, instruments: { ...sectionConfigs.verse.instruments } }); + sections.push({ type: 'chorus', bars: chorusBars, instruments: { ...sectionConfigs.chorus.instruments } }); + sections.push({ type: 'outro', bars: outroBars, instruments: { ...sectionConfigs.outro.instruments } }); + } else if (durationMinutes === 2) { + // Medium format: Intro -> Verse -> Bridge -> Chorus -> Outro + const introBars = Math.floor(totalBars * 0.1); + const verse1Bars = Math.floor(totalBars * 0.25); + const bridgeBars = Math.floor(totalBars * 0.15); + const chorusBars = Math.floor(totalBars * 0.35); + const outroBars = totalBars - introBars - verse1Bars - bridgeBars - chorusBars; + + sections.push({ type: 'intro', bars: introBars, instruments: { ...sectionConfigs.intro.instruments } }); + sections.push({ type: 'verse', bars: verse1Bars, instruments: { ...sectionConfigs.verse.instruments } }); + sections.push({ type: 'bridge', bars: bridgeBars, instruments: { ...sectionConfigs.bridge.instruments } }); + sections.push({ type: 'chorus', bars: chorusBars, instruments: { ...sectionConfigs.chorus.instruments } }); + sections.push({ type: 'outro', bars: outroBars, instruments: { ...sectionConfigs.outro.instruments } }); + } else { + // Full format (3+ mins): Intro -> Verse -> Chorus -> Verse -> Bridge -> Chorus -> Outro + const introBars = Math.floor(totalBars * 0.08); + const verse1Bars = Math.floor(totalBars * 0.18); + const chorus1Bars = Math.floor(totalBars * 0.18); + const verse2Bars = Math.floor(totalBars * 0.15); + const bridgeBars = Math.floor(totalBars * 0.12); + const chorus2Bars = Math.floor(totalBars * 0.18); + const outroBars = totalBars - introBars - verse1Bars - chorus1Bars - verse2Bars - bridgeBars - chorus2Bars; + + sections.push({ type: 'intro', bars: introBars, instruments: { ...sectionConfigs.intro.instruments } }); + sections.push({ type: 'verse', bars: verse1Bars, instruments: { ...sectionConfigs.verse.instruments } }); + sections.push({ type: 'chorus', bars: chorus1Bars, instruments: { ...sectionConfigs.chorus.instruments } }); + sections.push({ type: 'verse', bars: verse2Bars, instruments: { ...sectionConfigs.verse.instruments } }); + sections.push({ type: 'bridge', bars: bridgeBars, instruments: { ...sectionConfigs.bridge.instruments } }); + sections.push({ type: 'chorus', bars: chorus2Bars, instruments: { ...sectionConfigs.chorus.instruments } }); + sections.push({ type: 'outro', bars: outroBars, instruments: { ...sectionConfigs.outro.instruments } }); + } + + // Apply genre-specific modifications + applyGenreModifications(sections, genre); + + return { sections, totalBars }; +} + +function applyGenreModifications(sections: SongSection[], genre: Genre): void { + switch (genre) { + case 'trap': + // Trap: heavier bass, more brass in chorus + sections.forEach(section => { + if (section.instruments.bass.active) { + section.instruments.bass.volume = Math.min(1, section.instruments.bass.volume * 1.3); + } + if (section.type === 'chorus') { + section.instruments.brass.volume = Math.min(1, section.instruments.brass.volume * 1.2); + } + }); + break; + case 'classical': + // Classical: more piano and chords, less drums + sections.forEach(section => { + section.instruments.piano.active = true; + section.instruments.piano.volume = Math.min(1, section.instruments.piano.volume * 1.3); + section.instruments.drums.volume *= 0.7; + section.instruments.brass.active = true; + section.instruments.brass.volume = Math.min(1, section.instruments.brass.volume + 0.2); + }); + break; + case 'pop': + // Pop: balanced, catchy, more emphasis on chorus + sections.forEach(section => { + if (section.type === 'chorus') { + Object.keys(section.instruments).forEach(key => { + const inst = section.instruments[key as keyof typeof section.instruments]; + inst.volume = Math.min(1, inst.volume * 1.1); + }); + } + }); + break; + case 'hiphop': + default: + // Hip hop: heavy drums and bass, soulful samples + sections.forEach(section => { + if (section.type === 'verse' || section.type === 'chorus') { + section.instruments.drums.volume = Math.min(1, section.instruments.drums.volume * 1.1); + section.instruments.bass.volume = Math.min(1, section.instruments.bass.volume * 1.1); + } + }); + break; + } +} + +// Calculate timing for each section in seconds +export function calculateSectionTimings(structure: SongStructure, bpm: number): { startTime: number; endTime: number; section: SongSection }[] { + const secondsPerBar = (60 / bpm) * 4; // 4 beats per bar + let currentTime = 0; + + return structure.sections.map(section => { + const startTime = currentTime; + const duration = section.bars * secondsPerBar; + currentTime += duration; + return { + startTime, + endTime: currentTime, + section, + }; + }); +} diff --git a/types/audio.ts b/types/audio.ts index 5e1ebf9..3d50811 100644 --- a/types/audio.ts +++ b/types/audio.ts @@ -34,12 +34,15 @@ export interface InstrumentConfig { ambient: AmbientType; } +export type SectionType = 'intro' | 'verse' | 'bridge' | 'chorus' | 'outro'; + export interface EngineState { isPlaying: boolean; isInitialized: boolean; bpm: number; swing: number; currentStep: number; + currentSection: SectionType; genre: Genre; duration: number; // in minutes instruments: InstrumentConfig; @@ -67,6 +70,7 @@ export type LayerName = 'drums' | 'bass' | 'brass' | 'piano' | 'chords' | 'ambie export interface AudioEngineCallbacks { onStepChange?: (step: number) => void; onStateChange?: (state: EngineState) => void; + onSectionChange?: (section: SectionType) => void; } export const GENRE_CONFIG: Record = {