Reubencf commited on
Commit
739400f
·
verified ·
1 Parent(s): 2cbfcce

Launch Score Studio with Hugging Face sign-in and dedicated ZeroGPU API

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +7 -0
  2. .gitattributes +5 -0
  3. .gitignore +7 -0
  4. Dockerfile +23 -0
  5. README.md +71 -5
  6. app/api/auth/route.ts +12 -0
  7. app/api/media/route.ts +15 -0
  8. app/api/transcribe/route.ts +61 -0
  9. app/api/youtube/route.ts +35 -0
  10. app/auth/callback/route.ts +23 -0
  11. app/auth/login/route.ts +11 -0
  12. app/globals.css +15 -0
  13. app/identity.css +37 -0
  14. app/layout.tsx +6 -0
  15. app/page.tsx +98 -0
  16. app/preview/transcribe/page.tsx +7 -0
  17. components/CreateScoreButton.tsx +7 -0
  18. components/Processing.tsx +50 -0
  19. components/ScoreWorkspace.tsx +121 -0
  20. components/SignIn.tsx +17 -0
  21. components/ThemeToggle.tsx +33 -0
  22. components/TranscriptionPreview.tsx +18 -0
  23. env.example +9 -0
  24. lib/audio.ts +26 -0
  25. lib/auth.ts +47 -0
  26. lib/media.ts +12 -0
  27. lib/performers.ts +70 -0
  28. lib/server-tls.ts +7 -0
  29. lib/types.ts +4 -0
  30. lib/youtube.ts +13 -0
  31. next-env.d.ts +7 -0
  32. next.config.ts +3 -0
  33. package-lock.json +1116 -0
  34. package.json +1 -0
  35. public/LICENSE.abcjs +21 -0
  36. public/fonts/README.md +6 -0
  37. public/fonts/bespoke-stencil-bold.woff2 +0 -0
  38. public/fonts/supreme-regular.woff2 +0 -0
  39. public/performers/drums.png +3 -0
  40. public/performers/guitar.png +3 -0
  41. public/performers/piano.png +3 -0
  42. public/performers/saxophone.png +3 -0
  43. public/saxophonist-sprite.png +3 -0
  44. public/soundfonts/ATTRIBUTION.md +11 -0
  45. public/soundfonts/acoustic_grand_piano-mp3/A0.mp3 +0 -0
  46. public/soundfonts/acoustic_grand_piano-mp3/A1.mp3 +0 -0
  47. public/soundfonts/acoustic_grand_piano-mp3/A2.mp3 +0 -0
  48. public/soundfonts/acoustic_grand_piano-mp3/A3.mp3 +0 -0
  49. public/soundfonts/acoustic_grand_piano-mp3/A4.mp3 +0 -0
  50. public/soundfonts/acoustic_grand_piano-mp3/A5.mp3 +0 -0
.dockerignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .next
3
+ .env*
4
+ .git
5
+ *.tsbuildinfo
6
+ __pycache__
7
+ test-results
.gitattributes CHANGED
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ public/performers/drums.png filter=lfs diff=lfs merge=lfs -text
37
+ public/performers/guitar.png filter=lfs diff=lfs merge=lfs -text
38
+ public/performers/piano.png filter=lfs diff=lfs merge=lfs -text
39
+ public/performers/saxophone.png filter=lfs diff=lfs merge=lfs -text
40
+ public/saxophonist-sprite.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ node_modules/
2
+ .next/
3
+ .env*
4
+ !.env.example
5
+ *.tsbuildinfo
6
+ __pycache__/
7
+ test-results/
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:24-bookworm-slim AS build
2
+ WORKDIR /app
3
+ COPY package.json package-lock.json ./
4
+ RUN npm ci
5
+ COPY . .
6
+ ENV NEXT_TELEMETRY_DISABLED=1
7
+ RUN npm run build
8
+
9
+ FROM node:24-bookworm-slim AS runtime
10
+ RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-venv ffmpeg ca-certificates \
11
+ && python3 -m venv /opt/python \
12
+ && /opt/python/bin/pip install --no-cache-dir yt-dlp==2026.8.19 \
13
+ && rm -rf /var/lib/apt/lists/* \
14
+ && useradd -m -u 1000 studio
15
+ WORKDIR /app
16
+ ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=7860 PYTHON_BIN=/opt/python/bin/python
17
+ COPY --from=build --chown=studio:studio /app/.next/standalone ./
18
+ COPY --from=build --chown=studio:studio /app/.next/static ./.next/static
19
+ COPY --from=build --chown=studio:studio /app/public ./public
20
+ COPY --from=build --chown=studio:studio /app/scripts ./scripts
21
+ USER studio
22
+ EXPOSE 7860
23
+ CMD ["node", "--use-system-ca", "server.js"]
README.md CHANGED
@@ -1,10 +1,76 @@
1
  ---
2
  title: Score Studio
3
- emoji: 🏃
4
- colorFrom: blue
5
- colorTo: blue
6
  sdk: docker
7
- pinned: false
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Score Studio
3
+ emoji: 🎼
4
+ colorFrom: yellow
5
+ colorTo: red
6
  sdk: docker
7
+ app_port: 7860
8
+ short_description: Turn audio and YouTube music into editable sheet music
9
+ models:
10
+ - m-a-p/SheetSage2
11
+ tags:
12
+ - audio-to-score
13
+ - sheet-music
14
+ - music-transcription
15
+ - audio-to-midi
16
  ---
17
 
18
+ # Score Studio
19
+
20
+ Upload music, record with your microphone, or import a public YouTube video.
21
+ Sign in with Hugging Face to use your own ZeroGPU allowance. Play the resulting
22
+ lead sheet with a moving note highlight, edit its notation, undo/redo changes,
23
+ restore the AI original, and download ABC, MIDI or PDF.
24
+
25
+ The interface uses Supreme Regular and Bespoke Stencil Bold from Fontshare,
26
+ follows your device's preferred theme until you choose one, and switches themes
27
+ with an expanding circular wave. Reduced-motion preferences are respected.
28
+
29
+ ## Run locally
30
+
31
+ Requires Node 24, Python 3 with `yt-dlp==2026.8.19`, and FFmpeg/FFprobe on PATH.
32
+ Run `npm ci`, configure values from `env.example` in `.env.local`, then `npm run dev`.
33
+ Open http://localhost:3000. The development-only animation preview is at
34
+ http://localhost:3000/preview/transcribe.
35
+
36
+ ## Hosting
37
+
38
+ The frontend runs on a CPU Docker Space. Inference runs separately on the public
39
+ [Score Studio API](https://huggingface.co/spaces/Reubencf/Score-Studio-API)
40
+ Gradio ZeroGPU Space. The original Audio-to-Score Space remains separate.
41
+
42
+ Before deploying, configure these Space settings:
43
+
44
+ - Secret `SESSION_SECRET`: 32 random bytes encoded as 64 hexadecimal characters.
45
+ - Variable `APP_ORIGIN`: `https://reubencf-score-studio.hf.space`.
46
+ - Optional `HF_API_SPACE`, `HF_API_ORIGIN`, `HF_OAUTH_CLIENT_ID`: defaults point to the dedicated API.
47
+
48
+ The Dockerfile includes Python, yt-dlp, FFmpeg and the standalone Next.js build.
49
+ No owner Hugging Face token is required or used for public requests. The login
50
+ flow uses Hugging Face's Client ID Metadata Documents and authorization code
51
+ with PKCE, requesting only `openid profile`. User access tokens are encrypted in
52
+ HttpOnly cookies, expire within eight hours, and are never returned to frontend JavaScript.
53
+ Serve the app over HTTPS and keep the session secret stable across restarts.
54
+ Login opens the app at its own origin so it does not depend on third-party cookies.
55
+
56
+ YouTube import and transcription routes require a valid session and same-origin
57
+ submission. Imported audio is accessible only to the importing session's account
58
+ and expires after an hour. Backend score downloads expire after 24 hours.
59
+
60
+ ## Limits
61
+
62
+ Audio must be 5 seconds to 10 minutes and under 100 MB. The whole recording is
63
+ validated and submitted; longer input is rejected, not silently truncated.
64
+ Public YouTube videos may be unavailable to a hosted server because of YouTube's
65
+ restrictions. The importer does not bypass login, age or access restrictions;
66
+ users can upload their own audio when import is unavailable.
67
+
68
+ SheetSage2 produces melody and chord lead sheets, not a complete orchestral score.
69
+ Review AI notation before performing. The model weights are CC BY-NC 4.0
70
+ (non-commercial). Fonts are distributed by [Fontshare](https://www.fontshare.com/licenses).
71
+
72
+ ## Checks
73
+
74
+ `npm run typecheck` and `npm run build` verify the production frontend.
75
+ Before publishing, verify real Hugging Face login, full audio/YouTube processing,
76
+ score playback and editing, both themes, keyboard controls and reduced motion.
app/api/auth/route.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {cookies} from 'next/headers';
2
+ import {authorize,cookieOptions,session,SESSION_COOKIE} from '@/lib/auth';
3
+ export const runtime='nodejs';
4
+ export async function GET(){
5
+ const current=await session();
6
+ return Response.json({user:current?.user??null},{headers:{'Cache-Control':'no-store'}});
7
+ }
8
+ export async function DELETE(req:Request){
9
+ const auth=await authorize(req);if(auth.error)return auth.error;
10
+ (await cookies()).set(SESSION_COOKIE,'',cookieOptions(0));
11
+ return Response.json({ok:true},{headers:{'Cache-Control':'no-store'}});
12
+ }
app/api/media/route.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {readFile,stat} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {mediaRoot} from '@/lib/media';
4
+ import {authorize} from '@/lib/auth';
5
+ export const runtime='nodejs';
6
+ export async function GET(req:Request){
7
+ const auth=await authorize(req);if(auth.error)return auth.error;
8
+ const id=new URL(req.url).searchParams.get('id')||'';
9
+ if(!/^[a-f0-9-]{36}$/.test(id))return new Response('Not found',{status:404});
10
+ try{if(await readFile(path.join(mediaRoot,id,'owner'),'utf8')!==auth.session.user.id)return new Response('Not found',{status:404});
11
+ const file=path.join(mediaRoot,id,'audio.wav');const info=await stat(file);
12
+ if(Date.now()-info.mtimeMs>3600000)return new Response('This import expired. Import the video again.',{status:410});
13
+ return new Response(await readFile(file),{headers:{'Content-Type':'audio/wav','Cache-Control':'private, no-store','Content-Disposition':'attachment; filename="youtube-excerpt.wav"'}});
14
+ }catch{return new Response('Not found',{status:404});}
15
+ }
app/api/transcribe/route.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {Client,handle_file} from '@gradio/client';
2
+ import type {ScoreResult,Download} from '@/lib/types';
3
+ import {prepareAudio} from '@/lib/audio';
4
+ import {authorize} from '@/lib/auth';
5
+ export const runtime='nodejs';
6
+ const SPACE=process.env.HF_API_SPACE||'Reubencf/Score-Studio-API';
7
+ const ORIGIN=process.env.HF_API_ORIGIN||'https://reubencf-score-studio-api.hf.space';
8
+ function file(value:unknown):Download|null {
9
+ if(!value||typeof value!=='object')return null;
10
+ const data=value as {url?:string;path?:string;orig_name?:string};
11
+ const url=data.url||(data.path?ORIGIN+'/gradio_api/file='+data.path:'');
12
+ try{if(new URL(url).origin!==ORIGIN)return null;}catch{return null;}
13
+ return {url,name:data.orig_name||data.path?.split('/').pop()||'download'};
14
+ }
15
+ export async function POST(req:Request){
16
+ const auth=await authorize(req);if(auth.error)return auth.error;
17
+ if(Number(req.headers.get('content-length')||0)>102*1024*1024)return Response.json({error:'Choose an audio file under 100 MB.'},{status:413});
18
+ const form=await req.formData().catch(()=>null);
19
+ const input=form?.get('audio');
20
+ if(!(input instanceof File)||input.size>100*1024*1024||!input.size)
21
+ return Response.json({error:'Choose an audio file under 100 MB.'},{status:400});
22
+ const enc=new TextEncoder();let cancelJob:(()=>void)|undefined,closed=false,client:Client|undefined;
23
+ const preparation=new AbortController();
24
+ const stream=new ReadableStream({
25
+ async start(controller){
26
+ const send=(type:string,data:unknown)=>{if(!closed)controller.enqueue(enc.encode(JSON.stringify({type,data})+'\n'));};
27
+ const abort=()=>{closed=true;preparation.abort();cancelJob?.();client?.close();};req.signal.addEventListener('abort',abort,{once:true});
28
+ try{
29
+ if(req.signal.aborted){abort();return;}
30
+ send('status','Reading the full recording…');
31
+ const {audio,duration}=await prepareAudio(input,preparation.signal);
32
+ if(closed)return;
33
+ send('status','Connecting to the transcription studio…');
34
+ client=await Client.connect(SPACE,{events:['status','data'],token:auth.session.token as `hf_${string}`});
35
+ if(req.signal.aborted||closed){client.close();return;}
36
+ const job=client.submit('/transcribe',[handle_file(audio),0,duration,false,true]);
37
+ cancelJob=()=>{job.cancel();};let result:ScoreResult|null=null;
38
+ for await(const event of job){
39
+ if(closed)break;
40
+ if(event.type==='status'){
41
+ if(event.stage==='error')throw new Error(typeof event.message==='string'?event.message:'Conversion failed. Please try again.');
42
+ const details=event as unknown as {progress_data?:{desc?:string}[];position?:number};
43
+ send('status',details.progress_data?.find(p=>p.desc)?.desc|| (event.stage==='pending'?'Waiting for an available GPU…':'Listening and writing your score…'));
44
+ }
45
+ if(event.type==='data'){
46
+ const data=event.data as unknown[];
47
+ if(typeof data[5]==='string'&&data[5].trim()){
48
+ result={abc:data[5],status:String(data[0]),downloads:Array.isArray(data[4])?data[4].map(file).filter((d):d is Download=>!!d):[],pdf:file(data[2])?.url,audio:file(data[3])?.url};
49
+ }
50
+ }
51
+ }
52
+ if(!closed){if(!result)throw new Error('No editable score was returned. Try another recording.');send('result',result);}
53
+ }catch(error){
54
+ let message=error instanceof Error?error.message:'Conversion could not finish. Please try again.';
55
+ if(/quota|GPU.*duration|exceeded/i.test(message))message='Your Hugging Face processing allowance is unavailable for this recording. Try a shorter recording or wait for your allowance to reset.';
56
+ send('error',message.slice(0,600));
57
+ }finally{client?.close();req.signal.removeEventListener('abort',abort);if(!closed)controller.close();}
58
+ },cancel(){closed=true;preparation.abort();cancelJob?.();client?.close();}
59
+ });
60
+ return new Response(stream,{headers:{'Content-Type':'application/x-ndjson','Cache-Control':'no-store','X-Accel-Buffering':'no'}});
61
+ }
app/api/youtube/route.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {spawn} from 'node:child_process';
2
+ import {mkdir,rm,writeFile} from 'node:fs/promises';
3
+ import {authorize} from '@/lib/auth';
4
+ import path from 'node:path';
5
+ import {randomUUID} from 'node:crypto';
6
+ import {youtubeId} from '@/lib/youtube';
7
+ import {mediaRoot,prepareMedia} from '@/lib/media';
8
+ export const runtime='nodejs';
9
+ let active=0;
10
+ export async function POST(req:Request){
11
+ const auth=await authorize(req);if(auth.error)return auth.error;
12
+ if(active>=2)return Response.json({error:'Two imports are in progress. Please try again shortly.'},{status:429});
13
+ const body=await req.json().catch(()=>null);
14
+ const id=typeof body?.url==='string'?youtubeId(body.url):null;
15
+ if(!id)return Response.json({error:'Enter a valid YouTube video link.'},{status:400});
16
+ active++; const ticket=randomUUID(),dir=path.join(mediaRoot,ticket);
17
+ try{
18
+ await prepareMedia();await mkdir(dir);
19
+ const result=await new Promise<Record<string,string>>((resolve,reject)=>{
20
+ const child=spawn(/*turbopackIgnore: true*/ process.env.PYTHON_BIN||'python',['scripts/youtube_audio.py',id,dir],{shell:false,windowsHide:true});
21
+ let output='';let settled=false;
22
+ const finish=(err?:Error,result?:Record<string,string>)=>{if(settled)return;settled=true;clearTimeout(timer);req.signal.removeEventListener('abort',abort);if(err)reject(err);else resolve(result!);};
23
+ const abort=()=>{child.kill();finish(new Error('Import cancelled.'));};
24
+ const timer=setTimeout(()=>{child.kill();finish(new Error('YouTube import timed out. Please try again or upload an audio file.'));},150000);
25
+ req.signal.addEventListener('abort',abort,{once:true});
26
+ child.stdout.on('data',data=>{output+=data.toString();if(output.length>100000){child.kill();finish(new Error('Import returned an invalid response.'));}});
27
+ child.stderr.resume();
28
+ child.on('error',()=>finish(new Error('YouTube importer is unavailable. Please use an audio file.')));
29
+ child.on('close',()=>{try{const result=JSON.parse(output.trim());if(result.error)finish(new Error(result.error));else finish(undefined,result);}catch{finish(new Error('YouTube import failed. Try another public video or upload your audio.'));}});
30
+ });
31
+ await writeFile(path.join(dir,'owner'),auth.session.user.id);
32
+ return Response.json({...result,audioUrl:'/api/media?id='+ticket},{headers:{'Cache-Control':'no-store'}});
33
+ }catch(error){await rm(dir,{recursive:true,force:true});return Response.json({error:error instanceof Error?error.message:'Could not import YouTube audio.'},{status:422});}
34
+ finally{active--;}
35
+ }
app/auth/callback/route.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {cookies} from 'next/headers';
2
+ import {NextResponse} from 'next/server';
3
+ import {appOrigin,CLIENT_ID,FLOW_COOKIE,SESSION_COOKIE,seal,unseal,cookieOptions,type OAuthFlow} from '@/lib/auth';
4
+ export const runtime='nodejs';
5
+ export async function GET(req:Request){
6
+ const jar=await cookies(),params=new URL(req.url).searchParams;
7
+ const flow=unseal<OAuthFlow>(jar.get(FLOW_COOKIE)?.value);
8
+ jar.set(FLOW_COOKIE,'',cookieOptions(0));
9
+ const failure=()=>NextResponse.redirect(appOrigin()+'/?signIn=retry',{headers:{'Cache-Control':'no-store'}});
10
+ if(!flow||flow.expires<Date.now()||params.get('state')!==flow.state||!params.get('code')||params.has('error'))return failure();
11
+ try{
12
+ const response=await fetch('https://huggingface.co/oauth/token',{method:'POST',body:new URLSearchParams({client_id:CLIENT_ID,grant_type:'authorization_code',code:params.get('code')!,redirect_uri:appOrigin()+'/auth/callback',code_verifier:flow.verifier}),cache:'no-store',signal:AbortSignal.timeout(20000)});
13
+ if(!response.ok)return failure();
14
+ const tokens=await response.json();
15
+ if(typeof tokens.access_token!=='string'||!Number.isFinite(tokens.expires_in)||tokens.expires_in<=0)return failure();
16
+ const profile=await fetch('https://huggingface.co/oauth/userinfo',{headers:{Authorization:'Bearer '+tokens.access_token},cache:'no-store',signal:AbortSignal.timeout(15000)});
17
+ if(!profile.ok)return failure();
18
+ const user=await profile.json();if(typeof user.sub!=='string')return failure();
19
+ const age=Math.min(tokens.expires_in,28800);
20
+ jar.set(SESSION_COOKIE,seal({token:tokens.access_token,expires:Date.now()+age*1000,user:{id:user.sub,name:String(user.preferred_username||user.name||'Musician').slice(0,100)}}),cookieOptions(age));
21
+ return NextResponse.redirect(appOrigin(),{headers:{'Cache-Control':'no-store'}});
22
+ }catch{return failure();}
23
+ }
app/auth/login/route.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {cookies} from 'next/headers';
2
+ import {NextResponse} from 'next/server';
3
+ import {appOrigin,CLIENT_ID,FLOW_COOKIE,seal,createFlow,challenge,cookieOptions} from '@/lib/auth';
4
+ export const runtime='nodejs';
5
+ export async function GET(){
6
+ const flow=createFlow();
7
+ (await cookies()).set(FLOW_COOKIE,seal(flow),cookieOptions(600));
8
+ const url=new URL('https://huggingface.co/oauth/authorize');
9
+ url.search=new URLSearchParams({client_id:CLIENT_ID,redirect_uri:appOrigin()+'/auth/callback',response_type:'code',scope:'openid profile',state:flow.state,code_challenge:challenge(flow.verifier),code_challenge_method:'S256'}).toString();
10
+ return NextResponse.redirect(url,{headers:{'Cache-Control':'no-store'}});
11
+ }
app/globals.css ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import 'abcjs/abcjs-audio.css';
2
+ :root{color-scheme:dark;--paper:#101010;--panel:#181818;--surface:#232323;--ink:#f5f4f2;--muted:#a4a39f;--line:#333331;--accent:#fb5729;--blue:var(--accent);--yellow:var(--accent);--sans:Arial,Helvetica,sans-serif}
3
+ :root[data-theme=light]{color-scheme:light;--paper:#fff;--panel:#fff;--surface:#f5f4f1;--ink:#20201e;--muted:#716f69;--line:#dedcd6}
4
+ *{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--paper);font:16px var(--sans);-webkit-font-smoothing:antialiased;transition:background .25s,color .25s}button,input,select,textarea{font:inherit}button,a,input,summary,select{-webkit-tap-highlight-color:transparent}button{cursor:pointer;border:0;background:none;color:inherit}button:disabled{opacity:.4;cursor:not-allowed}a{color:inherit;text-decoration:none}button:focus-visible,a:focus-visible,summary:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:4px}input[type=checkbox]{accent-color:var(--accent)}.app-shell{min-height:100svh}main{width:100%}.theme-toggle{position:fixed;right:25px;top:22px;z-index:10;width:40px;height:40px;border:1px solid var(--line);border-radius:50%;display:grid;place-items:center;background:var(--panel);color:var(--muted)}.theme-toggle:hover{color:var(--accent)}
5
+ .input-view{min-height:100svh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:75px 24px 28px}.upload-panel{width:100%;max-width:590px;padding:32px;background:transparent}.panel-intro h1{font-size:23px;font-weight:500;line-height:1.25;letter-spacing:-.7px;margin:0 0 10px}.panel-intro p{font-size:16px;line-height:1.6;color:var(--muted);margin:0 0 25px}.drop-zone{border:1px dashed #ffffff24;border-radius:21px;background:var(--surface);transition:border-color .2s,background .2s;overflow:hidden}.drop-zone:hover,.drop-zone.drag-over{border-color:var(--accent)}.drop-zone.drag-over{background:color-mix(in srgb,var(--accent) 9%,var(--surface))}:root[data-theme=light] .drop-zone{border-color:#d1cec5}:root[data-theme=light] .drop-zone:hover,:root[data-theme=light] .drop-zone.drag-over{border-color:var(--accent)}.drop-area{width:100%;min-height:265px;padding:30px 18px 18px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:12px}.upload-symbol{width:62px;height:62px;border-radius:50%;background:var(--accent);color:white;display:grid;place-items:center;margin:6px 0 9px}.drop-area strong{font-size:17px;line-height:1.5;font-weight:400;max-width:100%;overflow-wrap:anywhere}.drop-area strong>span{color:var(--muted)}.file-hint{font-family:monospace;color:var(--muted);font-size:13px;line-height:1.9}.size-badge{background:var(--panel);border-radius:5px;padding:4px 7px;margin-left:4px;font-family:var(--sans)}.record-button{display:flex;align-items:center;justify-content:center;gap:7px;color:var(--muted);font-size:12px;margin:0 auto 21px;padding:4px}.record-button:hover{color:var(--ink)}.record-button.recording{color:var(--accent)}.recording svg{animation:record-pulse 1s ease infinite}@keyframes record-pulse{50%{opacity:.35}}
6
+ .or-divider{display:flex;align-items:center;gap:12px;color:#767672;font-size:13px;margin:24px 0}.or-divider:before,.or-divider:after{content:'';height:1px;background:var(--line);flex:1}.url-label{display:block;font-size:15px;color:var(--muted);margin-bottom:10px}.youtube-input{width:100%;height:47px;border:1px solid var(--line);border-radius:13px;background:var(--surface);color:var(--ink);padding:0 15px;font-size:15px;box-shadow:inset 0 0 0 1px #ffffff03}.youtube-input::placeholder{color:#858580}.panel-actions{display:flex;align-items:center;gap:10px;margin-top:28px}.panel-actions>span{font-size:11px;color:var(--muted);margin-right:auto}.clear-button,.primary-button{height:45px;display:flex;align-items:center;justify-content:center;gap:9px;padding:0 17px;border-radius:11px;font-size:14px;white-space:nowrap}.clear-button{background:var(--surface);border:1px solid var(--line)}.primary-button{background:var(--accent);border:1px solid #ff926c;color:white;box-shadow:inset 0 2px 4px #ffffff36,0 2px 6px #0002}.primary-button:hover:not(:disabled){background:#ff683c}.primary-button:disabled{opacity:.65}.input-footer{margin-top:24px;text-align:center}.text-button{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);padding:7px}.text-button:hover{color:var(--ink)}.error-message{margin:18px 0;padding:12px 14px;border:1px solid #b752324f;background:color-mix(in srgb,var(--accent) 7%,var(--panel));border-radius:10px;color:var(--ink);font-size:13px;line-height:1.6;display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.error-message p{margin:0}.error-message button{padding:3px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
7
+ .view-enter{animation:enter .5s ease both}.fade-out{animation:leave .32s ease both;pointer-events:none}@keyframes enter{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}@keyframes leave{to{opacity:0;transform:translateY(-12px);filter:blur(5px)}}.processing{min-height:100svh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:40px 24px}.processing .musician-canvas{width:min(350px,85vw);height:auto;aspect-ratio:1;margin:-35px 0 8px}.process-label{font-family:monospace;letter-spacing:2px;font-size:12px;color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);padding:8px 12px;border-radius:3px}.processing p{font-size:14px;color:var(--muted);max-width:430px;line-height:1.7;min-height:45px;margin:19px 0 9px}
8
+ .workspace{max-width:1280px;margin:auto;padding:47px 36px 50px}.workspace-heading{display:flex;align-items:center;justify-content:space-between;gap:30px;margin:0 3px 25px}.workspace-heading h1{font-size:32px;letter-spacing:-1px;margin:13px 0 8px;line-height:1.3}.workspace-heading p{font-size:13px;color:var(--muted);margin:0}.workspace-heading p span{margin:0 7px}.quiet-button{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:13px;white-space:nowrap}.transport{border-radius:14px;background:#252628;color:#cfd0d2;display:flex;align-items:center;gap:6px;padding:11px 14px;box-shadow:0 8px 22px #121a2417;min-height:68px}.transport-icon{height:36px;width:36px;min-width:32px;display:grid;place-items:center;border-radius:6px;color:#aaadb1}.transport-icon:hover{background:#383a3d;color:white}.transport-icon.active{color:var(--yellow);background:#393b33}.play-button{color:white}.cover{width:37px;height:37px;flex-shrink:0;border-radius:7px;overflow:hidden;background:#434749;display:grid;place-items:center;color:var(--yellow);margin:0 10px}.cover img{width:100%;height:100%;object-fit:cover}.track-progress{position:relative;border-radius:9px;height:39px;flex:1;min-width:80px;background:#353638;overflow:hidden}.progress-fill{position:absolute;inset:0 auto 0 0;background:var(--yellow);pointer-events:none}.track-label{position:absolute;inset:0;display:flex;gap:20px;align-items:center;justify-content:space-between;padding:0 13px;color:white;mix-blend-mode:difference;pointer-events:none;font-size:13px}.track-label strong{font-size:14px;font-weight:500;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.track-label span{white-space:nowrap;font-variant-numeric:tabular-nums}.track-progress input{opacity:0;width:100%;height:100%;margin:0;cursor:pointer}.track-progress:focus-within{outline:3px solid #8390ff;outline-offset:2px}.volume{display:flex;align-items:center;border-radius:7px;background:#353638;margin-left:5px;padding:0 7px 0 0}.volume input{width:60px;accent-color:white}.under-player{display:flex;align-items:center;justify-content:space-between;padding:15px 3px 25px;font-size:12px;color:#8b9099}.under-player label{display:flex;gap:7px;align-items:center}.under-player select{font-size:12px;border:1px solid var(--line);border-radius:5px;background:white;padding:4px;color:#6a7079}.score-toolbar{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--line);background:#fff;border-radius:10px 10px 0 0;padding:9px 12px;gap:10px}.toolbar-group{display:flex;align-items:center;gap:4px}.toolbar-group>button,.download-menu summary{font-size:12px;padding:8px 10px;display:flex;align-items:center;gap:7px;white-space:nowrap}.toolbar-group button:hover,.download-menu summary:hover,.chosen{background:#f1f3f7;border-radius:5px}.divider{height:18px;width:1px;background:var(--line);margin:0 5px}.follow{display:flex;align-items:center;font-size:12px;color:#707780;gap:4px;margin-right:10px}.download-menu{position:relative}.download-menu summary{cursor:pointer;list-style:none}.download-menu summary::-webkit-details-marker{display:none}.download-menu>div{position:absolute;right:0;top:40px;background:white;border:1px solid var(--line);border-radius:8px;padding:7px;z-index:5;box-shadow:0 6px 20px #0001;min-width:210px}.download-menu a,.download-menu div button{font-size:13px;padding:12px;display:block;width:100%;text-align:left;border-radius:4px}.download-menu a:hover{background:#f0f2f7}.edit-help{display:flex;align-items:center;justify-content:space-between;min-height:46px;border:1px solid var(--line);border-top:0;background:#fafbfc;padding:8px 18px;font-size:12px;color:#969ca4;gap:15px}.note-actions{display:flex;align-items:center;gap:6px}.note-actions button{border:1px solid var(--line);background:white;display:flex;padding:5px 9px;border-radius:4px;font-size:12px;white-space:nowrap}.score-layout{display:flex;border:1px solid var(--line);border-top:0;border-radius:0 0 10px 10px;overflow:hidden;background:#edeef1}.score-scroll{flex:1;min-width:0;height:620px;overflow:auto;padding:26px}.paper{background:white;min-height:560px;padding:20px 30px;box-shadow:0 4px 16px #2233440a;min-width:600px}.paper svg{width:100%;overflow:visible}.current-note{fill:#1d36d6!important;stroke:#1d36d6!important}.playhead{stroke:#d5b509;stroke-width:2;pointer-events:none}.selected-note{fill:#cd7815!important;stroke:#cd7815!important}.paper .abcjs-note{cursor:pointer}.notation-editor{width:350px;flex-shrink:0;background:#fafbfc;border-left:1px solid var(--line);padding:20px;display:flex;flex-direction:column;min-height:600px}.notation-editor>div{display:flex;justify-content:space-between;font-size:14px;align-items:center}.notation-editor p{font-size:12px;color:#9197a1;line-height:1.6}.notation-editor textarea{width:100%;flex:1;resize:vertical;min-height:360px;font:13px/1.8 monospace;border:1px solid var(--line);border-radius:5px;background:white;padding:13px;color:#4e596c;tab-size:2}.notation-editor>span{font-size:12px;line-height:1.6;color:#949aa3;margin-top:12px}.edit-notice{padding:10px 18px;margin:0;border:1px solid var(--line);font-size:13px;color:#6f7785;background:#fafbfc}.original-audio{margin-top:25px;color:var(--muted);font-size:13px}.original-audio summary{cursor:pointer}.original-audio p{font-size:12px}.original-audio audio{width:min(500px,100%)}.loading-score{padding:100px;text-align:center;color:var(--muted)}.spinner{height:17px;width:17px;border:2px solid #888;border-right-color:var(--yellow);border-radius:50%;animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
9
+
10
+ .eyebrow{font-size:11px;letter-spacing:1.6px;color:var(--accent)}.workspace{padding-top:78px}.score-toolbar,.download-menu>div,.note-actions button,.under-player select,.notation-editor textarea{background:var(--panel);color:var(--ink)}.toolbar-group button:hover,.download-menu summary:hover,.chosen,.download-menu a:hover{background:var(--surface)}.edit-help,.notation-editor,.edit-notice{background:var(--surface);color:var(--muted)}.score-layout{background:var(--surface)}.paper{color:#202020}.current-note{fill:#e34b1c!important;stroke:#e34b1c!important}.playhead{stroke:var(--accent)}.selected-note{fill:#b85319!important;stroke:#b85319!important}.track-progress:focus-within{outline-color:var(--accent)}.follow{color:var(--muted)}.track-label{mix-blend-mode:normal;text-shadow:0 1px 3px #0008}
11
+ @media(max-width:900px){.workspace{padding:78px 18px 30px}.score-toolbar{flex-wrap:wrap}.with-editor{flex-direction:column}.notation-editor{width:100%;min-height:400px;border-left:0;border-top:1px solid var(--line)}}
12
+ @media(max-width:600px){.theme-toggle{right:17px;top:16px;width:36px;height:36px}.input-view{padding:70px 16px 25px}.upload-panel{padding:25px 21px;border-radius:24px}.panel-intro h1{font-size:21px}.panel-intro p{font-size:14px}.drop-area{min-height:235px;padding:25px 12px 15px}.drop-area strong{font-size:15px}.drop-area strong>span{display:block}.file-hint{font-size:11px}.panel-actions{flex-wrap:wrap;gap:8px;margin-top:22px}.panel-actions>span{flex-basis:100%;margin:0 0 6px}.clear-button{margin-left:auto}.primary-button{flex:1;max-width:180px}.or-divider{margin:20px 0}.workspace-heading{align-items:flex-start;gap:10px}.workspace-heading h1{font-size:24px}.quiet-button{font-size:11px}.workspace-heading p{font-size:12px}.transport{padding:9px 7px;gap:1px;border-radius:10px;flex-wrap:wrap}.cover{margin:0 5px;width:30px;height:32px}.track-progress{height:36px;flex-basis:55%}.track-label{padding:0 9px;gap:8px;font-size:11px}.track-label strong{font-size:12px}.track-label span:last-child{display:none}.transport-icon{width:30px;min-width:26px}.volume{margin-left:auto;margin-top:6px}.transport>.transport-icon.active,.transport>.transport-icon:nth-last-child(2){margin-top:6px}.under-player{font-size:10px;gap:10px}.score-toolbar{padding:7px;gap:4px}.toolbar-group>button,.download-menu summary{font-size:11px;padding:8px 6px}.follow{font-size:11px;gap:3px;margin-right:2px}.score-scroll{padding:12px;height:540px}.paper{padding:15px;min-width:560px}.edit-help{font-size:11px;flex-wrap:wrap}}
13
+ @media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
14
+ .process-label+.text-button{margin-top:16px}.performer-unavailable{min-height:350px;display:grid;place-items:center;color:var(--muted);font-size:13px}
15
+ @media print{body{background:white}.theme-toggle,.workspace-heading,.transport,.under-player,.score-toolbar,.edit-help,.notation-editor,.edit-notice,.original-audio,.error-message{display:none!important}.workspace{padding:0;max-width:none}.score-layout{border:0;display:block;background:white}.score-scroll{height:auto;overflow:visible;padding:0}.paper{padding:0;box-shadow:none;min-width:0}.paper svg{break-inside:avoid}.playhead{display:none}.current-note,.selected-note{fill:black!important;stroke:black!important}}
app/identity.css ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @font-face{font-family:Supreme;src:url('/fonts/supreme-regular.woff2') format('woff2');font-weight:400;font-style:normal;font-display:swap}
2
+ @font-face{font-family:'Bespoke Stencil';src:url('/fonts/bespoke-stencil-bold.woff2') format('woff2');font-weight:700;font-style:normal;font-display:swap}
3
+ :root{--sans:Supreme,Arial,sans-serif;--display:'Bespoke Stencil',Supreme,sans-serif}
4
+ body{transition:none}
5
+ h1,h2,h3,.panel-intro h1{font-family:var(--display);font-weight:700;letter-spacing:-.025em}
6
+ .panel-intro h1{font-size:27px;line-height:1.2}
7
+ .file-hint,.process-label{font-family:var(--sans)}
8
+ ::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}
9
+ ::view-transition-old(root){z-index:1}
10
+ ::view-transition-new(root){z-index:2}
11
+ ::view-transition-group(root){animation-duration:0s}
12
+ .account-button{position:fixed;top:22px;right:77px;width:40px;height:40px;z-index:10;display:grid;place-items:center;color:var(--muted);border-radius:50%}
13
+ .account-button:hover{color:var(--accent);background:var(--surface)}
14
+ .auth-loading{min-height:100svh;display:grid;place-items:center;color:var(--accent)}
15
+ .sign-in-view{min-height:100svh;display:flex;justify-content:center;align-items:center;padding:90px 28px 50px}
16
+ .sign-in-content{width:100%;max-width:440px}
17
+ .sign-in-symbol{display:grid;place-items:center;color:var(--accent);width:64px;height:64px;border-radius:20px;background:color-mix(in srgb,var(--accent) 12%,transparent);margin-bottom:28px}
18
+ .sign-in-content h1{font-size:clamp(43px,7vw,61px);line-height:1.06;margin:0 0 23px}
19
+ .sign-in-content>p{max-width:370px;font-size:18px;line-height:1.6;color:var(--muted);margin:0 0 29px}
20
+ .hf-sign-in{display:inline-flex;gap:12px;align-items:center;justify-content:center;padding:17px 21px;border-radius:100px;background:var(--accent);color:white;font-size:16px;box-shadow:inset 0 1px 1px #ffffff50,0 4px 14px #0001;transition:transform .2s,background .2s}
21
+ .hf-sign-in>span{font-size:23px;line-height:1}
22
+ .hf-sign-in:hover{transform:translateY(-2px);background:#ed491d}
23
+ .sign-in-content>.sign-in-note{font-size:13px;line-height:1.7;margin:20px 0 0}
24
+ .create-score-pill{height:45px;width:182px;flex-shrink:0;position:relative;overflow:hidden;border-radius:100px;background:linear-gradient(#343434,#111 60%);border:1px solid #484848;color:#fff;font-size:13px;box-shadow:inset 0 1px 1px #ffffff38,0 3px 7px #0002;transition:box-shadow .3s,transform .3s}
25
+ .pill-rest,.pill-hover{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;transition:opacity .3s,transform .4s cubic-bezier(.22,.61,.36,1)}
26
+ .pill-rest{font-size:13px;letter-spacing:.07em;text-transform:uppercase}
27
+ .pill-hover{gap:9px;opacity:0;transform:translateY(28px);font-size:12px;white-space:nowrap}
28
+ .pill-circles{display:flex;padding-left:7px}
29
+ .pill-circles>span{height:27px;width:27px;display:grid;place-items:center;border:2px solid #252525;border-radius:50%;background:#f9ab82;color:#27211e;margin-left:-7px;font-size:8px}
30
+ .pill-circles>span:nth-child(2){background:var(--accent);color:white}.pill-circles>span:last-child{background:#fff;color:#191919}
31
+ .create-score-pill:is(:hover,:focus-visible):not(:disabled) .pill-rest{opacity:0;transform:translateY(-28px)}
32
+ .create-score-pill:is(:hover,:focus-visible):not(:disabled) .pill-hover{opacity:1;transform:translateY(0)}
33
+ .create-score-pill:hover:not(:disabled){box-shadow:inset 0 1px 1px #ffffff38,0 5px 12px #0003;transform:translateY(-1px)}
34
+ .create-score-pill:active:not(:disabled){transform:translateY(0)}
35
+ .create-score-pill:disabled{opacity:.5}
36
+ @media(max-width:560px){.panel-actions{flex-wrap:wrap}.panel-actions>span{width:100%;flex-basis:100%;margin-bottom:5px}.panel-actions>.clear-button{margin-right:auto}.create-score-pill{width:182px}.panel-intro h1{font-size:26px}.sign-in-content h1{font-size:46px}.hf-sign-in{font-size:14px;padding:16px 17px}}
37
+ @media(prefers-reduced-motion:reduce){.pill-rest,.pill-hover,.create-score-pill,.hf-sign-in{transition:none}}
app/layout.tsx ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import type { Metadata } from 'next';
2
+ import './globals.css';
3
+ import './identity.css';
4
+ export const metadata: Metadata = { title: 'Score Studio — audio into notation', description: 'Turn a recording or YouTube audio into editable sheet music. Listen, follow the notes, and make it your own.' };
5
+ const themeScript=`try{const t=localStorage.getItem('score-theme');document.documentElement.dataset.theme=t==='dark'||t==='light'?t:matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}catch{document.documentElement.dataset.theme=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}`;
6
+ export default function Layout({children}:{children:React.ReactNode}) { return <html lang="en" suppressHydrationWarning><head><script dangerouslySetInnerHTML={{__html:themeScript}}/></head><body>{children}</body></html>; }
app/page.tsx ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+ import {useEffect,useRef,useState} from 'react';
3
+ import {Check,CloudUpload,Mic,Square,X,LogOut} from 'lucide-react';
4
+ import ThemeToggle from '@/components/ThemeToggle';
5
+ import SignIn from '@/components/SignIn';
6
+ import CreateScoreButton from '@/components/CreateScoreButton';
7
+ import dynamic from 'next/dynamic';
8
+ import Processing from '@/components/Processing';
9
+ import {choosePerformer,type PerformerId} from '@/lib/performers';
10
+ import {youtubeId} from '@/lib/youtube';
11
+ import {type Source,type ScoreResult} from '@/lib/types';
12
+ const ScoreWorkspace=dynamic(()=>import('@/components/ScoreWorkspace'),{ssr:false,loading:()=> <p className="loading-score">Opening your score…</p>});
13
+ type Stage='input'|'leaving'|'processing'|'score';
14
+ export default function Home(){
15
+ const [stage,setStage]=useState<Stage>('input'),[file,setFile]=useState<File|null>(null),[url,setUrl]=useState(''),[error,setError]=useState(''),[message,setMessage]=useState('Preparing your music…');
16
+ const [drag,setDrag]=useState(false),[recording,setRecording]=useState(false),[recordTime,setRecordTime]=useState(0),[micPending,setMicPending]=useState(false);
17
+ const [user,setUser]=useState<{id:string;name:string}|null>(null),[authReady,setAuthReady]=useState(false);
18
+ const [source,setSource]=useState<Source>({title:''}),[result,setResult]=useState<ScoreResult|null>(null);
19
+ const [performer,setPerformer]=useState<PerformerId|null>(null);
20
+ const picker=useRef<HTMLInputElement>(null),abort=useRef<AbortController|null>(null),media=useRef<MediaRecorder|null>(null),tracks=useRef<MediaStream|null>(null),localURL=useRef<string|null>(null),busy=useRef(false),mounted=useRef(true);
21
+ useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;abort.current?.abort();tracks.current?.getTracks().forEach(t=>t.stop());if(localURL.current)URL.revokeObjectURL(localURL.current);};},[]);
22
+ useEffect(()=>{let active=true;const check=()=>fetch('/api/auth',{cache:'no-store'}).then(r=>r.json()).then(data=>{if(active){setUser(data.user);setAuthReady(true);}}).catch(()=>{if(active)setAuthReady(true);});check();window.addEventListener('focus',check);return()=>{active=false;window.removeEventListener('focus',check);};},[]);
23
+ async function signOut(){const response=await fetch('/api/auth',{method:'DELETE'});if(response.ok||response.status===401){reset();setUser(null);setFile(null);setUrl('');}}
24
+ useEffect(()=>{if(!recording)return;const tick=setInterval(()=>setRecordTime(t=>t+1),1000);return()=>clearInterval(tick);},[recording]);
25
+ useEffect(()=>{if(recordTime>=600&&media.current?.state==='recording')media.current.stop();},[recordTime]);
26
+ function selectFile(next?:File){if(!next)return;if(next.size>100*1024*1024){setError('Choose an audio file under 100 MB.');return;}if(!next.type.startsWith('audio/')&&!/\.(wav|mp3|m4a|ogg|flac|aac|webm|mp4)$/i.test(next.name)){setError('Choose an audio recording, such as MP3, WAV, M4A, OGG, or FLAC.');return;}setFile(next);setUrl('');setError('');}
27
+ async function record(){
28
+ if(recording){media.current?.stop();return;}
29
+ if(!navigator.mediaDevices?.getUserMedia){setError('Recording needs a supported browser and a secure connection. You can upload a file instead.');return;}
30
+ setMicPending(true);setError('');
31
+ try{const stream=await navigator.mediaDevices.getUserMedia({audio:true});if(!mounted.current){stream.getTracks().forEach(t=>t.stop());return;}tracks.current=stream;
32
+ const recorder=new MediaRecorder(stream),parts:Blob[]=[];media.current=recorder;
33
+ recorder.ondataavailable=e=>{if(e.data.size)parts.push(e.data);};
34
+ recorder.onstop=()=>{const type=recorder.mimeType||'audio/webm';selectFile(new File(parts,`My recording.${type.includes('mp4')?'m4a':'webm'}`,{type}));stream.getTracks().forEach(t=>t.stop());setRecording(false);};
35
+ recorder.start(1000);setRecordTime(0);setRecording(true);
36
+ }catch{setError('Microphone access was not available. Allow it in your browser or upload a recording.');}finally{setMicPending(false);}
37
+ }
38
+ function reset(){abort.current?.abort();busy.current=false;setStage('input');setError('');setResult(null);if(localURL.current){URL.revokeObjectURL(localURL.current);localURL.current=null;}}
39
+ async function convert(kind:'file'|'youtube'){
40
+ if(busy.current||recording||!user)return;
41
+ if(kind==='file'&&!file){picker.current?.click();return;}
42
+ if(kind==='youtube'&&!youtubeId(url)){setError('Paste a link to a YouTube video, for example youtube.com/watch?v=… or youtu.be/…');return;}
43
+ busy.current=true;const controller=new AbortController();abort.current=controller;setPerformer(choosePerformer(performer));setError('');setStage('leaving');
44
+ await new Promise(r=>setTimeout(r,320));if(controller.signal.aborted)return;setStage('processing');
45
+ let submitted=file!,meta:Source={title:file?.name.replace(/\.[^.]+$/,'')||'Your recording'};
46
+ try{
47
+ if(kind==='youtube'){
48
+ setMessage('Getting the audio and artwork from YouTube…');
49
+ const response=await fetch('/api/youtube',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url}),signal:controller.signal});const data=await response.json();
50
+ if(!response.ok){if(response.status===401)setUser(null);throw new Error(data.error||'Could not import this video.');}meta=data;
51
+ const audioResponse=await fetch(data.audioUrl,{signal:controller.signal});if(!audioResponse.ok)throw new Error('The imported audio expired. Please try again.');
52
+ submitted=new File([await audioResponse.blob()],'youtube-audio.wav',{type:'audio/wav'});
53
+ }
54
+ if(localURL.current)URL.revokeObjectURL(localURL.current);localURL.current=URL.createObjectURL(submitted);meta.audioUrl=localURL.current;
55
+ setMessage('Preparing the full recording…');
56
+ const form=new FormData();form.append('audio',submitted);
57
+ const response=await fetch('/api/transcribe',{method:'POST',body:form,signal:controller.signal});
58
+ if(!response.ok){if(response.status===401)setUser(null);const data=await response.json();throw new Error(data.error||'Conversion could not start.');}
59
+ if(!response.body)throw new Error('The connection closed. Please try again.');
60
+ const reader=response.body.getReader(),decoder=new TextDecoder();let pending='',finished:ScoreResult|null=null;
61
+ while(true){const {done,value}=await reader.read();pending+=decoder.decode(value,{stream:!done});const lines=pending.split('\n');pending=lines.pop()||'';
62
+ for(const line of lines){if(!line.trim())continue;const event=JSON.parse(line);if(event.type==='error')throw new Error(event.data);if(event.type==='status')setMessage(event.data);if(event.type==='result')finished=event.data;}
63
+ if(done)break;
64
+ }
65
+ if(!finished)throw new Error('The connection ended before a score was ready. Please try again.');
66
+ if(!controller.signal.aborted){setResult(finished);setSource(meta);setStage('score');}
67
+ }catch(e){if(!controller.signal.aborted){setError(e instanceof Error?e.message:'Something went wrong. Please try again.');setStage('input');}}
68
+ finally{if(abort.current===controller)busy.current=false;}
69
+ }
70
+ return <div className="app-shell">
71
+ <ThemeToggle/>
72
+ {user&&stage==='input'&&<button className="account-button" onClick={signOut} aria-label={`Sign out ${user.name}`} title={`Signed in as ${user.name} · Sign out`}><LogOut size={17}/></button>}
73
+ <main>
74
+ {!authReady?<div className="auth-loading" aria-label="Checking sign-in" role="status"><span className="spinner"/></div>:!user?<SignIn/>:null}
75
+ {user&&(stage==='input'||stage==='leaving')&&<section className={`input-view ${stage==='leaving'?'fade-out':'view-enter'}`}>
76
+ <form className="upload-panel" onSubmit={e=>{e.preventDefault();convert(file?'file':'youtube');}}>
77
+ <div className="panel-intro"><h1>Turn audio into sheet music.</h1><p>Upload a recording or paste a YouTube link.<br/>Make the notes your own.</p></div>
78
+ <div className={`drop-zone ${drag?'drag-over':''} ${file?'has-file':''}`} onDragOver={e=>{e.preventDefault();if(!recording)setDrag(true);}} onDragLeave={e=>{if(!e.currentTarget.contains(e.relatedTarget as Node))setDrag(false);}} onDrop={e=>{e.preventDefault();setDrag(false);if(!recording&&stage==='input')selectFile(e.dataTransfer.files[0]);}}>
79
+ <button type="button" className="drop-area" onClick={()=>picker.current?.click()} disabled={recording||stage==='leaving'}>
80
+ <span className="upload-symbol">{file?<Check size={27}/>:<CloudUpload size={27}/>}</span>
81
+ <strong>{file?file.name:<>Click to upload <span>or just use drag &amp; drop.</span></>}</strong>
82
+ <span className="file-hint">{file?`${(file.size/1024/1024).toFixed(1)} MB · click to change`:<>MP3, WAV, M4A &amp; more <span className="size-badge">Max 100 MB</span></>}</span>
83
+ </button>
84
+ <input ref={picker} className="sr-only" type="file" accept="audio/*,.mp4,.webm" aria-label="Upload audio recording" onChange={e=>selectFile(e.target.files?.[0])}/>
85
+ <button type="button" className={`record-button ${recording?'recording':''}`} onClick={record} disabled={micPending||stage==='leaving'}>{recording?<Square size={13} fill="currentColor"/>:<Mic size={15}/>} {recording?`Stop recording · ${Math.floor(recordTime/60)}:${String(recordTime%60).padStart(2,'0')}`:micPending?'Connecting microphone…':'Or record with your microphone'}</button>
86
+ </div>
87
+ <div className="or-divider"><span>OR</span></div>
88
+ <label className="url-label" htmlFor="youtube-url">Import from YouTube</label>
89
+ <input className="youtube-input" id="youtube-url" type="url" placeholder="Paste a YouTube link here" value={url} onChange={e=>{setUrl(e.target.value);if(e.target.value)setFile(null);setError('');}} disabled={stage==='leaving'||recording}/>
90
+ {error&&<div className="error-message" role="alert"><p>{error}</p><button type="button" onClick={()=>setError('')} aria-label="Dismiss error"><X size={17}/></button></div>}
91
+ <div className="panel-actions"><span>Full audio · up to 10 min</span><button type="button" className="clear-button" disabled={(!file&&!url)||recording||stage==='leaving'} onClick={()=>{setFile(null);setUrl('');setError('');if(picker.current)picker.current.value='';}}>Clear</button><CreateScoreButton disabled={(!file&&!url)||recording||micPending||stage==='leaving'}/></div>
92
+ </form>
93
+ </section>}
94
+ {user&&stage==='processing'&&<Processing message={message} onCancel={reset} performer={performer??'saxophone'}/>}
95
+ {user&&stage==='score'&&result&&<ScoreWorkspace result={result} source={source} onNew={reset}/>}
96
+ </main>
97
+ </div>;
98
+ }
app/preview/transcribe/page.tsx ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import {notFound} from 'next/navigation';
2
+ import TranscriptionPreview from '@/components/TranscriptionPreview';
3
+
4
+ export default function Page(){
5
+ if(process.env.NODE_ENV!=='development')notFound();
6
+ return <TranscriptionPreview/>;
7
+ }
components/CreateScoreButton.tsx ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import {AudioLines,Music2} from 'lucide-react';
2
+ export default function CreateScoreButton({disabled}:{disabled:boolean}){
3
+ return <button type="submit" className="create-score-pill" disabled={disabled} aria-label="Create score">
4
+ <span className="pill-rest" aria-hidden="true">Create score</span>
5
+ <span className="pill-hover" aria-hidden="true"><span className="pill-circles"><span><Music2 size={15}/></span><span><AudioLines size={16}/></span><span>YOU</span></span><span>Let’s transcribe</span></span>
6
+ </button>;
7
+ }
components/Processing.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+ import {useEffect,useRef,useState} from 'react';
3
+ import {coverageAt,GRID_SIZE,loadPerformer,LOOP_SECONDS,PERFORMERS,POSE_COUNT,type PerformerId} from '@/lib/performers';
4
+
5
+ type Props={message?:string;onCancel:()=>void;cancelLabel?:string;performer?:PerformerId};
6
+ export default function Processing({message,onCancel,cancelLabel='Cancel conversion',performer='saxophone'}:Props){
7
+ const canvas=useRef<HTMLCanvasElement>(null);
8
+ const [unavailable,setUnavailable]=useState(false);
9
+ const person=PERFORMERS.find(item=>item.id===performer)!.person;
10
+ useEffect(()=>{
11
+ const surface=canvas.current;if(!surface)return;
12
+ const ctx=surface.getContext('2d');if(!ctx){setUnavailable(true);return;}
13
+ const reduced=matchMedia('(prefers-reduced-motion: reduce)');
14
+ let frame=0,disposed=false;
15
+ setUnavailable(false);
16
+ delete surface.dataset.particles;
17
+ ctx.clearRect(0,0,GRID_SIZE,GRID_SIZE);
18
+ loadPerformer(performer).then(dots=>{
19
+ if(disposed)return;
20
+ const scale=Math.min(devicePixelRatio||1,2);
21
+ surface.width=GRID_SIZE*scale;surface.height=GRID_SIZE*scale;
22
+ ctx.setTransform(scale,0,0,scale,0,0);
23
+ surface.dataset.particles=String(dots.length);
24
+ surface.dataset.poses=String(POSE_COUNT);
25
+ const began=performance.now();
26
+ function draw(now:number){
27
+ if(disposed||!ctx)return;
28
+ const seconds=reduced.matches?0:Math.max(0,now-began)/1000;
29
+ const position=(seconds/LOOP_SECONDS%1)*POSE_COUNT;
30
+ ctx.clearRect(0,0,GRID_SIZE,GRID_SIZE);
31
+ ctx.fillStyle=getComputedStyle(surface!).getPropertyValue('--accent').trim()||'#fa5425';
32
+ for(const dot of dots){
33
+ const radius=3.35*Math.sqrt(coverageAt(dot.coverage,position));
34
+ if(radius<.08)continue;
35
+ ctx.beginPath();ctx.arc(dot.x,dot.y,radius,0,Math.PI*2);ctx.fill();
36
+ }
37
+ frame=requestAnimationFrame(draw);
38
+ }
39
+ frame=requestAnimationFrame(draw);
40
+ }).catch(()=>{if(!disposed)setUnavailable(true);});
41
+ return()=>{disposed=true;cancelAnimationFrame(frame);};
42
+ },[performer]);
43
+ return <section className="processing view-enter" aria-live="polite">
44
+ <canvas ref={canvas} className="musician-canvas" data-performer={performer} role="img" aria-label={`A ${person} animated by dots growing and shrinking on a fixed grid`} hidden={unavailable}/>
45
+ {unavailable&&<div className="performer-unavailable" role="status">The animation could not load.</div>}
46
+ <div className="process-label">PROCESSING</div>
47
+ {message&&<p>{message}</p>}
48
+ <button className="text-button" onClick={onCancel}>{cancelLabel}</button>
49
+ </section>;
50
+ }
components/ScoreWorkspace.tsx ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+ import {useCallback,useEffect,useRef,useState} from 'react';
3
+ import {ArrowDown,ArrowUp,ChevronDown,Code2,Download,Music2,Pause,Play,Printer,Redo2,Repeat2,RotateCcw,SkipBack,Undo2,Volume2,VolumeX,X} from 'lucide-react';
4
+ import type {NoteTimingEvent,TuneObject} from 'abcjs';
5
+ import type {Source,ScoreResult} from '@/lib/types';
6
+
7
+ type AbcLibrary=typeof import('abcjs');
8
+ type Selection={start:number;end:number;token:string};
9
+ const clock=(seconds:number)=>`${Math.floor(seconds/60)}:${String(Math.floor(seconds%60)).padStart(2,'0')}`;
10
+ function save(text:string,name:string,type='text/plain'){
11
+ const url=URL.createObjectURL(new Blob([text],{type})),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
12
+ }
13
+
14
+ export default function ScoreWorkspace({result,source,onNew}:{result:ScoreResult;source:Source;onNew:()=>void}){
15
+ const [history,setHistory]=useState([result.abc]),[index,setIndex]=useState(0);
16
+ const abc=history[index],edited=abc!==result.abc;
17
+ const [codeOpen,setCodeOpen]=useState(false),[selection,setSelection]=useState<Selection|null>(null);
18
+ const [playing,setPlaying]=useState(false),[loading,setLoading]=useState(false),[position,setPosition]=useState(0),[total,setTotal]=useState(0);
19
+ const [speed,setSpeed]=useState(100),[volume,setVolume]=useState(.7),[loop,setLoop]=useState(false),[follow,setFollow]=useState(true);
20
+ const [problem,setProblem]=useState(''),[notice,setNotice]=useState(''),[ready,setReady]=useState(false);
21
+ const paper=useRef<HTMLDivElement>(null),scoreScroll=useRef<HTMLDivElement>(null),textarea=useRef<HTMLTextAreaElement>(null);
22
+ const lib=useRef<AbcLibrary|null>(null),tune=useRef<TuneObject|null>(null),audio=useRef<AudioContext|null>(null),gain=useRef<GainNode|null>(null),node=useRef<AudioBufferSourceNode|null>(null),buffer=useRef<AudioBuffer|null>(null);
23
+ const timings=useRef<NoteTimingEvent[]>([]),offset=useRef(0),started=useRef(0),isPlaying=useRef(false),generation=useRef(0),loopRef=useRef(loop),followRef=useRef(follow),lastEvent=useRef(-1);
24
+ loopRef.current=loop;followRef.current=follow;
25
+ const mark=useCallback((seconds:number)=>{
26
+ const events=timings.current,ms=seconds*1000;let lo=0,hi=events.length-1,found=-1;
27
+ while(lo<=hi){const mid=(lo+hi)>>1;if(events[mid].milliseconds<=ms){found=mid;lo=mid+1;}else hi=mid-1;}
28
+ if(found===lastEvent.current)return;lastEvent.current=found;
29
+ paper.current?.querySelectorAll('.current-note').forEach(n=>n.classList.remove('current-note'));
30
+ const event=events[found];if(!event||event.type==='end'||event.left==null)return;
31
+ event.elements?.flat().forEach(el=>el?.classList.add('current-note'));
32
+ const svg=paper.current?.querySelector('svg');if(!svg)return;
33
+ let line=svg.querySelector('.playhead');if(!line){line=document.createElementNS('http://www.w3.org/2000/svg','line');line.setAttribute('class','playhead');svg.append(line);}
34
+ line.setAttribute('x1',String(event.left-3));line.setAttribute('x2',String(event.left-3));line.setAttribute('y1',String(event.top||0));line.setAttribute('y2',String((event.top||0)+(event.height||0)));
35
+ const pane=scoreScroll.current;if(followRef.current&&pane){const rect=line.getBoundingClientRect(),bound=pane.getBoundingClientRect();if(rect.top<bound.top+30||rect.bottom>bound.bottom-30)pane.scrollBy({top:rect.top-bound.top-70,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});}
36
+ },[]);
37
+ const stop=useCallback(()=>{
38
+ if(isPlaying.current&&audio.current)offset.current=Math.min((buffer.current?.duration||0),offset.current+audio.current.currentTime-started.current);
39
+ if(node.current){node.current.onended=null;try{node.current.stop();}catch{}node.current.disconnect();node.current=null;}
40
+ isPlaying.current=false;setPlaying(false);
41
+ },[]);
42
+ const startBuffer=useCallback((at:number)=>{
43
+ const ctx=audio.current,buf=buffer.current;if(!ctx||!buf||!gain.current)return;
44
+ const src=ctx.createBufferSource();src.buffer=buf;src.connect(gain.current);node.current=src;
45
+ offset.current=Math.max(0,Math.min(at,buf.duration-.001));started.current=ctx.currentTime;isPlaying.current=true;setPlaying(true);
46
+ src.onended=()=>{if(node.current!==src)return;if(loopRef.current){isPlaying.current=false;node.current=null;startBuffer(0);}else{isPlaying.current=false;node.current=null;offset.current=0;setPlaying(false);setPosition(0);lastEvent.current=-1;paper.current?.querySelectorAll('.current-note,.playhead').forEach(n=>n.classList.contains('playhead')?n.remove():n.classList.remove('current-note'));}};
47
+ src.start(0,offset.current);
48
+ },[]);
49
+ const commit=useCallback((next:string)=>{if(next===abc)return;stop();setHistory(h=>[...h.slice(0,index+1),next]);setIndex(index+1);setNotice('Edits applied. Playback uses this version.');},[abc,index,stop]);
50
+ const undo=useCallback(()=>{if(index>0){stop();setIndex(i=>i-1);setSelection(null);}},[index,stop]);
51
+ const redo=useCallback(()=>{if(index<history.length-1){stop();setIndex(i=>i+1);setSelection(null);}},[index,history.length,stop]);
52
+ useEffect(()=>{const key=(e:KeyboardEvent)=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)redo();else undo();}else if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='y'){e.preventDefault();redo();}};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[undo,redo]);
53
+ useEffect(()=>{
54
+ const revision=++generation.current;stop();buffer.current=null;offset.current=0;setPosition(0);setTotal(0);setLoading(false);setReady(false);setProblem('');lastEvent.current=-1;
55
+ const timer=setTimeout(async()=>{
56
+ const ABC=await import('abcjs');if(revision!==generation.current||!paper.current)return;lib.current=ABC;
57
+ try{
58
+ if(!/\nK:/.test(abc)||!abc.trim())throw new Error('The score needs a K: key field and some notes. Undo or restore the AI score.');
59
+ const visual=ABC.renderAbc(paper.current,abc,{add_classes:true,selectTypes:['note'],selectionColor:'#cd7815',responsive:'resize',staffwidth:850,paddingtop:30,paddingbottom:40,
60
+ clickListener:(el,_n,_classes,analysis)=>{if(el.el_type!=='note'||typeof el.startChar!=='number'||typeof el.endChar!=='number')return;
61
+ const selected={start:el.startChar,end:el.endChar,token:abc.slice(el.startChar,el.endChar)};setSelection(selected);
62
+ paper.current?.querySelectorAll('.selected-note').forEach(n=>n.classList.remove('selected-note'));analysis.selectableElement?.classList.add('selected-note');
63
+ if(textarea.current){textarea.current.focus({preventScroll:true});textarea.current.setSelectionRange(selected.start,selected.end);}
64
+ }})[0];
65
+ if(!visual||!paper.current.querySelector('.abcjs-note'))throw new Error('No playable notes found. Check the notation, undo, or restore the AI score.');
66
+ tune.current=visual;setReady(true);
67
+ setNotice(visual.warnings?.length?'Some notation could not be read. Check your edit before playing.':'');
68
+ }catch(e){setProblem(e instanceof Error?e.message:'This notation could not be read.');tune.current=null;}
69
+ },180);return()=>clearTimeout(timer);
70
+ },[abc,speed,stop]);
71
+ useEffect(()=>{let frame=0;const tick=()=>{if(isPlaying.current&&audio.current){const time=offset.current+audio.current.currentTime-started.current;setPosition(time);mark(time);}frame=requestAnimationFrame(tick);};frame=requestAnimationFrame(tick);return()=>cancelAnimationFrame(frame);},[mark]);
72
+ useEffect(()=>{if(gain.current)gain.current.gain.value=volume;},[volume]);
73
+ useEffect(()=>()=>{generation.current++;stop();gain.current?.disconnect();},[stop]);
74
+ async function toggle(){
75
+ if(playing){stop();return;}if(!ready||loading||!lib.current||!tune.current)return;
76
+ const rev=generation.current;
77
+ try{
78
+ if(!audio.current){audio.current=new AudioContext();gain.current=audio.current.createGain();gain.current.gain.value=volume;gain.current.connect(audio.current.destination);}
79
+ await audio.current.resume();
80
+ if(!buffer.current){
81
+ setLoading(true);setProblem('');const synth=new lib.current.synth.CreateSynth();
82
+ const load=await synth.init({audioContext:audio.current,visualObj:tune.current,millisecondsPerMeasure:tune.current.millisecondsPerMeasure()*100/speed,options:{soundFontUrl:location.origin+'/soundfonts/',program:0}});
83
+ if(load.error?.length)throw new Error('Some piano sounds could not load. Please try again.');
84
+ await synth.prime();if(rev!==generation.current)return;
85
+ buffer.current=synth.getAudioBuffer()||null;if(!buffer.current)throw new Error('Could not prepare piano playback.');
86
+ const timer=new lib.current.TimingCallbacks(tune.current,{qpm:tune.current.getBpm(tune.current.metaText.tempo)*speed/100});timings.current=timer.noteTimings;
87
+ setTotal(buffer.current.duration);setLoading(false);
88
+ }
89
+ if(rev===generation.current)startBuffer(offset.current);
90
+ }catch(e){if(rev===generation.current){setLoading(false);setProblem(e instanceof Error?e.message:'Playback failed. Please try again.');}}
91
+ }
92
+ function seek(value:number){const was=isPlaying.current;stop();offset.current=value;setPosition(value);lastEvent.current=-1;mark(value);if(was)startBuffer(value);}
93
+ function noteEdit(action:'up'|'down'|'longer'|'shorter'){
94
+ if(!selection)return;const match=selection.token.match(/^(\s*(?:"[^"]*"\s*)*)([_^=]*)([A-Ga-g])([,']*)(\d*(?:\/\d*)?)(-?)(\s*)$/);
95
+ if(!match){setCodeOpen(true);setNotice('Use the notation editor for this chord, rest, or decorated note.');return;}
96
+ let [,prefix,acc,note,oct,length,tie,suffix]=match;
97
+ if(action==='up'||action==='down'){
98
+ const names='CDEFGAB';let pitch=names.indexOf(note.toUpperCase())+(note===note.toLowerCase()?7:0)+7*([...oct].filter(x=>x==="'").length-[...oct].filter(x=>x===',').length)+(action==='up'?1:-1);
99
+ const octave=Math.floor(pitch/7);note=names[((pitch%7)+7)%7];oct=octave<0?', '.trim().repeat(-octave):octave>1?"'".repeat(octave-1):'';if(octave>=1)note=note.toLowerCase();
100
+ }else{const [n,d]=length.split('/');let num=Number(n||1),den=d===undefined?1:Number(d||2);if(action==='longer')num*=2;else den*=2;while(num%2===0&&den%2===0){num/=2;den/=2;}length=den===1?(num===1?'':String(num)):`${num===1?'':num}/${den===2?'':den}`;}
101
+ const token=prefix+acc+note+oct+length+tie+suffix;commit(abc.slice(0,selection.start)+token+abc.slice(selection.end));setSelection({...selection,end:selection.start+token.length,token});
102
+ }
103
+ function midi(){if(!lib.current)return;try{const uri=lib.current.synth.getMidiFile(abc,{midiOutputType:'encoded'}) as string;const a=document.createElement('a');a.href=uri;a.download='edited-score.mid';a.click();}catch{setProblem('MIDI export failed. Check the notation.');}}
104
+ return <section className="workspace view-enter">
105
+ <div className="workspace-heading"><div><div className="eyebrow">YOUR MUSIC, ON PAPER</div><h1>{source.title}</h1><p>{source.artist||'Your recording'} <span>·</span> {edited?'Edited score':'Original AI score'}</p></div><button className="quiet-button" onClick={onNew}><X size={16}/> New score</button></div>
106
+ <div className="transport" aria-label="Score playback controls">
107
+ <button className="transport-icon" onClick={()=>seek(0)} title="Restart" aria-label="Restart playback"><SkipBack size={18}/></button>
108
+ <button className="transport-icon play-button" onClick={toggle} disabled={!ready||loading} aria-label={playing?'Pause score':'Play score'}>{loading?<span className="spinner"/>:playing?<Pause size={22} fill="currentColor"/>:<Play size={22} fill="currentColor"/>}</button>
109
+ <div className="cover">{source.thumbnail?<img src={source.thumbnail} alt="Video thumbnail" onError={e=>{e.currentTarget.style.display='none';}}/>:<Music2 size={21}/>}</div>
110
+ <div className="track-progress"><div className="progress-fill" style={{width:total?`${Math.min(100,position/total*100)}%`:'0%'}}/><div className="track-label"><span>{clock(position)}</span><strong>{source.title}</strong><span>{total?`−${clock(Math.max(0,total-position))}`:'SCORE'}</span></div><input type="range" min="0" max={total||1} step=".05" value={Math.min(position,total||1)} disabled={!total} onChange={e=>seek(Number(e.target.value))} aria-label="Playback position"/></div>
111
+ <button className={`transport-icon ${loop?'active':''}`} onClick={()=>setLoop(!loop)} aria-label="Loop score" aria-pressed={loop} title="Loop"><Repeat2 size={20}/></button>
112
+ <div className="volume"><button className="transport-icon" onClick={()=>setVolume(volume?0:.7)} aria-label={volume?'Mute':'Unmute'}>{volume?<Volume2 size={18}/>:<VolumeX size={18}/>}</button><input aria-label="Volume" type="range" min="0" max="1" step=".05" value={volume} onChange={e=>setVolume(Number(e.target.value))}/></div>
113
+ </div>
114
+ <div className="under-player"><span>{loading?'Preparing piano sounds…':'Piano playback follows your written score'}</span><label>Speed <select aria-label="Playback speed" value={speed} onChange={e=>{stop();setSpeed(Number(e.target.value));}}><option value="50">0.5×</option><option value="75">0.75×</option><option value="100">1×</option><option value="125">1.25×</option><option value="150">1.5×</option></select></label></div>
115
+ <div className="score-toolbar"><div className="toolbar-group"><button onClick={undo} disabled={index===0} title="Undo (Ctrl Z)" aria-label="Undo"><Undo2 size={18}/></button><button onClick={redo} disabled={index===history.length-1} title="Redo (Ctrl Shift Z)" aria-label="Redo"><Redo2 size={18}/></button><span className="divider"/><button onClick={()=>{commit(result.abc);setSelection(null);}} disabled={!edited}><RotateCcw size={16}/><span>Restore AI score</span></button></div><div className="toolbar-group"><label className="follow"><input type="checkbox" checked={follow} onChange={e=>setFollow(e.target.checked)}/> Follow notes</label><button className={codeOpen?'chosen':''} onClick={()=>setCodeOpen(!codeOpen)} aria-expanded={codeOpen}><Code2 size={17}/><span>Edit notation</span></button><button onClick={()=>window.print()} title="Print current score" aria-label="Print current score"><Printer size={17}/></button><details className="download-menu"><summary><Download size={17}/><span>Download</span><ChevronDown size={13}/></summary><div><button onClick={()=>save(abc,'score.abc')}>Current score · ABC</button><button onClick={midi}>Current score · MIDI</button>{result.pdf&&<a href={result.pdf} target="_blank" rel="noreferrer">Original AI score · PDF</a>}{result.downloads.find(d=>d.name.endsWith('.zip'))&&<a href={result.downloads.find(d=>d.name.endsWith('.zip'))!.url} target="_blank" rel="noreferrer">Original AI files · ZIP</a>}</div></details></div></div>
116
+ <div className="edit-help"><span>{selection?`Selected: ${selection.token.trim()}`:'Click a note to edit its pitch or length.'}</span>{selection&&<div className="note-actions"><button onClick={()=>noteEdit('down')} aria-label="Lower selected note"><ArrowDown size={14}/></button><button onClick={()=>noteEdit('up')} aria-label="Raise selected note"><ArrowUp size={14}/></button><button onClick={()=>noteEdit('shorter')}>½ length</button><button onClick={()=>noteEdit('longer')}>2× length</button></div>}</div>
117
+ {problem&&<p role="alert" className="error-message">{problem}</p>}{notice&&<p role="status" className="edit-notice">{notice}</p>}
118
+ <div className={`score-layout ${codeOpen?'with-editor':''}`}><div className="score-scroll" ref={scoreScroll}><div className="paper" ref={paper}/></div>{codeOpen&&<aside className="notation-editor"><div><strong>ABC notation</strong><button onClick={()=>setCodeOpen(false)} aria-label="Close notation editor"><X size={16}/></button></div><p>Change notes, rhythm, chords, title, or tempo. Playback updates with your edits.</p><textarea ref={textarea} aria-label="Edit ABC notation" spellCheck={false} value={abc} onChange={e=>commit(e.target.value)}/><span>Undo and redo work here too. Print to save an edited PDF.</span></aside>}</div>
119
+ {source.audioUrl&&<details className="original-audio"><summary>Listen to the original audio</summary><p>The original recording is separate from the score player.</p><audio controls src={source.audioUrl} onPlay={stop}/></details>}
120
+ </section>;
121
+ }
components/SignIn.tsx ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+ import {useEffect,useState} from 'react';
3
+ import {ArrowUpRight,Music2} from 'lucide-react';
4
+ export default function SignIn(){
5
+ const [retry,setRetry]=useState(false);
6
+ useEffect(()=>setRetry(new URLSearchParams(location.search).get('signIn')==='retry'),[]);
7
+ return <section className="sign-in-view view-enter">
8
+ <div className="sign-in-content">
9
+ <span className="sign-in-symbol"><Music2 size={31}/></span>
10
+ <h1>Your music.<br/>Written down.</h1>
11
+ <p>Turn a recording or YouTube audio into a score you can play, edit, and make your own.</p>
12
+ <a className="hf-sign-in" href="/auth/login" target="_top"><span aria-hidden="true">🤗</span> Sign in with Hugging Face <ArrowUpRight size={18}/></a>
13
+ <p className="sign-in-note">Use your account’s free processing allowance.<br/>New here? You can create an account when you sign in.</p>
14
+ {retry&&<p className="error-message" role="alert">Sign-in didn’t complete. Please try again.</p>}
15
+ </div>
16
+ </section>;
17
+ }
components/ThemeToggle.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+ import {useEffect,useRef,useState} from 'react';
3
+ import {flushSync} from 'react-dom';
4
+ import {Moon,Sun} from 'lucide-react';
5
+ type Theme='dark'|'light';
6
+ export default function ThemeToggle(){
7
+ const [theme,setTheme]=useState<Theme>('light'),[ready,setReady]=useState(false);
8
+ const transitioning=useRef(false);
9
+ useEffect(()=>{
10
+ const media=matchMedia('(prefers-color-scheme: dark)');
11
+ const sync=()=>{let saved:string|null=null;try{saved=localStorage.getItem('score-theme');}catch{}
12
+ const next:Theme=saved==='light'||saved==='dark'?saved:media.matches?'dark':'light';
13
+ document.documentElement.dataset.theme=next;setTheme(next);setReady(true);
14
+ };sync();media.addEventListener('change',sync);window.addEventListener('storage',sync);
15
+ return()=>{media.removeEventListener('change',sync);window.removeEventListener('storage',sync);};
16
+ },[]);
17
+ async function toggle(event:React.MouseEvent<HTMLButtonElement>){
18
+ if(transitioning.current)return;
19
+ const next:Theme=theme==='dark'?'light':'dark';
20
+ const change=()=>{document.documentElement.dataset.theme=next;flushSync(()=>setTheme(next));try{localStorage.setItem('score-theme',next);}catch{}};
21
+ if(!document.startViewTransition||matchMedia('(prefers-reduced-motion: reduce)').matches){change();return;}
22
+ const box=event.currentTarget.getBoundingClientRect(),x=box.left+box.width/2,y=box.top+box.height/2;
23
+ const radius=Math.hypot(Math.max(x,innerWidth-x),Math.max(y,innerHeight-y));
24
+ transitioning.current=true;
25
+ try{
26
+ const transition=document.startViewTransition(change);
27
+ await transition.ready;
28
+ await document.documentElement.animate({clipPath:[`circle(0px at ${x}px ${y}px)`,`circle(${radius}px at ${x}px ${y}px)`]},{duration:800,easing:'cubic-bezier(.22,.61,.36,1)',pseudoElement:'::view-transition-new(root)',fill:'both'}).finished;
29
+ await transition.finished;
30
+ }catch{}finally{transitioning.current=false;}
31
+ }
32
+ return <button className="theme-toggle" onClick={toggle} aria-label={theme==='dark'?'Switch to white mode':'Switch to dark mode'} style={{visibility:ready?'visible':'hidden'}}>{theme==='dark'?<Sun size={19}/>:<Moon size={19}/>}</button>;
33
+ }
components/TranscriptionPreview.tsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+ import {useEffect,useState} from 'react';
3
+ import {useRouter} from 'next/navigation';
4
+ import ThemeToggle from './ThemeToggle';
5
+ import {choosePerformer,type PerformerId} from '@/lib/performers';
6
+ import Processing from './Processing';
7
+
8
+ export default function TranscriptionPreview(){
9
+ const router=useRouter();
10
+ const [performer,setPerformer]=useState<PerformerId|null>(null);
11
+ useEffect(()=>{
12
+ setPerformer(choosePerformer(null));
13
+ },[]);
14
+ return <>
15
+ <ThemeToggle/>
16
+ {performer&&<Processing cancelLabel="Back to upload" performer={performer} onCancel={()=>router.push('/')}/>}
17
+ </>;
18
+ }
env.example ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ APP_ORIGIN=http://localhost:3000
2
+ HF_API_SPACE=Reubencf/Score-Studio-API
3
+ HF_API_ORIGIN=https://reubencf-score-studio-api.hf.space
4
+ HF_OAUTH_CLIENT_ID=https://reubencf-score-studio-api.hf.space/.well-known/oauth-cimd
5
+ # Required in production: a 64-character random hex string, stored as a Space secret.
6
+ # Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
7
+ SESSION_SECRET=
8
+ # Optional local path to Python with yt-dlp installed. ffmpeg/ffprobe must be on PATH.
9
+ PYTHON_BIN=python
lib/audio.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {execFile} from 'node:child_process';
2
+ import {promisify} from 'node:util';
3
+ import {mkdtemp,writeFile,readFile,rm} from 'node:fs/promises';
4
+ import {tmpdir} from 'node:os';
5
+ import path from 'node:path';
6
+ const run=promisify(execFile);
7
+
8
+ /** Decode the entire input and measure it server-side; never trust client duration. */
9
+ export async function prepareAudio(input:File,signal:AbortSignal){
10
+ const dir=await mkdtemp(path.join(tmpdir(),'score-audio-'));
11
+ try{
12
+ const source=path.join(dir,'source'),wav=path.join(dir,'audio.wav');
13
+ await writeFile(source,new Uint8Array(await input.arrayBuffer()));
14
+ // One extra second detects overlong recordings without decoding unbounded input.
15
+ await run('ffmpeg',['-nostdin','-v','error','-protocol_whitelist','file,pipe','-format_whitelist','wav,mp3,mov,matroska,webm,ogg,flac,aac,aiff','-i',source,'-map','0:a:0','-vn','-t','601','-ac','1','-ar','24000','-y',wav],{windowsHide:true,timeout:90000,signal,maxBuffer:1024*1024});
16
+ const {stdout}=await run('ffprobe',['-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',wav],{windowsHide:true,timeout:15000,signal});
17
+ const duration=Number(stdout.trim());
18
+ if(!Number.isFinite(duration)||duration<5)throw new Error('Use a recording with at least five seconds of audio.');
19
+ if(duration>600)throw new Error('This recording is longer than the current ten-minute limit. Nothing was cut or submitted.');
20
+ const bytes=await readFile(wav);
21
+ return {audio:new File([new Uint8Array(bytes)],'recording.wav',{type:'audio/wav'}),duration};
22
+ }catch(error){
23
+ if(error instanceof Error&&(/five seconds|ten-minute/.test(error.message)||error.name==='AbortError'))throw error;
24
+ throw new Error('This recording could not be read. Try an MP3, WAV, M4A, OGG, or FLAC file.');
25
+ }finally{await rm(dir,{recursive:true,force:true});}
26
+ }
lib/auth.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import './server-tls';
2
+ import {createCipheriv,createDecipheriv,randomBytes,createHash} from 'node:crypto';
3
+ import {cookies} from 'next/headers';
4
+
5
+ export const SESSION_COOKIE='score-session';
6
+ export const FLOW_COOKIE='score-oauth';
7
+ export const CLIENT_ID=process.env.HF_OAUTH_CLIENT_ID||'https://reubencf-score-studio-api.hf.space/.well-known/oauth-cimd';
8
+ type Session={token:string;expires:number;user:{id:string;name:string}};
9
+ export type OAuthFlow={state:string;verifier:string;expires:number};
10
+ const devGlobal=globalThis as typeof globalThis & {scoreSessionKey?:Buffer};
11
+ function key(){
12
+ const secret=process.env.SESSION_SECRET;
13
+ if(secret){if(!/^[a-f\d]{64}$/i.test(secret))throw new Error('SESSION_SECRET must contain 32 random bytes encoded as hex.');return Buffer.from(secret,'hex');}
14
+ if(process.env.NODE_ENV==='production')throw new Error('SESSION_SECRET is required.');
15
+ return devGlobal.scoreSessionKey??=(randomBytes(32));
16
+ }
17
+ export function seal(value:unknown){
18
+ const iv=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key(),iv);
19
+ const encrypted=Buffer.concat([cipher.update(JSON.stringify(value),'utf8'),cipher.final()]);
20
+ return Buffer.concat([iv,cipher.getAuthTag(),encrypted]).toString('base64url');
21
+ }
22
+ export function unseal<T>(value?:string):T|null{
23
+ if(!value)return null;
24
+ try{const bytes=Buffer.from(value,'base64url');if(bytes.length<29)return null;
25
+ const decipher=createDecipheriv('aes-256-gcm',key(),bytes.subarray(0,12));decipher.setAuthTag(bytes.subarray(12,28));
26
+ return JSON.parse(Buffer.concat([decipher.update(bytes.subarray(28)),decipher.final()]).toString('utf8')) as T;
27
+ }catch{return null;}
28
+ }
29
+ export function appOrigin(){
30
+ const configured=process.env.APP_ORIGIN||(process.env.SPACE_HOST?'https://'+process.env.SPACE_HOST:undefined);
31
+ if(!configured&&process.env.NODE_ENV==='production')throw new Error('APP_ORIGIN is required.');
32
+ return new URL(configured||'http://localhost:3000').origin;
33
+ }
34
+ export function cookieOptions(maxAge:number){return {httpOnly:true,secure:appOrigin().startsWith('https:'),sameSite:'lax' as const,path:'/',maxAge};}
35
+ export async function session(){
36
+ const value=unseal<Session>((await cookies()).get(SESSION_COOKIE)?.value);
37
+ return value&&typeof value.token==='string'&&value.expires>Date.now()&&value.user?.id?value:null;
38
+ }
39
+ export async function authorize(req:Request){
40
+ const user=await session();
41
+ if(!user)return {error:Response.json({error:'Sign in with Hugging Face to continue.',signIn:true},{status:401})} as const;
42
+ if(req.method!=='GET'&&(req.headers.get('origin')!==appOrigin()||req.headers.get('sec-fetch-site')==='cross-site'))
43
+ return {error:Response.json({error:'Please submit from Score Studio.'},{status:403})} as const;
44
+ return {session:user} as const;
45
+ }
46
+ export function createFlow():OAuthFlow{return {state:randomBytes(32).toString('base64url'),verifier:randomBytes(32).toString('base64url'),expires:Date.now()+600000};}
47
+ export function challenge(verifier:string){return createHash('sha256').update(verifier).digest('base64url');}
lib/media.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {tmpdir} from 'node:os';
2
+ import path from 'node:path';
3
+ import {mkdir,readdir,stat,rm} from 'node:fs/promises';
4
+ export const mediaRoot=path.join(tmpdir(),'score-studio-media');
5
+ export async function prepareMedia(){
6
+ await mkdir(mediaRoot,{recursive:true});
7
+ for(const id of await readdir(mediaRoot)){
8
+ if(!/^[a-f0-9-]{36}$/.test(id))continue;
9
+ const dir=path.join(mediaRoot,id);
10
+ try{if(Date.now()-(await stat(dir)).mtimeMs>3600000)await rm(dir,{recursive:true,force:true});}catch{}
11
+ }
12
+ }
lib/performers.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const PERFORMERS=[
2
+ {id:'saxophone',label:'Saxophone',person:'saxophonist'},
3
+ {id:'guitar',label:'Guitar',person:'guitarist'},
4
+ {id:'piano',label:'Piano',person:'pianist'},
5
+ {id:'drums',label:'Drums',person:'drummer'},
6
+ ] as const;
7
+ export type PerformerId=typeof PERFORMERS[number]['id'];
8
+ export const POSE_COUNT=16;
9
+ export const LOOP_SECONDS=5.7;
10
+ export const GRID_SIZE=512;
11
+ export const GRID_SPACING=9;
12
+ export type PerformerDot={x:number;y:number;coverage:Float32Array};
13
+ const cache=new Map<PerformerId,Promise<PerformerDot[]>>();
14
+
15
+ export function choosePerformer(previous:PerformerId|null):PerformerId{
16
+ const options=PERFORMERS.filter(item=>item.id!==previous);
17
+ return options[Math.floor(Math.random()*options.length)].id;
18
+ }
19
+
20
+ // Continuous slopes avoid a stop/start at each illustrated pose.
21
+ export function coverageAt(poses:Float32Array,position:number){
22
+ const index=Math.floor(position),t=position-index,n=poses.length;
23
+ const p0=poses[(index+n-1)%n],p1=poses[index%n],p2=poses[(index+1)%n],p3=poses[(index+2)%n];
24
+ const value=.5*(2*p1+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t*t+(-p0+3*p1-3*p2+p3)*t*t*t);
25
+ return Math.max(0,Math.min(1,value));
26
+ }
27
+
28
+ export function loadPerformer(id:PerformerId):Promise<PerformerDot[]>{
29
+ const cached=cache.get(id);if(cached)return cached;
30
+ const pending=new Promise<PerformerDot[]>((resolve,reject)=>{
31
+ const img=new Image();
32
+ img.onload=()=>{
33
+ try{
34
+ const sample=document.createElement('canvas');sample.width=GRID_SIZE;sample.height=GRID_SIZE;
35
+ const scan=sample.getContext('2d',{willReadFrequently:true});
36
+ if(!scan)throw new Error('Canvas is unavailable.');
37
+ const grid:PerformerDot[]=[];
38
+ for(let y=9;y<504;y+=GRID_SPACING)for(let x=9;x<504;x+=GRID_SPACING)
39
+ grid.push({x,y,coverage:new Float32Array(POSE_COUNT)});
40
+ const cellWidth=img.naturalWidth/4,cellHeight=img.naturalHeight/4;
41
+ for(let pose=0;pose<POSE_COUNT;pose++){
42
+ scan.clearRect(0,0,GRID_SIZE,GRID_SIZE);
43
+ scan.drawImage(img,(pose%4)*cellWidth,Math.floor(pose/4)*cellHeight,cellWidth,cellHeight,0,0,GRID_SIZE,GRID_SIZE);
44
+ const pixels=scan.getImageData(0,0,GRID_SIZE,GRID_SIZE).data;
45
+ for(const dot of grid){
46
+ let coverage=0;
47
+ // Preserve the generated alpha silhouette, including dark clothing.
48
+ // Discard faint matte fringes. The grid never moves between poses.
49
+ for(let dy=-4;dy<=4;dy++)for(let dx=-4;dx<=4;dx++){
50
+ const p=((dot.y+dy)*GRID_SIZE+dot.x+dx)*4;
51
+ const alpha=Math.max(0,(pixels[p+3]-32)/223);
52
+ // Keep the acoustic guitar's soundhole and the strumming hand legible.
53
+ const detail=id==='guitar'?Math.max(0,((pixels[p]+pixels[p+1]+pixels[p+2])/3-24)/231):1;
54
+ coverage+=alpha*detail;
55
+ }
56
+ dot.coverage[pose]=Math.min(1,coverage/81);
57
+ }
58
+ }
59
+ const dots=grid.filter(dot=>dot.coverage.some(value=>value>.01));
60
+ if(!dots.length)throw new Error('The performer artwork is empty.');
61
+ resolve(dots);
62
+ }catch(error){reject(error);}
63
+ };
64
+ img.onerror=()=>reject(new Error('The performer artwork could not load.'));
65
+ img.src='/performers/'+id+'.png';
66
+ });
67
+ cache.set(id,pending);
68
+ pending.catch(()=>cache.delete(id));
69
+ return pending;
70
+ }
lib/server-tls.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import {getCACertificates,setDefaultCACertificates} from 'node:tls';
2
+
3
+ // Include certificates trusted by the host OS without disabling verification.
4
+ // This also covers Gradio's HTTPS calls on Windows development machines.
5
+ if (typeof setDefaultCACertificates==='function') {
6
+ setDefaultCACertificates([...new Set([...getCACertificates('default'),...getCACertificates('system')])]);
7
+ }
lib/types.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ export type Download = {name:string;url:string};
2
+ export type Source = {title:string;artist?:string;thumbnail?:string;url?:string;audioUrl?:string};
3
+ export type ScoreResult = {abc:string;status:string;downloads:Download[];pdf?:string;audio?:string};
4
+ export const DEMO_ABC = 'X:1\nT:A little room for music\nC:Score Studio demo\nM:4/4\nL:1/8\nQ:1/4=96\nK:C\n"C" E2 G2 c2 B2 | "Am" A3 G E2 C2 | "F" F2 A2 c2 A2 | "G" B2 G2 D4 |\n"C" E2 G2 c2 e2 | "Am" d2 c2 A4 | "F" A2 G2 F2 E2 | "C" D2 E2 C4 |]';
lib/youtube.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function youtubeId(value:string):string|null {
2
+ try {
3
+ const u=new URL(value.trim()); if(u.protocol!=='https:' && u.protocol!=='http:')return null;
4
+ if(u.username||u.password||u.port)return null;
5
+ const host=u.hostname.toLowerCase();let id:string|null=null;
6
+ if(host==='youtu.be')id=u.pathname.slice(1).split('/')[0];
7
+ else if(['youtube.com','www.youtube.com','m.youtube.com','music.youtube.com'].includes(host)) {
8
+ if(u.pathname==='/watch')id=u.searchParams.get('v');
9
+ else if(/^\/(shorts|embed|live)\//.test(u.pathname))id=u.pathname.split('/')[2];
10
+ }
11
+ return id && /^[A-Za-z0-9_-]{11}$/.test(id)?id:null;
12
+ }catch{return null;}
13
+ }
next-env.d.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ import "./.next/types/routes.d.ts";
4
+ import "./.next/types/root-params.d.ts";
5
+
6
+ // NOTE: This file should not be edited
7
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
next.config.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ import type { NextConfig } from 'next';
2
+ const config: NextConfig = { output: 'standalone', poweredByHeader: false };
3
+ export default config;
package-lock.json ADDED
@@ -0,0 +1,1116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "score-studio",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "score-studio",
9
+ "version": "1.0.0",
10
+ "dependencies": {
11
+ "@gradio/client": "2.6.0",
12
+ "abcjs": "6.6.3",
13
+ "lucide-react": "^0.577.0",
14
+ "next": "16.3.4",
15
+ "react": "19.2.4",
16
+ "react-dom": "19.2.4"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^24.0.0",
20
+ "@types/react": "^19.0.0",
21
+ "@types/react-dom": "^19.0.0",
22
+ "typescript": "^5.9.3"
23
+ }
24
+ },
25
+ "node_modules/@emnapi/runtime": {
26
+ "version": "1.11.3",
27
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
28
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
29
+ "license": "MIT",
30
+ "optional": true,
31
+ "dependencies": {
32
+ "tslib": "^2.4.0"
33
+ }
34
+ },
35
+ "node_modules/@gradio/client": {
36
+ "version": "2.6.0",
37
+ "resolved": "https://registry.npmjs.org/@gradio/client/-/client-2.6.0.tgz",
38
+ "integrity": "sha512-6UEzLru4kHI1zrnIVM1TEo9R6Qn3M9Ov+AySbonaCb57X5R7wesne9sXT7tbrGLiGFKeLnC7UgEGF6QaJoTHrw==",
39
+ "license": "ISC",
40
+ "dependencies": {
41
+ "fetch-event-stream": "^0.1.5"
42
+ },
43
+ "engines": {
44
+ "node": ">=18.0.0"
45
+ }
46
+ },
47
+ "node_modules/@img/colour": {
48
+ "version": "1.1.0",
49
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
50
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
51
+ "license": "MIT",
52
+ "optional": true,
53
+ "engines": {
54
+ "node": ">=18"
55
+ }
56
+ },
57
+ "node_modules/@img/sharp-darwin-arm64": {
58
+ "version": "0.35.4",
59
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
60
+ "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
61
+ "cpu": [
62
+ "arm64"
63
+ ],
64
+ "license": "Apache-2.0",
65
+ "optional": true,
66
+ "os": [
67
+ "darwin"
68
+ ],
69
+ "engines": {
70
+ "node": ">=20.9.0"
71
+ },
72
+ "funding": {
73
+ "url": "https://opencollective.com/libvips"
74
+ },
75
+ "optionalDependencies": {
76
+ "@img/sharp-libvips-darwin-arm64": "1.3.3"
77
+ }
78
+ },
79
+ "node_modules/@img/sharp-darwin-x64": {
80
+ "version": "0.35.4",
81
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
82
+ "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
83
+ "cpu": [
84
+ "x64"
85
+ ],
86
+ "license": "Apache-2.0",
87
+ "optional": true,
88
+ "os": [
89
+ "darwin"
90
+ ],
91
+ "engines": {
92
+ "node": ">=20.9.0"
93
+ },
94
+ "funding": {
95
+ "url": "https://opencollective.com/libvips"
96
+ },
97
+ "optionalDependencies": {
98
+ "@img/sharp-libvips-darwin-x64": "1.3.3"
99
+ }
100
+ },
101
+ "node_modules/@img/sharp-freebsd-wasm32": {
102
+ "version": "0.35.4",
103
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
104
+ "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
105
+ "license": "Apache-2.0",
106
+ "optional": true,
107
+ "os": [
108
+ "freebsd"
109
+ ],
110
+ "dependencies": {
111
+ "@img/sharp-wasm32": "0.35.4"
112
+ },
113
+ "engines": {
114
+ "node": ">=20.9.0"
115
+ },
116
+ "funding": {
117
+ "url": "https://opencollective.com/libvips"
118
+ }
119
+ },
120
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
121
+ "version": "1.3.3",
122
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
123
+ "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
124
+ "cpu": [
125
+ "arm64"
126
+ ],
127
+ "license": "LGPL-3.0-or-later",
128
+ "optional": true,
129
+ "os": [
130
+ "darwin"
131
+ ],
132
+ "funding": {
133
+ "url": "https://opencollective.com/libvips"
134
+ }
135
+ },
136
+ "node_modules/@img/sharp-libvips-darwin-x64": {
137
+ "version": "1.3.3",
138
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
139
+ "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
140
+ "cpu": [
141
+ "x64"
142
+ ],
143
+ "license": "LGPL-3.0-or-later",
144
+ "optional": true,
145
+ "os": [
146
+ "darwin"
147
+ ],
148
+ "funding": {
149
+ "url": "https://opencollective.com/libvips"
150
+ }
151
+ },
152
+ "node_modules/@img/sharp-libvips-linux-arm": {
153
+ "version": "1.3.3",
154
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
155
+ "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
156
+ "cpu": [
157
+ "arm"
158
+ ],
159
+ "libc": [
160
+ "glibc"
161
+ ],
162
+ "license": "LGPL-3.0-or-later",
163
+ "optional": true,
164
+ "os": [
165
+ "linux"
166
+ ],
167
+ "funding": {
168
+ "url": "https://opencollective.com/libvips"
169
+ }
170
+ },
171
+ "node_modules/@img/sharp-libvips-linux-arm64": {
172
+ "version": "1.3.3",
173
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
174
+ "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
175
+ "cpu": [
176
+ "arm64"
177
+ ],
178
+ "libc": [
179
+ "glibc"
180
+ ],
181
+ "license": "LGPL-3.0-or-later",
182
+ "optional": true,
183
+ "os": [
184
+ "linux"
185
+ ],
186
+ "funding": {
187
+ "url": "https://opencollective.com/libvips"
188
+ }
189
+ },
190
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
191
+ "version": "1.3.3",
192
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
193
+ "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
194
+ "cpu": [
195
+ "ppc64"
196
+ ],
197
+ "libc": [
198
+ "glibc"
199
+ ],
200
+ "license": "LGPL-3.0-or-later",
201
+ "optional": true,
202
+ "os": [
203
+ "linux"
204
+ ],
205
+ "funding": {
206
+ "url": "https://opencollective.com/libvips"
207
+ }
208
+ },
209
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
210
+ "version": "1.3.3",
211
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
212
+ "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
213
+ "cpu": [
214
+ "riscv64"
215
+ ],
216
+ "libc": [
217
+ "glibc"
218
+ ],
219
+ "license": "LGPL-3.0-or-later",
220
+ "optional": true,
221
+ "os": [
222
+ "linux"
223
+ ],
224
+ "funding": {
225
+ "url": "https://opencollective.com/libvips"
226
+ }
227
+ },
228
+ "node_modules/@img/sharp-libvips-linux-s390x": {
229
+ "version": "1.3.3",
230
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
231
+ "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
232
+ "cpu": [
233
+ "s390x"
234
+ ],
235
+ "libc": [
236
+ "glibc"
237
+ ],
238
+ "license": "LGPL-3.0-or-later",
239
+ "optional": true,
240
+ "os": [
241
+ "linux"
242
+ ],
243
+ "funding": {
244
+ "url": "https://opencollective.com/libvips"
245
+ }
246
+ },
247
+ "node_modules/@img/sharp-libvips-linux-x64": {
248
+ "version": "1.3.3",
249
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
250
+ "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
251
+ "cpu": [
252
+ "x64"
253
+ ],
254
+ "libc": [
255
+ "glibc"
256
+ ],
257
+ "license": "LGPL-3.0-or-later",
258
+ "optional": true,
259
+ "os": [
260
+ "linux"
261
+ ],
262
+ "funding": {
263
+ "url": "https://opencollective.com/libvips"
264
+ }
265
+ },
266
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
267
+ "version": "1.3.3",
268
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
269
+ "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
270
+ "cpu": [
271
+ "arm64"
272
+ ],
273
+ "libc": [
274
+ "musl"
275
+ ],
276
+ "license": "LGPL-3.0-or-later",
277
+ "optional": true,
278
+ "os": [
279
+ "linux"
280
+ ],
281
+ "funding": {
282
+ "url": "https://opencollective.com/libvips"
283
+ }
284
+ },
285
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
286
+ "version": "1.3.3",
287
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
288
+ "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
289
+ "cpu": [
290
+ "x64"
291
+ ],
292
+ "libc": [
293
+ "musl"
294
+ ],
295
+ "license": "LGPL-3.0-or-later",
296
+ "optional": true,
297
+ "os": [
298
+ "linux"
299
+ ],
300
+ "funding": {
301
+ "url": "https://opencollective.com/libvips"
302
+ }
303
+ },
304
+ "node_modules/@img/sharp-linux-arm": {
305
+ "version": "0.35.4",
306
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
307
+ "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
308
+ "cpu": [
309
+ "arm"
310
+ ],
311
+ "libc": [
312
+ "glibc"
313
+ ],
314
+ "license": "Apache-2.0",
315
+ "optional": true,
316
+ "os": [
317
+ "linux"
318
+ ],
319
+ "engines": {
320
+ "node": ">=20.9.0"
321
+ },
322
+ "funding": {
323
+ "url": "https://opencollective.com/libvips"
324
+ },
325
+ "optionalDependencies": {
326
+ "@img/sharp-libvips-linux-arm": "1.3.3"
327
+ }
328
+ },
329
+ "node_modules/@img/sharp-linux-arm64": {
330
+ "version": "0.35.4",
331
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
332
+ "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
333
+ "cpu": [
334
+ "arm64"
335
+ ],
336
+ "libc": [
337
+ "glibc"
338
+ ],
339
+ "license": "Apache-2.0",
340
+ "optional": true,
341
+ "os": [
342
+ "linux"
343
+ ],
344
+ "engines": {
345
+ "node": ">=20.9.0"
346
+ },
347
+ "funding": {
348
+ "url": "https://opencollective.com/libvips"
349
+ },
350
+ "optionalDependencies": {
351
+ "@img/sharp-libvips-linux-arm64": "1.3.3"
352
+ }
353
+ },
354
+ "node_modules/@img/sharp-linux-ppc64": {
355
+ "version": "0.35.4",
356
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
357
+ "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
358
+ "cpu": [
359
+ "ppc64"
360
+ ],
361
+ "libc": [
362
+ "glibc"
363
+ ],
364
+ "license": "Apache-2.0",
365
+ "optional": true,
366
+ "os": [
367
+ "linux"
368
+ ],
369
+ "engines": {
370
+ "node": ">=20.9.0"
371
+ },
372
+ "funding": {
373
+ "url": "https://opencollective.com/libvips"
374
+ },
375
+ "optionalDependencies": {
376
+ "@img/sharp-libvips-linux-ppc64": "1.3.3"
377
+ }
378
+ },
379
+ "node_modules/@img/sharp-linux-riscv64": {
380
+ "version": "0.35.4",
381
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
382
+ "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
383
+ "cpu": [
384
+ "riscv64"
385
+ ],
386
+ "libc": [
387
+ "glibc"
388
+ ],
389
+ "license": "Apache-2.0",
390
+ "optional": true,
391
+ "os": [
392
+ "linux"
393
+ ],
394
+ "engines": {
395
+ "node": ">=20.9.0"
396
+ },
397
+ "funding": {
398
+ "url": "https://opencollective.com/libvips"
399
+ },
400
+ "optionalDependencies": {
401
+ "@img/sharp-libvips-linux-riscv64": "1.3.3"
402
+ }
403
+ },
404
+ "node_modules/@img/sharp-linux-s390x": {
405
+ "version": "0.35.4",
406
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
407
+ "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
408
+ "cpu": [
409
+ "s390x"
410
+ ],
411
+ "libc": [
412
+ "glibc"
413
+ ],
414
+ "license": "Apache-2.0",
415
+ "optional": true,
416
+ "os": [
417
+ "linux"
418
+ ],
419
+ "engines": {
420
+ "node": ">=20.9.0"
421
+ },
422
+ "funding": {
423
+ "url": "https://opencollective.com/libvips"
424
+ },
425
+ "optionalDependencies": {
426
+ "@img/sharp-libvips-linux-s390x": "1.3.3"
427
+ }
428
+ },
429
+ "node_modules/@img/sharp-linux-x64": {
430
+ "version": "0.35.4",
431
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
432
+ "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
433
+ "cpu": [
434
+ "x64"
435
+ ],
436
+ "libc": [
437
+ "glibc"
438
+ ],
439
+ "license": "Apache-2.0",
440
+ "optional": true,
441
+ "os": [
442
+ "linux"
443
+ ],
444
+ "engines": {
445
+ "node": ">=20.9.0"
446
+ },
447
+ "funding": {
448
+ "url": "https://opencollective.com/libvips"
449
+ },
450
+ "optionalDependencies": {
451
+ "@img/sharp-libvips-linux-x64": "1.3.3"
452
+ }
453
+ },
454
+ "node_modules/@img/sharp-linuxmusl-arm64": {
455
+ "version": "0.35.4",
456
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
457
+ "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
458
+ "cpu": [
459
+ "arm64"
460
+ ],
461
+ "libc": [
462
+ "musl"
463
+ ],
464
+ "license": "Apache-2.0",
465
+ "optional": true,
466
+ "os": [
467
+ "linux"
468
+ ],
469
+ "engines": {
470
+ "node": ">=20.9.0"
471
+ },
472
+ "funding": {
473
+ "url": "https://opencollective.com/libvips"
474
+ },
475
+ "optionalDependencies": {
476
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
477
+ }
478
+ },
479
+ "node_modules/@img/sharp-linuxmusl-x64": {
480
+ "version": "0.35.4",
481
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
482
+ "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
483
+ "cpu": [
484
+ "x64"
485
+ ],
486
+ "libc": [
487
+ "musl"
488
+ ],
489
+ "license": "Apache-2.0",
490
+ "optional": true,
491
+ "os": [
492
+ "linux"
493
+ ],
494
+ "engines": {
495
+ "node": ">=20.9.0"
496
+ },
497
+ "funding": {
498
+ "url": "https://opencollective.com/libvips"
499
+ },
500
+ "optionalDependencies": {
501
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.3"
502
+ }
503
+ },
504
+ "node_modules/@img/sharp-wasm32": {
505
+ "version": "0.35.4",
506
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
507
+ "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
508
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
509
+ "optional": true,
510
+ "dependencies": {
511
+ "@emnapi/runtime": "^1.11.3"
512
+ },
513
+ "engines": {
514
+ "node": ">=20.9.0"
515
+ },
516
+ "funding": {
517
+ "url": "https://opencollective.com/libvips"
518
+ }
519
+ },
520
+ "node_modules/@img/sharp-webcontainers-wasm32": {
521
+ "version": "0.35.4",
522
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
523
+ "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
524
+ "cpu": [
525
+ "wasm32"
526
+ ],
527
+ "license": "Apache-2.0",
528
+ "optional": true,
529
+ "dependencies": {
530
+ "@img/sharp-wasm32": "0.35.4"
531
+ },
532
+ "engines": {
533
+ "node": ">=20.9.0"
534
+ },
535
+ "funding": {
536
+ "url": "https://opencollective.com/libvips"
537
+ }
538
+ },
539
+ "node_modules/@img/sharp-win32-arm64": {
540
+ "version": "0.35.4",
541
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
542
+ "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
543
+ "cpu": [
544
+ "arm64"
545
+ ],
546
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
547
+ "optional": true,
548
+ "os": [
549
+ "win32"
550
+ ],
551
+ "engines": {
552
+ "node": ">=20.9.0"
553
+ },
554
+ "funding": {
555
+ "url": "https://opencollective.com/libvips"
556
+ }
557
+ },
558
+ "node_modules/@img/sharp-win32-ia32": {
559
+ "version": "0.35.4",
560
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
561
+ "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
562
+ "cpu": [
563
+ "ia32"
564
+ ],
565
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
566
+ "optional": true,
567
+ "os": [
568
+ "win32"
569
+ ],
570
+ "engines": {
571
+ "node": "^20.9.0"
572
+ },
573
+ "funding": {
574
+ "url": "https://opencollective.com/libvips"
575
+ }
576
+ },
577
+ "node_modules/@img/sharp-win32-x64": {
578
+ "version": "0.35.4",
579
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
580
+ "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
581
+ "cpu": [
582
+ "x64"
583
+ ],
584
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
585
+ "optional": true,
586
+ "os": [
587
+ "win32"
588
+ ],
589
+ "engines": {
590
+ "node": ">=20.9.0"
591
+ },
592
+ "funding": {
593
+ "url": "https://opencollective.com/libvips"
594
+ }
595
+ },
596
+ "node_modules/@next/env": {
597
+ "version": "16.3.4",
598
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz",
599
+ "integrity": "sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==",
600
+ "license": "MIT"
601
+ },
602
+ "node_modules/@next/swc-darwin-arm64": {
603
+ "version": "16.3.4",
604
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.4.tgz",
605
+ "integrity": "sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==",
606
+ "cpu": [
607
+ "arm64"
608
+ ],
609
+ "license": "MIT",
610
+ "optional": true,
611
+ "os": [
612
+ "darwin"
613
+ ],
614
+ "engines": {
615
+ "node": ">= 10"
616
+ }
617
+ },
618
+ "node_modules/@next/swc-darwin-x64": {
619
+ "version": "16.3.4",
620
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.4.tgz",
621
+ "integrity": "sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==",
622
+ "cpu": [
623
+ "x64"
624
+ ],
625
+ "license": "MIT",
626
+ "optional": true,
627
+ "os": [
628
+ "darwin"
629
+ ],
630
+ "engines": {
631
+ "node": ">= 10"
632
+ }
633
+ },
634
+ "node_modules/@next/swc-linux-arm64-gnu": {
635
+ "version": "16.3.4",
636
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.4.tgz",
637
+ "integrity": "sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==",
638
+ "cpu": [
639
+ "arm64"
640
+ ],
641
+ "libc": [
642
+ "glibc"
643
+ ],
644
+ "license": "MIT",
645
+ "optional": true,
646
+ "os": [
647
+ "linux"
648
+ ],
649
+ "engines": {
650
+ "node": ">= 10"
651
+ }
652
+ },
653
+ "node_modules/@next/swc-linux-arm64-musl": {
654
+ "version": "16.3.4",
655
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.4.tgz",
656
+ "integrity": "sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==",
657
+ "cpu": [
658
+ "arm64"
659
+ ],
660
+ "libc": [
661
+ "musl"
662
+ ],
663
+ "license": "MIT",
664
+ "optional": true,
665
+ "os": [
666
+ "linux"
667
+ ],
668
+ "engines": {
669
+ "node": ">= 10"
670
+ }
671
+ },
672
+ "node_modules/@next/swc-linux-x64-gnu": {
673
+ "version": "16.3.4",
674
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.4.tgz",
675
+ "integrity": "sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==",
676
+ "cpu": [
677
+ "x64"
678
+ ],
679
+ "libc": [
680
+ "glibc"
681
+ ],
682
+ "license": "MIT",
683
+ "optional": true,
684
+ "os": [
685
+ "linux"
686
+ ],
687
+ "engines": {
688
+ "node": ">= 10"
689
+ }
690
+ },
691
+ "node_modules/@next/swc-linux-x64-musl": {
692
+ "version": "16.3.4",
693
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.4.tgz",
694
+ "integrity": "sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==",
695
+ "cpu": [
696
+ "x64"
697
+ ],
698
+ "libc": [
699
+ "musl"
700
+ ],
701
+ "license": "MIT",
702
+ "optional": true,
703
+ "os": [
704
+ "linux"
705
+ ],
706
+ "engines": {
707
+ "node": ">= 10"
708
+ }
709
+ },
710
+ "node_modules/@next/swc-win32-arm64-msvc": {
711
+ "version": "16.3.4",
712
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.4.tgz",
713
+ "integrity": "sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==",
714
+ "cpu": [
715
+ "arm64"
716
+ ],
717
+ "license": "MIT",
718
+ "optional": true,
719
+ "os": [
720
+ "win32"
721
+ ],
722
+ "engines": {
723
+ "node": ">= 10"
724
+ }
725
+ },
726
+ "node_modules/@next/swc-win32-x64-msvc": {
727
+ "version": "16.3.4",
728
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.4.tgz",
729
+ "integrity": "sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==",
730
+ "cpu": [
731
+ "x64"
732
+ ],
733
+ "license": "MIT",
734
+ "optional": true,
735
+ "os": [
736
+ "win32"
737
+ ],
738
+ "engines": {
739
+ "node": ">= 10"
740
+ }
741
+ },
742
+ "node_modules/@swc/helpers": {
743
+ "version": "0.5.23",
744
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
745
+ "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
746
+ "license": "Apache-2.0",
747
+ "dependencies": {
748
+ "tslib": "^2.8.0"
749
+ }
750
+ },
751
+ "node_modules/@types/node": {
752
+ "version": "24.13.4",
753
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz",
754
+ "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==",
755
+ "dev": true,
756
+ "license": "MIT",
757
+ "dependencies": {
758
+ "undici-types": "~7.18.0"
759
+ }
760
+ },
761
+ "node_modules/@types/react": {
762
+ "version": "19.3.0",
763
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
764
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
765
+ "dev": true,
766
+ "license": "MIT",
767
+ "dependencies": {
768
+ "csstype": "^3.2.2"
769
+ }
770
+ },
771
+ "node_modules/@types/react-dom": {
772
+ "version": "19.3.0",
773
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
774
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
775
+ "dev": true,
776
+ "license": "MIT",
777
+ "peerDependencies": {
778
+ "@types/react": "^19.3.0"
779
+ }
780
+ },
781
+ "node_modules/abcjs": {
782
+ "version": "6.6.3",
783
+ "resolved": "https://registry.npmjs.org/abcjs/-/abcjs-6.6.3.tgz",
784
+ "integrity": "sha512-BerGJCY8+pvJV1+VxZn1Y/VNcuSAk8BysCbBICY0W8fgE5g4W6sA/zB5pKxcgqzY5/gObh8ugl++4ZoaTqUCkw==",
785
+ "license": "MIT",
786
+ "funding": {
787
+ "type": "github",
788
+ "url": "https://github.com/sponsors/paulrosen"
789
+ }
790
+ },
791
+ "node_modules/baseline-browser-mapping": {
792
+ "version": "2.11.22",
793
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz",
794
+ "integrity": "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==",
795
+ "license": "Apache-2.0",
796
+ "bin": {
797
+ "baseline-browser-mapping": "dist/cli.cjs"
798
+ },
799
+ "engines": {
800
+ "node": ">=6.0.0"
801
+ }
802
+ },
803
+ "node_modules/caniuse-lite": {
804
+ "version": "1.0.30001810",
805
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
806
+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
807
+ "funding": [
808
+ {
809
+ "type": "opencollective",
810
+ "url": "https://opencollective.com/browserslist"
811
+ },
812
+ {
813
+ "type": "tidelift",
814
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
815
+ },
816
+ {
817
+ "type": "github",
818
+ "url": "https://github.com/sponsors/ai"
819
+ }
820
+ ],
821
+ "license": "CC-BY-4.0"
822
+ },
823
+ "node_modules/client-only": {
824
+ "version": "0.0.1",
825
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
826
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
827
+ "license": "MIT"
828
+ },
829
+ "node_modules/csstype": {
830
+ "version": "3.2.3",
831
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
832
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
833
+ "dev": true,
834
+ "license": "MIT"
835
+ },
836
+ "node_modules/detect-libc": {
837
+ "version": "2.1.2",
838
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
839
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
840
+ "license": "Apache-2.0",
841
+ "optional": true,
842
+ "engines": {
843
+ "node": ">=8"
844
+ }
845
+ },
846
+ "node_modules/fetch-event-stream": {
847
+ "version": "0.1.6",
848
+ "resolved": "https://registry.npmjs.org/fetch-event-stream/-/fetch-event-stream-0.1.6.tgz",
849
+ "integrity": "sha512-GREtJ5HNikdU2AXtZ6E/5bk+aslMU6ie5mPG6H9nvsdDkkHQ6m5lHwmmmDTOBexok9hApQ7EprsXCdmz9ZC68w==",
850
+ "license": "MIT"
851
+ },
852
+ "node_modules/lucide-react": {
853
+ "version": "0.577.0",
854
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz",
855
+ "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
856
+ "license": "ISC",
857
+ "peerDependencies": {
858
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
859
+ }
860
+ },
861
+ "node_modules/nanoid": {
862
+ "version": "3.3.19",
863
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
864
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
865
+ "funding": [
866
+ {
867
+ "type": "github",
868
+ "url": "https://github.com/sponsors/ai"
869
+ }
870
+ ],
871
+ "license": "MIT",
872
+ "bin": {
873
+ "nanoid": "bin/nanoid.cjs"
874
+ },
875
+ "engines": {
876
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
877
+ }
878
+ },
879
+ "node_modules/next": {
880
+ "version": "16.3.4",
881
+ "resolved": "https://registry.npmjs.org/next/-/next-16.3.4.tgz",
882
+ "integrity": "sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==",
883
+ "license": "MIT",
884
+ "dependencies": {
885
+ "@next/env": "16.3.4",
886
+ "@swc/helpers": "0.5.23",
887
+ "baseline-browser-mapping": "^2.9.19",
888
+ "caniuse-lite": "^1.0.30001579",
889
+ "postcss": "8.5.23",
890
+ "styled-jsx": "5.1.6"
891
+ },
892
+ "bin": {
893
+ "next": "dist/bin/next"
894
+ },
895
+ "engines": {
896
+ "node": ">=20.9.0"
897
+ },
898
+ "optionalDependencies": {
899
+ "@next/swc-darwin-arm64": "16.3.4",
900
+ "@next/swc-darwin-x64": "16.3.4",
901
+ "@next/swc-linux-arm64-gnu": "16.3.4",
902
+ "@next/swc-linux-arm64-musl": "16.3.4",
903
+ "@next/swc-linux-x64-gnu": "16.3.4",
904
+ "@next/swc-linux-x64-musl": "16.3.4",
905
+ "@next/swc-win32-arm64-msvc": "16.3.4",
906
+ "@next/swc-win32-x64-msvc": "16.3.4",
907
+ "sharp": "^0.35.4"
908
+ },
909
+ "peerDependencies": {
910
+ "@opentelemetry/api": "^1.1.0",
911
+ "@playwright/test": "^1.51.1",
912
+ "babel-plugin-react-compiler": "*",
913
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
914
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
915
+ "sass": "^1.3.0"
916
+ },
917
+ "peerDependenciesMeta": {
918
+ "@opentelemetry/api": {
919
+ "optional": true
920
+ },
921
+ "@playwright/test": {
922
+ "optional": true
923
+ },
924
+ "babel-plugin-react-compiler": {
925
+ "optional": true
926
+ },
927
+ "sass": {
928
+ "optional": true
929
+ }
930
+ }
931
+ },
932
+ "node_modules/picocolors": {
933
+ "version": "1.1.1",
934
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
935
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
936
+ "license": "ISC"
937
+ },
938
+ "node_modules/postcss": {
939
+ "version": "8.5.23",
940
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
941
+ "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
942
+ "funding": [
943
+ {
944
+ "type": "opencollective",
945
+ "url": "https://opencollective.com/postcss/"
946
+ },
947
+ {
948
+ "type": "tidelift",
949
+ "url": "https://tidelift.com/funding/github/npm/postcss"
950
+ },
951
+ {
952
+ "type": "github",
953
+ "url": "https://github.com/sponsors/ai"
954
+ }
955
+ ],
956
+ "license": "MIT",
957
+ "dependencies": {
958
+ "nanoid": "^3.3.16",
959
+ "picocolors": "^1.1.1",
960
+ "source-map-js": "^1.2.1"
961
+ },
962
+ "engines": {
963
+ "node": "^10 || ^12 || >=14"
964
+ }
965
+ },
966
+ "node_modules/react": {
967
+ "version": "19.2.4",
968
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
969
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
970
+ "license": "MIT",
971
+ "engines": {
972
+ "node": ">=0.10.0"
973
+ }
974
+ },
975
+ "node_modules/react-dom": {
976
+ "version": "19.2.4",
977
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
978
+ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
979
+ "license": "MIT",
980
+ "dependencies": {
981
+ "scheduler": "^0.27.0"
982
+ },
983
+ "peerDependencies": {
984
+ "react": "^19.2.4"
985
+ }
986
+ },
987
+ "node_modules/scheduler": {
988
+ "version": "0.27.0",
989
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
990
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
991
+ "license": "MIT"
992
+ },
993
+ "node_modules/semver": {
994
+ "version": "7.8.5",
995
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
996
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
997
+ "license": "ISC",
998
+ "optional": true,
999
+ "bin": {
1000
+ "semver": "bin/semver.js"
1001
+ },
1002
+ "engines": {
1003
+ "node": ">=10"
1004
+ }
1005
+ },
1006
+ "node_modules/sharp": {
1007
+ "version": "0.35.4",
1008
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
1009
+ "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
1010
+ "license": "Apache-2.0",
1011
+ "optional": true,
1012
+ "dependencies": {
1013
+ "@img/colour": "^1.1.0",
1014
+ "detect-libc": "^2.1.2",
1015
+ "semver": "^7.8.5"
1016
+ },
1017
+ "engines": {
1018
+ "node": ">=20.9.0"
1019
+ },
1020
+ "funding": {
1021
+ "url": "https://opencollective.com/libvips"
1022
+ },
1023
+ "optionalDependencies": {
1024
+ "@img/sharp-darwin-arm64": "0.35.4",
1025
+ "@img/sharp-darwin-x64": "0.35.4",
1026
+ "@img/sharp-freebsd-wasm32": "0.35.4",
1027
+ "@img/sharp-libvips-darwin-arm64": "1.3.3",
1028
+ "@img/sharp-libvips-darwin-x64": "1.3.3",
1029
+ "@img/sharp-libvips-linux-arm": "1.3.3",
1030
+ "@img/sharp-libvips-linux-arm64": "1.3.3",
1031
+ "@img/sharp-libvips-linux-ppc64": "1.3.3",
1032
+ "@img/sharp-libvips-linux-riscv64": "1.3.3",
1033
+ "@img/sharp-libvips-linux-s390x": "1.3.3",
1034
+ "@img/sharp-libvips-linux-x64": "1.3.3",
1035
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
1036
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.3",
1037
+ "@img/sharp-linux-arm": "0.35.4",
1038
+ "@img/sharp-linux-arm64": "0.35.4",
1039
+ "@img/sharp-linux-ppc64": "0.35.4",
1040
+ "@img/sharp-linux-riscv64": "0.35.4",
1041
+ "@img/sharp-linux-s390x": "0.35.4",
1042
+ "@img/sharp-linux-x64": "0.35.4",
1043
+ "@img/sharp-linuxmusl-arm64": "0.35.4",
1044
+ "@img/sharp-linuxmusl-x64": "0.35.4",
1045
+ "@img/sharp-webcontainers-wasm32": "0.35.4",
1046
+ "@img/sharp-win32-arm64": "0.35.4",
1047
+ "@img/sharp-win32-ia32": "0.35.4",
1048
+ "@img/sharp-win32-x64": "0.35.4"
1049
+ },
1050
+ "peerDependenciesMeta": {
1051
+ "@types/node": {
1052
+ "optional": true
1053
+ }
1054
+ }
1055
+ },
1056
+ "node_modules/source-map-js": {
1057
+ "version": "1.2.1",
1058
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1059
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1060
+ "license": "BSD-3-Clause",
1061
+ "engines": {
1062
+ "node": ">=0.10.0"
1063
+ }
1064
+ },
1065
+ "node_modules/styled-jsx": {
1066
+ "version": "5.1.6",
1067
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
1068
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
1069
+ "license": "MIT",
1070
+ "dependencies": {
1071
+ "client-only": "0.0.1"
1072
+ },
1073
+ "engines": {
1074
+ "node": ">= 12.0.0"
1075
+ },
1076
+ "peerDependencies": {
1077
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
1078
+ },
1079
+ "peerDependenciesMeta": {
1080
+ "@babel/core": {
1081
+ "optional": true
1082
+ },
1083
+ "babel-plugin-macros": {
1084
+ "optional": true
1085
+ }
1086
+ }
1087
+ },
1088
+ "node_modules/tslib": {
1089
+ "version": "2.8.1",
1090
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
1091
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
1092
+ "license": "0BSD"
1093
+ },
1094
+ "node_modules/typescript": {
1095
+ "version": "5.9.3",
1096
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1097
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1098
+ "dev": true,
1099
+ "license": "Apache-2.0",
1100
+ "bin": {
1101
+ "tsc": "bin/tsc",
1102
+ "tsserver": "bin/tsserver"
1103
+ },
1104
+ "engines": {
1105
+ "node": ">=14.17"
1106
+ }
1107
+ },
1108
+ "node_modules/undici-types": {
1109
+ "version": "7.18.2",
1110
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
1111
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
1112
+ "dev": true,
1113
+ "license": "MIT"
1114
+ }
1115
+ }
1116
+ }
package.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"name":"score-studio","version":"1.0.0","private":true,"scripts":{"dev":"next dev --hostname 0.0.0.0","build":"next build","start":"next start --hostname 0.0.0.0","typecheck":"tsc --noEmit"},"dependencies":{"next":"16.3.4","react":"19.2.4","react-dom":"19.2.4","@gradio/client":"2.6.0","abcjs":"6.6.3","lucide-react":"^0.577.0"},"devDependencies":{"typescript":"^5.9.3","@types/node":"^24.0.0","@types/react":"^19.0.0","@types/react-dom":"^19.0.0"}}
public/LICENSE.abcjs ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Copyright (c) 2009-2024 Paul Rosen and Gregory Dyke
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
20
+
21
+ **This text is from: http://opensource.org/licenses/MIT**
public/fonts/README.md ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Fonts
2
+
3
+ Supreme Regular and Bespoke Stencil Bold by Indian Type Foundry, distributed by Fontshare.
4
+ Official sources: https://www.fontshare.com/fonts/supreme and https://www.fontshare.com/fonts/bespoke-stencil
5
+ Fontshare licensing: https://www.fontshare.com/licenses
6
+ Downloaded from the official Fontshare CSS/CDN for self-hosted web use.
public/fonts/bespoke-stencil-bold.woff2 ADDED
Binary file (21.8 kB). View file
 
