diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..df10ba43ea096586fc0dc9ee9d5e27cea9ef1332 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +.env* +.git +*.tsbuildinfo +__pycache__ +test-results diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..bcfe918becdb01ea31e4bf92bf4099fcddd9b08b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +public/performers/drums.png filter=lfs diff=lfs merge=lfs -text +public/performers/guitar.png filter=lfs diff=lfs merge=lfs -text +public/performers/piano.png filter=lfs diff=lfs merge=lfs -text +public/performers/saxophone.png filter=lfs diff=lfs merge=lfs -text +public/saxophonist-sprite.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..3467f5eda0175f22c6fba95ad2595506a95b7519 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.next/ +.env* +!.env.example +*.tsbuildinfo +__pycache__/ +test-results/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..d33194eeacb4068bb8c7f41af9d8f1480eea26a8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:24-bookworm-slim AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:24-bookworm-slim AS runtime +RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-venv ffmpeg ca-certificates \ + && python3 -m venv /opt/python \ + && /opt/python/bin/pip install --no-cache-dir yt-dlp==2026.8.19 \ + && rm -rf /var/lib/apt/lists/* \ + && useradd -m -u 1000 studio +WORKDIR /app +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=7860 PYTHON_BIN=/opt/python/bin/python +COPY --from=build --chown=studio:studio /app/.next/standalone ./ +COPY --from=build --chown=studio:studio /app/.next/static ./.next/static +COPY --from=build --chown=studio:studio /app/public ./public +COPY --from=build --chown=studio:studio /app/scripts ./scripts +USER studio +EXPOSE 7860 +CMD ["node", "--use-system-ca", "server.js"] diff --git a/README.md b/README.md index 1d02fda49af7105c81821b31d2e9746f8d2fa1ba..c771fd797bdc8abadbdd041ce5aadd3bdde17cf2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,76 @@ --- title: Score Studio -emoji: 🏃 -colorFrom: blue -colorTo: blue +emoji: 🎼 +colorFrom: yellow +colorTo: red sdk: docker -pinned: false +app_port: 7860 +short_description: Turn audio and YouTube music into editable sheet music +models: + - m-a-p/SheetSage2 +tags: + - audio-to-score + - sheet-music + - music-transcription + - audio-to-midi --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# Score Studio + +Upload music, record with your microphone, or import a public YouTube video. +Sign in with Hugging Face to use your own ZeroGPU allowance. Play the resulting +lead sheet with a moving note highlight, edit its notation, undo/redo changes, +restore the AI original, and download ABC, MIDI or PDF. + +The interface uses Supreme Regular and Bespoke Stencil Bold from Fontshare, +follows your device's preferred theme until you choose one, and switches themes +with an expanding circular wave. Reduced-motion preferences are respected. + +## Run locally + +Requires Node 24, Python 3 with `yt-dlp==2026.8.19`, and FFmpeg/FFprobe on PATH. +Run `npm ci`, configure values from `env.example` in `.env.local`, then `npm run dev`. +Open http://localhost:3000. The development-only animation preview is at +http://localhost:3000/preview/transcribe. + +## Hosting + +The frontend runs on a CPU Docker Space. Inference runs separately on the public +[Score Studio API](https://huggingface.co/spaces/Reubencf/Score-Studio-API) +Gradio ZeroGPU Space. The original Audio-to-Score Space remains separate. + +Before deploying, configure these Space settings: + +- Secret `SESSION_SECRET`: 32 random bytes encoded as 64 hexadecimal characters. +- Variable `APP_ORIGIN`: `https://reubencf-score-studio.hf.space`. +- Optional `HF_API_SPACE`, `HF_API_ORIGIN`, `HF_OAUTH_CLIENT_ID`: defaults point to the dedicated API. + +The Dockerfile includes Python, yt-dlp, FFmpeg and the standalone Next.js build. +No owner Hugging Face token is required or used for public requests. The login +flow uses Hugging Face's Client ID Metadata Documents and authorization code +with PKCE, requesting only `openid profile`. User access tokens are encrypted in +HttpOnly cookies, expire within eight hours, and are never returned to frontend JavaScript. +Serve the app over HTTPS and keep the session secret stable across restarts. +Login opens the app at its own origin so it does not depend on third-party cookies. + +YouTube import and transcription routes require a valid session and same-origin +submission. Imported audio is accessible only to the importing session's account +and expires after an hour. Backend score downloads expire after 24 hours. + +## Limits + +Audio must be 5 seconds to 10 minutes and under 100 MB. The whole recording is +validated and submitted; longer input is rejected, not silently truncated. +Public YouTube videos may be unavailable to a hosted server because of YouTube's +restrictions. The importer does not bypass login, age or access restrictions; +users can upload their own audio when import is unavailable. + +SheetSage2 produces melody and chord lead sheets, not a complete orchestral score. +Review AI notation before performing. The model weights are CC BY-NC 4.0 +(non-commercial). Fonts are distributed by [Fontshare](https://www.fontshare.com/licenses). + +## Checks + +`npm run typecheck` and `npm run build` verify the production frontend. +Before publishing, verify real Hugging Face login, full audio/YouTube processing, +score playback and editing, both themes, keyboard controls and reduced motion. diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..96c9521f543ca11c131bff2bca51849aff98eae5 --- /dev/null +++ b/app/api/auth/route.ts @@ -0,0 +1,12 @@ +import {cookies} from 'next/headers'; +import {authorize,cookieOptions,session,SESSION_COOKIE} from '@/lib/auth'; +export const runtime='nodejs'; +export async function GET(){ + const current=await session(); + return Response.json({user:current?.user??null},{headers:{'Cache-Control':'no-store'}}); +} +export async function DELETE(req:Request){ + const auth=await authorize(req);if(auth.error)return auth.error; + (await cookies()).set(SESSION_COOKIE,'',cookieOptions(0)); + return Response.json({ok:true},{headers:{'Cache-Control':'no-store'}}); +} diff --git a/app/api/media/route.ts b/app/api/media/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9e366971a569a0526c7f228df4ad20591c022a1 --- /dev/null +++ b/app/api/media/route.ts @@ -0,0 +1,15 @@ +import {readFile,stat} from 'node:fs/promises'; +import path from 'node:path'; +import {mediaRoot} from '@/lib/media'; +import {authorize} from '@/lib/auth'; +export const runtime='nodejs'; +export async function GET(req:Request){ + const auth=await authorize(req);if(auth.error)return auth.error; + const id=new URL(req.url).searchParams.get('id')||''; + if(!/^[a-f0-9-]{36}$/.test(id))return new Response('Not found',{status:404}); + try{if(await readFile(path.join(mediaRoot,id,'owner'),'utf8')!==auth.session.user.id)return new Response('Not found',{status:404}); + const file=path.join(mediaRoot,id,'audio.wav');const info=await stat(file); + if(Date.now()-info.mtimeMs>3600000)return new Response('This import expired. Import the video again.',{status:410}); + return new Response(await readFile(file),{headers:{'Content-Type':'audio/wav','Cache-Control':'private, no-store','Content-Disposition':'attachment; filename="youtube-excerpt.wav"'}}); + }catch{return new Response('Not found',{status:404});} +} diff --git a/app/api/transcribe/route.ts b/app/api/transcribe/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..96bb8191b051593f1119717a1f00cc25021d8afa --- /dev/null +++ b/app/api/transcribe/route.ts @@ -0,0 +1,61 @@ +import {Client,handle_file} from '@gradio/client'; +import type {ScoreResult,Download} from '@/lib/types'; +import {prepareAudio} from '@/lib/audio'; +import {authorize} from '@/lib/auth'; +export const runtime='nodejs'; +const SPACE=process.env.HF_API_SPACE||'Reubencf/Score-Studio-API'; +const ORIGIN=process.env.HF_API_ORIGIN||'https://reubencf-score-studio-api.hf.space'; +function file(value:unknown):Download|null { + if(!value||typeof value!=='object')return null; + const data=value as {url?:string;path?:string;orig_name?:string}; + const url=data.url||(data.path?ORIGIN+'/gradio_api/file='+data.path:''); + try{if(new URL(url).origin!==ORIGIN)return null;}catch{return null;} + return {url,name:data.orig_name||data.path?.split('/').pop()||'download'}; +} +export async function POST(req:Request){ + const auth=await authorize(req);if(auth.error)return auth.error; + if(Number(req.headers.get('content-length')||0)>102*1024*1024)return Response.json({error:'Choose an audio file under 100 MB.'},{status:413}); + const form=await req.formData().catch(()=>null); + const input=form?.get('audio'); + if(!(input instanceof File)||input.size>100*1024*1024||!input.size) + return Response.json({error:'Choose an audio file under 100 MB.'},{status:400}); + const enc=new TextEncoder();let cancelJob:(()=>void)|undefined,closed=false,client:Client|undefined; + const preparation=new AbortController(); + const stream=new ReadableStream({ + async start(controller){ + const send=(type:string,data:unknown)=>{if(!closed)controller.enqueue(enc.encode(JSON.stringify({type,data})+'\n'));}; + const abort=()=>{closed=true;preparation.abort();cancelJob?.();client?.close();};req.signal.addEventListener('abort',abort,{once:true}); + try{ + if(req.signal.aborted){abort();return;} + send('status','Reading the full recording…'); + const {audio,duration}=await prepareAudio(input,preparation.signal); + if(closed)return; + send('status','Connecting to the transcription studio…'); + client=await Client.connect(SPACE,{events:['status','data'],token:auth.session.token as `hf_${string}`}); + if(req.signal.aborted||closed){client.close();return;} + const job=client.submit('/transcribe',[handle_file(audio),0,duration,false,true]); + cancelJob=()=>{job.cancel();};let result:ScoreResult|null=null; + for await(const event of job){ + if(closed)break; + if(event.type==='status'){ + if(event.stage==='error')throw new Error(typeof event.message==='string'?event.message:'Conversion failed. Please try again.'); + const details=event as unknown as {progress_data?:{desc?:string}[];position?:number}; + send('status',details.progress_data?.find(p=>p.desc)?.desc|| (event.stage==='pending'?'Waiting for an available GPU…':'Listening and writing your score…')); + } + if(event.type==='data'){ + const data=event.data as unknown[]; + if(typeof data[5]==='string'&&data[5].trim()){ + 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}; + } + } + } + if(!closed){if(!result)throw new Error('No editable score was returned. Try another recording.');send('result',result);} + }catch(error){ + let message=error instanceof Error?error.message:'Conversion could not finish. Please try again.'; + 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.'; + send('error',message.slice(0,600)); + }finally{client?.close();req.signal.removeEventListener('abort',abort);if(!closed)controller.close();} + },cancel(){closed=true;preparation.abort();cancelJob?.();client?.close();} + }); + return new Response(stream,{headers:{'Content-Type':'application/x-ndjson','Cache-Control':'no-store','X-Accel-Buffering':'no'}}); +} diff --git a/app/api/youtube/route.ts b/app/api/youtube/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6497606ad8561681856bec7ded481ab521eb508 --- /dev/null +++ b/app/api/youtube/route.ts @@ -0,0 +1,35 @@ +import {spawn} from 'node:child_process'; +import {mkdir,rm,writeFile} from 'node:fs/promises'; +import {authorize} from '@/lib/auth'; +import path from 'node:path'; +import {randomUUID} from 'node:crypto'; +import {youtubeId} from '@/lib/youtube'; +import {mediaRoot,prepareMedia} from '@/lib/media'; +export const runtime='nodejs'; +let active=0; +export async function POST(req:Request){ + const auth=await authorize(req);if(auth.error)return auth.error; + if(active>=2)return Response.json({error:'Two imports are in progress. Please try again shortly.'},{status:429}); + const body=await req.json().catch(()=>null); + const id=typeof body?.url==='string'?youtubeId(body.url):null; + if(!id)return Response.json({error:'Enter a valid YouTube video link.'},{status:400}); + active++; const ticket=randomUUID(),dir=path.join(mediaRoot,ticket); + try{ + await prepareMedia();await mkdir(dir); + const result=await new Promise>((resolve,reject)=>{ + const child=spawn(/*turbopackIgnore: true*/ process.env.PYTHON_BIN||'python',['scripts/youtube_audio.py',id,dir],{shell:false,windowsHide:true}); + let output='';let settled=false; + const finish=(err?:Error,result?:Record)=>{if(settled)return;settled=true;clearTimeout(timer);req.signal.removeEventListener('abort',abort);if(err)reject(err);else resolve(result!);}; + const abort=()=>{child.kill();finish(new Error('Import cancelled.'));}; + const timer=setTimeout(()=>{child.kill();finish(new Error('YouTube import timed out. Please try again or upload an audio file.'));},150000); + req.signal.addEventListener('abort',abort,{once:true}); + child.stdout.on('data',data=>{output+=data.toString();if(output.length>100000){child.kill();finish(new Error('Import returned an invalid response.'));}}); + child.stderr.resume(); + child.on('error',()=>finish(new Error('YouTube importer is unavailable. Please use an audio file.'))); + 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.'));}}); + }); + await writeFile(path.join(dir,'owner'),auth.session.user.id); + return Response.json({...result,audioUrl:'/api/media?id='+ticket},{headers:{'Cache-Control':'no-store'}}); + }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});} + finally{active--;} +} diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..136929360e8904f94c6a2557abe9a92300d16ca8 --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,23 @@ +import {cookies} from 'next/headers'; +import {NextResponse} from 'next/server'; +import {appOrigin,CLIENT_ID,FLOW_COOKIE,SESSION_COOKIE,seal,unseal,cookieOptions,type OAuthFlow} from '@/lib/auth'; +export const runtime='nodejs'; +export async function GET(req:Request){ + const jar=await cookies(),params=new URL(req.url).searchParams; + const flow=unseal(jar.get(FLOW_COOKIE)?.value); + jar.set(FLOW_COOKIE,'',cookieOptions(0)); + const failure=()=>NextResponse.redirect(appOrigin()+'/?signIn=retry',{headers:{'Cache-Control':'no-store'}}); + if(!flow||flow.expiresspan{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}} +.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} +.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} +.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)}} + +.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} +@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)}} +@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}} +@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}} +.process-label+.text-button{margin-top:16px}.performer-unavailable{min-height:350px;display:grid;place-items:center;color:var(--muted);font-size:13px} +@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}} diff --git a/app/identity.css b/app/identity.css new file mode 100644 index 0000000000000000000000000000000000000000..368f754691eeccbd70c7024f82c56c1e66d07fb9 --- /dev/null +++ b/app/identity.css @@ -0,0 +1,37 @@ +@font-face{font-family:Supreme;src:url('/fonts/supreme-regular.woff2') format('woff2');font-weight:400;font-style:normal;font-display:swap} +@font-face{font-family:'Bespoke Stencil';src:url('/fonts/bespoke-stencil-bold.woff2') format('woff2');font-weight:700;font-style:normal;font-display:swap} +:root{--sans:Supreme,Arial,sans-serif;--display:'Bespoke Stencil',Supreme,sans-serif} +body{transition:none} +h1,h2,h3,.panel-intro h1{font-family:var(--display);font-weight:700;letter-spacing:-.025em} +.panel-intro h1{font-size:27px;line-height:1.2} +.file-hint,.process-label{font-family:var(--sans)} +::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal} +::view-transition-old(root){z-index:1} +::view-transition-new(root){z-index:2} +::view-transition-group(root){animation-duration:0s} +.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%} +.account-button:hover{color:var(--accent);background:var(--surface)} +.auth-loading{min-height:100svh;display:grid;place-items:center;color:var(--accent)} +.sign-in-view{min-height:100svh;display:flex;justify-content:center;align-items:center;padding:90px 28px 50px} +.sign-in-content{width:100%;max-width:440px} +.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} +.sign-in-content h1{font-size:clamp(43px,7vw,61px);line-height:1.06;margin:0 0 23px} +.sign-in-content>p{max-width:370px;font-size:18px;line-height:1.6;color:var(--muted);margin:0 0 29px} +.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} +.hf-sign-in>span{font-size:23px;line-height:1} +.hf-sign-in:hover{transform:translateY(-2px);background:#ed491d} +.sign-in-content>.sign-in-note{font-size:13px;line-height:1.7;margin:20px 0 0} +.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} +.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)} +.pill-rest{font-size:13px;letter-spacing:.07em;text-transform:uppercase} +.pill-hover{gap:9px;opacity:0;transform:translateY(28px);font-size:12px;white-space:nowrap} +.pill-circles{display:flex;padding-left:7px} +.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} +.pill-circles>span:nth-child(2){background:var(--accent);color:white}.pill-circles>span:last-child{background:#fff;color:#191919} +.create-score-pill:is(:hover,:focus-visible):not(:disabled) .pill-rest{opacity:0;transform:translateY(-28px)} +.create-score-pill:is(:hover,:focus-visible):not(:disabled) .pill-hover{opacity:1;transform:translateY(0)} +.create-score-pill:hover:not(:disabled){box-shadow:inset 0 1px 1px #ffffff38,0 5px 12px #0003;transform:translateY(-1px)} +.create-score-pill:active:not(:disabled){transform:translateY(0)} +.create-score-pill:disabled{opacity:.5} +@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}} +@media(prefers-reduced-motion:reduce){.pill-rest,.pill-hover,.create-score-pill,.hf-sign-in{transition:none}} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c9f2ab1f6232a74f7e94566d5ff81fcac85f7f9c --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,6 @@ +import type { Metadata } from 'next'; +import './globals.css'; +import './identity.css'; +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.' }; +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';}`; +export default function Layout({children}:{children:React.ReactNode}) { return