File size: 4,766 Bytes
a63c2c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
/**
 * The studio's project: tracks holding clips, each clip a window onto an
 * immutable audio buffer. Buffers are never edited in place — an edit makes a
 * new buffer and points the clip at it — so a project snapshot is cheap and
 * undo is just keeping old snapshots.
 */
export type Track={id:string;name:string;color:string;volume:number;pan:number;mute:boolean;solo:boolean};
export type Clip={
 id:string;trackId:string;
 /** Key into the buffer store. */
 buffer:string;
 /** Where the clip sits on the timeline, in seconds. */
 start:number;
 /** How far into its buffer the clip begins, and how much of it plays. */
 offset:number;duration:number;
 name:string;muted?:boolean;
};
export type Chord={time:number;name:string};
export type Project={tracks:Track[];clips:Clip[];bpm:number;key:string;chords:Chord[]};
/** A stretch of time across one or more tracks, drawn with the range tool. */
export type Range={start:number;end:number;tracks:string[]};
export type Selection={clips:string[];range:Range|null};

export const COLORS=['#e8476f','#f2b33d','#f47b36','#4f7cf5','#3fbf8a','#9b6cf0','#35b5c9','#d9534a'];
export const EMPTY:Project={tracks:[],clips:[],bpm:120,key:'',chords:[]};
export const MIN_CLIP=0.05;

export const uid=()=>crypto.randomUUID();
export const clipEnd=(clip:Clip)=>clip.start+clip.duration;
export const projectEnd=(project:Project)=>project.clips.reduce((end,clip)=>Math.max(end,clipEnd(clip)),0);

export function addTrack(project:Project,name:string,after?:string):[Project,Track]{
 const used=new Set(project.tracks.map(t=>t.color));
 const color=COLORS.find(c=>!used.has(c))||COLORS[project.tracks.length%COLORS.length];
 const track:Track={id:uid(),name,color,volume:1,pan:0,mute:false,solo:false};
 const tracks=[...project.tracks];
 const index=after?tracks.findIndex(t=>t.id===after):-1;
 tracks.splice(index<0?tracks.length:index+1,0,track);
 return [{...project,tracks},track];
}

/** Whether a track is heard, given every track's mute and solo. */
export function audible(project:Project,track:Track){
 const soloed=project.tracks.some(t=>t.solo);
 return soloed?track.solo:!track.mute;
}

/** Cut a clip in two at a timeline position; returns the ids of both halves. */
export function splitClip(project:Project,id:string,at:number):[Project,string[]]{
 const clip=project.clips.find(c=>c.id===id);
 if(!clip||at<=clip.start+MIN_CLIP||at>=clipEnd(clip)-MIN_CLIP)return [project,clip?[clip.id]:[]];
 const cut=at-clip.start;
 const left={...clip,duration:cut};
 const right={...clip,id:uid(),start:at,offset:clip.offset+cut,duration:clip.duration-cut};
 return [{...project,clips:project.clips.flatMap(c=>c.id===id?[left,right]:[c])},[left.id,right.id]];
}

/**
 * Mute exactly the part of each listed track that a range covers, splitting
 * clips at its edges. This is how a result that replaces a stretch of several
 * clips takes over without destroying what it replaced.
 */
export function muteRange(project:Project,range:Range){
 let next=project;
 for(const clip of project.clips.filter(c=>range.tracks.includes(c.trackId))){
  [next]=splitClip(next,clip.id,range.start);
 }
 for(const clip of next.clips.filter(c=>range.tracks.includes(c.trackId))){
  [next]=splitClip(next,clip.id,range.end);
 }
 return {...next,clips:next.clips.map(c=>range.tracks.includes(c.trackId)&&c.start>=range.start-1e-6&&clipEnd(c)<=range.end+1e-6
  ?{...c,muted:true}:c)};
}

/** Clips on the given tracks that overlap a stretch of time. */
export function clipsIn(project:Project,range:Range){
 return project.clips.filter(c=>range.tracks.includes(c.trackId)&&c.start<range.end&&clipEnd(c)>range.start);
}

/** The one clip that covers a range on its own, if there is one: then an edit can go in place. */
export function soleClip(project:Project,range:Range){
 const inside=clipsIn(project,range);
 const clip=inside.length===1?inside[0]:null;
 return clip&&range.tracks.length===1&&clip.start<=range.start+1e-6&&clipEnd(clip)>=range.end-1e-6?clip:null;
}

/** Snap a time to the beat grid. */
export function snap(time:number,bpm:number,division:number){
 if(!division)return time;
 const step=60/bpm/division;
 return Math.max(0,Math.round(time/step)*step);
}

export function formatTime(seconds:number){
 const sign=seconds<0?'-':'';seconds=Math.abs(seconds);
 const m=Math.floor(seconds/60),s=Math.floor(seconds%60),ms=Math.floor((seconds%1)*1000);
 return `${sign}${m}:${String(s).padStart(2,'0')}.${String(ms).padStart(3,'0')}`;
}

/** Bars and beats, counted from one like a DAW's position display. */
export function formatBars(seconds:number,bpm:number){
 const beats=Math.max(0,seconds)*bpm/60;
 return `${Math.floor(beats/4)+1}.${Math.floor(beats%4)+1}.${String(Math.floor((beats%1)*100)).padStart(2,'0')}`;
}