2025-11-17 20:59:36 +01:00
|
|
|
/**
|
|
|
|
|
* Track utility functions
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { Track, TrackColor } from '@/types/track';
|
|
|
|
|
import { DEFAULT_TRACK_HEIGHT, TRACK_COLORS } from '@/types/track';
|
2025-11-18 07:30:46 +01:00
|
|
|
import { createEffectChain } from '@/lib/audio/effects/chain';
|
2025-11-18 18:34:35 +01:00
|
|
|
import { createAutomationLane } from '@/lib/audio/automation-utils';
|
2025-11-17 20:59:36 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generate a unique track ID
|
|
|
|
|
*/
|
|
|
|
|
export function generateTrackId(): string {
|
|
|
|
|
return `track-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a new empty track
|
|
|
|
|
*/
|
|
|
|
|
export function createTrack(name?: string, color?: TrackColor): Track {
|
|
|
|
|
const colors: TrackColor[] = ['blue', 'green', 'purple', 'orange', 'pink', 'indigo', 'yellow', 'red'];
|
|
|
|
|
const randomColor = colors[Math.floor(Math.random() * colors.length)];
|
|
|
|
|
|
2025-11-17 22:50:43 +01:00
|
|
|
// Ensure name is always a string, handle cases where event objects might be passed
|
|
|
|
|
const trackName = typeof name === 'string' && name.trim() ? name.trim() : 'New Track';
|
|
|
|
|
|
2025-11-18 18:34:35 +01:00
|
|
|
const trackId = generateTrackId();
|
|
|
|
|
|
2025-11-17 20:59:36 +01:00
|
|
|
return {
|
2025-11-18 18:34:35 +01:00
|
|
|
id: trackId,
|
2025-11-17 22:50:43 +01:00
|
|
|
name: trackName,
|
2025-11-17 20:59:36 +01:00
|
|
|
color: TRACK_COLORS[color || randomColor],
|
|
|
|
|
height: DEFAULT_TRACK_HEIGHT,
|
|
|
|
|
audioBuffer: null,
|
|
|
|
|
volume: 0.8,
|
|
|
|
|
pan: 0,
|
|
|
|
|
mute: false,
|
|
|
|
|
solo: false,
|
|
|
|
|
recordEnabled: false,
|
2025-11-18 07:30:46 +01:00
|
|
|
effectChain: createEffectChain(`${trackName} Effects`),
|
2025-11-18 16:30:01 +01:00
|
|
|
automation: {
|
2025-11-18 18:34:35 +01:00
|
|
|
lanes: [
|
|
|
|
|
createAutomationLane(trackId, 'volume', 'Volume', {
|
|
|
|
|
min: 0,
|
|
|
|
|
max: 1,
|
|
|
|
|
unit: 'dB',
|
|
|
|
|
}),
|
|
|
|
|
createAutomationLane(trackId, 'pan', 'Pan', {
|
|
|
|
|
min: -1,
|
|
|
|
|
max: 1,
|
|
|
|
|
formatter: (value: number) => {
|
|
|
|
|
if (value === 0) return 'C';
|
|
|
|
|
if (value < 0) return `${Math.abs(value * 100).toFixed(0)}L`;
|
|
|
|
|
return `${(value * 100).toFixed(0)}R`;
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
],
|
2025-11-18 16:30:01 +01:00
|
|
|
showAutomation: false,
|
|
|
|
|
},
|
2025-11-17 20:59:36 +01:00
|
|
|
collapsed: false,
|
|
|
|
|
selected: false,
|
2025-11-18 13:05:05 +01:00
|
|
|
selection: null,
|
2025-11-17 20:59:36 +01:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a track from an audio buffer
|
|
|
|
|
*/
|
|
|
|
|
export function createTrackFromBuffer(
|
|
|
|
|
buffer: AudioBuffer,
|
|
|
|
|
name?: string,
|
|
|
|
|
color?: TrackColor
|
|
|
|
|
): Track {
|
2025-11-17 22:50:43 +01:00
|
|
|
// Ensure name is a string before passing to createTrack
|
|
|
|
|
const trackName = typeof name === 'string' && name.trim() ? name.trim() : undefined;
|
|
|
|
|
const track = createTrack(trackName, color);
|
2025-11-17 20:59:36 +01:00
|
|
|
track.audioBuffer = buffer;
|
|
|
|
|
return track;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Mix multiple tracks into a single stereo buffer
|
|
|
|
|
*/
|
|
|
|
|
export function mixTracks(
|
|
|
|
|
tracks: Track[],
|
|
|
|
|
sampleRate: number,
|
|
|
|
|
duration: number
|
|
|
|
|
): AudioBuffer {
|
|
|
|
|
// Determine the length needed
|
|
|
|
|
const length = Math.ceil(duration * sampleRate);
|
|
|
|
|
|
|
|
|
|
// Create output buffer (stereo)
|
|
|
|
|
const offlineContext = new OfflineAudioContext(2, length, sampleRate);
|
|
|
|
|
const outputBuffer = offlineContext.createBuffer(2, length, sampleRate);
|
|
|
|
|
|
|
|
|
|
const leftChannel = outputBuffer.getChannelData(0);
|
|
|
|
|
const rightChannel = outputBuffer.getChannelData(1);
|
|
|
|
|
|
|
|
|
|
// Check if any tracks are soloed
|
|
|
|
|
const soloedTracks = tracks.filter((t) => t.solo && !t.mute);
|
|
|
|
|
const audibleTracks = soloedTracks.length > 0 ? soloedTracks : tracks.filter((t) => !t.mute);
|
|
|
|
|
|
|
|
|
|
// Mix each audible track
|
|
|
|
|
for (const track of audibleTracks) {
|
|
|
|
|
if (!track.audioBuffer) continue;
|
|
|
|
|
|
|
|
|
|
const buffer = track.audioBuffer;
|
|
|
|
|
const trackLength = Math.min(buffer.length, length);
|
|
|
|
|
|
|
|
|
|
// Get source channels (handle mono/stereo)
|
|
|
|
|
const sourceLeft = buffer.getChannelData(0);
|
|
|
|
|
const sourceRight = buffer.numberOfChannels > 1 ? buffer.getChannelData(1) : sourceLeft;
|
|
|
|
|
|
|
|
|
|
// Calculate pan gains (constant power panning)
|
|
|
|
|
const panAngle = (track.pan * Math.PI) / 4; // -π/4 to π/4
|
|
|
|
|
const leftGain = Math.cos(panAngle + Math.PI / 4) * track.volume;
|
|
|
|
|
const rightGain = Math.sin(panAngle + Math.PI / 4) * track.volume;
|
|
|
|
|
|
|
|
|
|
// Mix into output buffer
|
|
|
|
|
for (let i = 0; i < trackLength; i++) {
|
|
|
|
|
leftChannel[i] += sourceLeft[i] * leftGain;
|
|
|
|
|
rightChannel[i] += sourceRight[i] * rightGain;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return outputBuffer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the maximum duration across all tracks
|
|
|
|
|
*/
|
|
|
|
|
export function getMaxTrackDuration(tracks: Track[]): number {
|
|
|
|
|
let maxDuration = 0;
|
|
|
|
|
|
|
|
|
|
for (const track of tracks) {
|
|
|
|
|
if (track.audioBuffer) {
|
|
|
|
|
const duration = track.audioBuffer.duration;
|
|
|
|
|
if (duration > maxDuration) {
|
|
|
|
|
maxDuration = duration;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return maxDuration;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Calculate track mix gain (considering solo/mute)
|
|
|
|
|
*/
|
|
|
|
|
export function getTrackGain(track: Track, allTracks: Track[]): number {
|
|
|
|
|
// If track is muted, gain is 0
|
|
|
|
|
if (track.mute) return 0;
|
|
|
|
|
|
|
|
|
|
// Check if any tracks are soloed
|
|
|
|
|
const hasSoloedTracks = allTracks.some((t) => t.solo);
|
|
|
|
|
|
|
|
|
|
// If there are soloed tracks and this track is not soloed, gain is 0
|
|
|
|
|
if (hasSoloedTracks && !track.solo) return 0;
|
|
|
|
|
|
|
|
|
|
// Otherwise, return the track's volume
|
|
|
|
|
return track.volume;
|
|
|
|
|
}
|