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 <noreply@anthropic.com>
This commit is contained in:
Avery Felts 2026-01-20 23:30:33 -07:00
parent 0f17775f3f
commit 26d66f329c
13 changed files with 494 additions and 74 deletions

View File

@ -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({

View File

@ -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 (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 12h4l3-3v6l-3-3" />
<path d="M10 12h8" />
<circle cx="20" cy="12" r="2" />
<path d="M7 9V6" />
<path d="M7 15v3" />
</svg>
);
}
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 (
<div className="min-h-screen flex flex-col items-center p-4 pt-8">
{/* 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() {
<Button
size="lg"
onClick={togglePlayback}
disabled={isGenerating}
className="h-14 w-14 rounded-full bg-gradient-to-br from-lofi-orange to-lofi-pink hover:opacity-90"
>
{state.isPlaying ? (
@ -125,7 +182,8 @@ export function LofiGenerator() {
<Button
variant="secondary"
size="lg"
onClick={generateNewBeat}
onClick={handleGenerateBeat}
disabled={isGenerating}
className="gap-2"
>
<Shuffle className="h-4 w-4" />
@ -133,6 +191,35 @@ export function LofiGenerator() {
</Button>
</div>
{/* Generation Progress Bar */}
{isGenerating && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Generating beat with song structure...</span>
<span>{Math.round(generationProgress)}%</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-lofi-orange via-lofi-pink to-lofi-purple transition-all duration-100 ease-out"
style={{ width: `${generationProgress}%` }}
/>
</div>
<p className="text-xs text-muted-foreground/60 text-center">
Creating intro, verse, bridge, chorus, and outro...
</p>
</div>
)}
{/* Current Section Indicator */}
{state.isPlaying && !isGenerating && (
<div className="flex items-center justify-center gap-2">
<span className="text-xs text-muted-foreground uppercase tracking-wider">Now Playing:</span>
<span className="px-3 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-lofi-orange/20 via-lofi-pink/20 to-lofi-purple/20 border border-lofi-pink/30 text-lofi-pink capitalize">
{state.currentSection}
</span>
</div>
)}
{/* Visualizer */}
<Visualizer currentStep={currentStep} isPlaying={state.isPlaying} />
@ -202,7 +289,7 @@ export function LofiGenerator() {
onVolumeChange={(v) => setLayerVolume('drums', v)}
onToggleMute={() => toggleMute('drums')}
onInstrumentChange={(v) => setInstrument('drums', v)}
accentColor="orange"
accentColor="grey"
/>
<LayerBox
@ -215,7 +302,7 @@ export function LofiGenerator() {
onVolumeChange={(v) => setLayerVolume('bass', v)}
onToggleMute={() => toggleMute('bass')}
onInstrumentChange={(v) => setInstrument('bass', v)}
accentColor="blue"
accentColor="red"
/>
<LayerBox
@ -233,7 +320,7 @@ export function LofiGenerator() {
<LayerBox
title="Brass"
icon={<Music className="h-4 w-4" />}
icon={<TrumpetIcon className="h-4 w-4" />}
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"
/>
<LayerBox

View File

@ -9,6 +9,7 @@ const defaultState: EngineState = {
bpm: 90,
swing: 0.15,
currentStep: 0,
currentSection: 'intro',
genre: 'hiphop',
duration: 3,
instruments: {

View File

@ -12,15 +12,16 @@ export class AmbientLayer {
private lfo: Tone.LFO | null = null;
private currentType: AmbientType = 'rain';
private isPlaying = false;
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.output.connect(destination);
this.createAmbient('rain');
}
private createAmbient(type: AmbientType): void {
// Dispose existing
this.noise1?.dispose();
this.noise2?.dispose();
this.filter1?.dispose();
@ -58,7 +59,6 @@ export class AmbientLayer {
const config = configs[type];
// Primary noise layer
this.noise1 = new Tone.Noise(config.noise1.type);
this.filter1 = new Tone.Filter({
frequency: config.noise1.filter,
@ -69,7 +69,6 @@ export class AmbientLayer {
this.filter1.connect(this.gain1);
this.gain1.connect(this.output);
// Secondary noise layer
this.noise2 = new Tone.Noise(config.noise2.type);
this.filter2 = new Tone.Filter({
frequency: config.noise2.filter,
@ -80,7 +79,6 @@ export class AmbientLayer {
this.filter2.connect(this.gain2);
this.gain2.connect(this.output);
// LFO for subtle variation
this.lfo = new Tone.LFO({
frequency: config.lfoFreq,
min: config.noise1.gain * 0.7,
@ -90,7 +88,6 @@ export class AmbientLayer {
this.currentType = type;
// Restart if was playing
if (this.isPlaying) {
this.noise1.start();
this.noise2.start();
@ -117,11 +114,15 @@ export class AmbientLayer {
}
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 {

View File

@ -5,6 +5,7 @@ import { AmbientLayer } from './ambientLayer';
import { BassEngine } from './bassEngine';
import { BrassEngine } from './brassEngine';
import { PianoEngine } from './pianoEngine';
import { generateSongStructure, calculateSectionTimings, SongStructure } from './songStructure';
import {
EngineState,
AudioEngineCallbacks,
@ -17,6 +18,7 @@ import {
PianoType,
ChordType,
AmbientType,
SectionType,
} from '@/types/audio';
class AudioEngine {
@ -34,6 +36,10 @@ class AudioEngine {
private masterLimiter: Tone.Limiter | null = null;
private masterReverb: Tone.Reverb | null = null;
private songStructure: SongStructure | null = null;
private scheduledEvents: number[] = [];
private currentSection: SectionType = 'intro';
private callbacks: AudioEngineCallbacks = {};
private state: EngineState = {
@ -42,6 +48,7 @@ class AudioEngine {
bpm: 90,
swing: 0.15,
currentStep: 0,
currentSection: 'intro',
genre: 'hiphop',
duration: 3,
instruments: {
@ -173,17 +180,109 @@ class AudioEngine {
stop(): void {
Tone.getTransport().stop();
this.ambientLayer?.stop();
this.clearScheduledEvents();
this.state.isPlaying = false;
this.state.currentStep = 0;
this.state.currentSection = 'intro';
this.notifyStateChange();
}
generateNewBeat(): void {
// Clear any previously scheduled events
this.clearScheduledEvents();
// Randomize patterns for all instruments
this.drumMachine?.randomize();
this.chordEngine?.randomize();
this.bassEngine?.randomize();
this.brassEngine?.randomize();
this.pianoEngine?.randomize();
// Generate song structure based on current settings
this.songStructure = generateSongStructure(this.state.duration, this.state.bpm, this.state.genre);
// Calculate section timings and schedule changes
const timings = calculateSectionTimings(this.songStructure, this.state.bpm);
timings.forEach(({ startTime, section }) => {
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;
}
}

View File

@ -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<BassType, Partial<Tone.MonoSynthOptions>> = {
const configs: Record<BassType, { oscillator: { type: string }; envelope: object; filterEnvelope: object }> = {
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 {

View File

@ -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<BrassType, Partial<Tone.SynthOptions>> = {
const configs: Record<BrassType, { oscillator: object; envelope: object }> = {
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 {

View File

@ -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 {

View File

@ -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<DrumKit, {
kick: Partial<Tone.MembraneSynthOptions>;
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 {

View File

@ -153,7 +153,7 @@ export const chordProgressions: Record<Genre, ChordProgression[]> = {
};
// Bass patterns by genre
export const bassPatterns: Record<Genre, string[][]> = {
export const bassPatterns: Record<Genre, (string | null)[][]> = {
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<Genre, string[][]> = {
};
// Brass melody patterns by genre
export const brassPatterns: Record<Genre, string[][]> = {
export const brassPatterns: Record<Genre, (string | null)[][]> = {
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<Genre, string[][]> = {
};
// Piano patterns by genre
export const pianoPatterns: Record<Genre, string[][][]> = {
export const pianoPatterns: Record<Genre, (string[] | null)[][]> = {
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],

View File

@ -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<PianoType, { synth: typeof Tone.Synth; options: object }> = {
const configs: Record<PianoType, { synth: typeof Tone.Synth | typeof Tone.FMSynth; options: object }> = {
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 {

197
lib/audio/songStructure.ts Normal file
View File

@ -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<SectionType, {
instruments: SongSection['instruments'];
}> = {
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,
};
});
}

View File

@ -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<Genre, { bpm: number; swing: number; name: string }> = {