Spaces:
Running
Running
Download lib/studio/model.ts from Reubencf/Score-Studio: direct link, hf CLI and curl.
- Browser
- Download file 4.77 kB
-
https://huggingface.co/spaces/Reubencf/Score-Studio/resolve/main/lib/studio/model.ts
- Command line
-
hf download hf://spaces/Reubencf/Score-Studio/lib/studio/model.ts
-
curl -L -o model.ts https://huggingface.co/spaces/Reubencf/Score-Studio/resolve/main/lib/studio/model.ts
4.77 kB
| /** | |
| * 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')}`; | |
| } | |