public/fonts/supreme-regular.woff2 ADDED
Binary file (18.7 kB). View file
 
public/performers/drums.png ADDED

Git LFS Details

  • SHA256: a5e361d71293ab3113253eb606dc518abf1e37d5c9f8fd0cd5fb5f808e46b2fc
  • Pointer size: 132 Bytes
  • Size of remote file: 1.64 MB
public/performers/guitar.png ADDED

Git LFS Details

  • SHA256: 75b8bd94023bd54a9141bb1ff3eb9aa16e19258dad9ad34151062a607de4f8dc
  • Pointer size: 131 Bytes
  • Size of remote file: 917 kB
public/performers/piano.png ADDED

Git LFS Details

  • SHA256: f01e151ccd9d581245c587f78dfc1c9bdb7b9f77ef2811a3306aeb58b76e408e
  • Pointer size: 131 Bytes
  • Size of remote file: 785 kB
public/performers/saxophone.png ADDED

Git LFS Details

  • SHA256: 8225611d5c4c750d4358f94abe5fc140e54a259da16d8d4e849ac7f3c90421d4
  • Pointer size: 131 Bytes
  • Size of remote file: 874 kB
public/saxophonist-sprite.png ADDED

Git LFS Details

  • SHA256: 4a043bf9149bfc8084279c707b16747d8e03da046f338147110b2748a582d5e4
  • Pointer size: 132 Bytes
  • Size of remote file: 1.72 MB
