/** * 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.startrange.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')}`; }