public/soundfonts/ATTRIBUTION.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Piano samples
2
+
3
+ FluidR3 GM by Frank Wen. License: Creative Commons Attribution 3.0 US.
4
+ https://creativecommons.org/licenses/by/3.0/us/
5
+
6
+ Pre-rendered MP3 samples from Paul Rosen's fork of gleitz/midi-js-soundfonts:
7
+ https://github.com/paulrosen/midi-js-soundfonts
8
+ https://raw.githubusercontent.com/paulrosen/midi-js-soundfonts/gh-pages/FluidR3_GM/acoustic_grand_piano-mp3.js
9
+
10
+ The base64 samples were unpacked into individual MP3 files without changing the
11
+ audio, for same-origin/offline ABCJS score playback. Only piano is bundled.
public/soundfonts/acoustic_grand_piano-mp3/A0.mp3 ADDED
Binary file (25.6 kB). View file
 
public/soundfonts/acoustic_grand_piano-mp3/A1.mp3 ADDED
Binary file (25.6 kB). View file
 
public/soundfonts/acoustic_grand_piano-mp3/A2.mp3 ADDED
Binary file (25.6 kB). View file
 
public/soundfonts/acoustic_grand_piano-mp3/A3.mp3 ADDED
Binary file (25.6 kB). View file
 
public/soundfonts/acoustic_grand_piano-mp3/A4.mp3 ADDED
Binary file (25.6 kB). View file
 
public/soundfonts/acoustic_grand_piano-mp3/A5.mp3 ADDED
Binary file (18.9 kB). View file