From 04e8f8ebe8c470ecfe17d29cfb439c898000c961 Mon Sep 17 00:00:00 2001 From: kim Date: Mon, 22 Jun 2026 17:44:32 +0900 Subject: [PATCH] =?UTF-8?q?v3.0.9:=20=EB=AF=B8=EB=94=94=20=EC=97=90?= =?UTF-8?q?=EB=94=94=ED=84=B0=20=EB=8C=80=ED=8F=AD=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=E2=80=94=20=EC=86=94=EB=A1=9C/=EB=AE=A4=ED=8A=B8/CC/=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 미디 에디터: - 솔로/뮤트 실시간 반영: writeMidi 기반으로 buildSpessaMidi 재설계, 토글 시 sfReloadSong()으로 즉시 시퀀서 리로드 - MIDI 파서 CC 이벤트 보존: 기존에 모든 CC(볼륨·팬·expression·리버브 등) 버리던 것 수정, ccs[] 배열로 저장·재출력 - 멀티채널 트랙 분리: 포맷0 등 한 트랙에 채널 혼합 시 채널별로 자동 분리, 드럼 채널 보호 - 노트에 ch 필드 추가: 파서에서 각 노트에 실제 MIDI 채널 저장, writeMidi에서 활용 - 롤에디터 기본 8마디 보기, 마스터 FX 패널 추가 - 기타프로 마스터 볼륨 200%까지 확장 기타프로 MIDI 포트 매핑: - sendMidiProgramChanges 순서 수정: PC→Bank 오류를 Bank→PC로 교정 - 세컨더리 채널(팜뮤트·하모닉스) 초기화 추가 - CC7 볼륨·CC10 팬 초기값 전송 추가 Co-Authored-By: Claude Sonnet 4.6 --- .smallclaw/config.json | 43 + ...urple — Black Night (5) [MIDIfind.com].mid | Bin 41632 -> 0 bytes .../Midnight Blue Claw/Midnight Blue Claw.mid | Bin 640 -> 0 bytes .../workspace/music/SMOK_WAT/SMOK_WAT.MID | Bin 56594 -> 0 bytes .../workspace/music/SMOK_WAT/SMOK_WAT.mid | Bin 56594 -> 0 bytes .../Super Exciting Jazz.mid | Bin 518 -> 0 bytes .../smallclaw_morning/smallclaw_morning.mid | Bin 331 -> 0 bytes .../smallclaw_morning_full.mid | Bin 1091 -> 0 bytes .../soldieroffortune/soldieroffortune.mid | Bin 32011 -> 0 bytes .../music/신나는_재즈_잼/신나는_재즈_잼.mid | Bin 1577 -> 0 bytes .smallclaw/workspace/memory/2026-06-21.md | 14 + src/gateway/routes-music.ts | 134 + src/gateway/server-v2.ts | 267 ++ web-ui/app.js | 58 +- web-ui/index.html | 81 +- web-ui/midi-editor.html | 915 ------- web-ui/mind-app.html | 278 +- web-ui/music-app.html | 2316 ++++++++++++++++- web-ui/styles.css | 32 +- 19 files changed, 3130 insertions(+), 1008 deletions(-) delete mode 100644 .smallclaw/users/papa/workspace/music/Deep Purple — Black Night (5) [MIDIfind.com]/Deep Purple — Black Night (5) [MIDIfind.com].mid delete mode 100644 .smallclaw/users/papa/workspace/music/Midnight Blue Claw/Midnight Blue Claw.mid delete mode 100644 .smallclaw/users/papa/workspace/music/SMOK_WAT/SMOK_WAT.MID delete mode 100644 .smallclaw/users/papa/workspace/music/SMOK_WAT/SMOK_WAT.mid delete mode 100644 .smallclaw/users/papa/workspace/music/Super Exciting Jazz/Super Exciting Jazz.mid delete mode 100644 .smallclaw/users/papa/workspace/music/smallclaw_morning/smallclaw_morning.mid delete mode 100644 .smallclaw/users/papa/workspace/music/smallclaw_morning_full/smallclaw_morning_full.mid delete mode 100644 .smallclaw/users/papa/workspace/music/soldieroffortune/soldieroffortune.mid delete mode 100644 .smallclaw/users/papa/workspace/music/신나는_재즈_잼/신나는_재즈_잼.mid delete mode 100644 web-ui/midi-editor.html diff --git a/.smallclaw/config.json b/.smallclaw/config.json index ffd32c8..01dc39b 100644 --- a/.smallclaw/config.json +++ b/.smallclaw/config.json @@ -266,5 +266,48 @@ }, "pubmed": { "api_key": "vault:pubmed.api_key" + }, + "email": { + "accounts": [ + { + "id": "applecherry", + "imap_host": "121.183.65.201", + "imap_port": 993, + "imap_tls": true, + "imap_reject_unauthorized": false, + "smtp_host": "121.183.65.201", + "smtp_port": 587, + "smtp_secure": false, + "smtp_reject_unauthorized": false, + "username": "kim@applecherry.net", + "password": "vault:email.accounts.applecherry.password" + }, + { + "id": "evangel", + "imap_host": "mail.evangel.xyz", + "imap_port": 993, + "imap_tls": true, + "imap_reject_unauthorized": false, + "smtp_host": "mail.evangel.xyz", + "smtp_port": 587, + "smtp_secure": false, + "smtp_reject_unauthorized": false, + "username": "kim@evangel.xyz", + "password": "vault:email.accounts.evangel.password" + }, + { + "id": "daum", + "imap_host": "imap.daum.net", + "imap_port": 993, + "imap_tls": true, + "imap_reject_unauthorized": true, + "smtp_host": "smtp.daum.net", + "smtp_port": 465, + "smtp_secure": true, + "smtp_reject_unauthorized": true, + "username": "kimsgms@hanmail.net", + "password": "vault:email.accounts.daum.password" + } + ] } } \ No newline at end of file diff --git a/.smallclaw/users/papa/workspace/music/Deep Purple — Black Night (5) [MIDIfind.com]/Deep Purple — Black Night (5) [MIDIfind.com].mid b/.smallclaw/users/papa/workspace/music/Deep Purple — Black Night (5) [MIDIfind.com]/Deep Purple — Black Night (5) [MIDIfind.com].mid deleted file mode 100644 index a85e29628af2dd2fbc69d738e36403db2f2496ec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41632 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 D$Cn)x diff --git a/.smallclaw/users/papa/workspace/music/Midnight Blue Claw/Midnight Blue Claw.mid b/.smallclaw/users/papa/workspace/music/Midnight Blue Claw/Midnight Blue Claw.mid deleted file mode 100644 index 2594876eeb10668914740c75af7fe14324c9742c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 640 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�MtlA+I}@_6{FsMKWWGl@VOT1vP`T>DHt z;WU<0R$f))l*lRitbWF>UUhr0t5a}YZ$7`DCJKomItvx6C(JbtO3%p14zw8j- zCG&VRYvPCAug$xn^lQEFgf!j>f_HM^4<4;EFI)6~d?z;0*KJ<>JFz_>cBI7axNs7p zJ0*JK;!r{yO^M;SIGzxvQsQ)6j3mUFlsFp~=WKDIT8t`jalII=78jH_S0&Est0Qr7 zTEm~v9gn5NP+SZq#G#Zp5Es1(u|Fj`mFP%`b|onFHQoz()A90H5w&{b|4be7Yec_) z#ye@imi%XGleEt!$EH?EyCv=81i4i-oS@Swx)`U+3HmriUYtHo(1$5{FHW}-^mc|iQeVRat+=7Jm7JEG(?VZ- zw9m`$!&1iA0Y}F+Z&w@b*BoPmif5e9M)kY0@SYj{qPB>2VOD-CSYYWJ--&f8(GnMK zLJXzE$+$R`5F^?+=TdB_iz#s_E^-MmmJ)BS6>p@(nEpAM5@+J#XhIC8#35}cctKZO zbZq##i_~|5CdR&YJUr*`L^;*G>-xGjpkY1^^ChSaW=m0PoOV~!jyN?Zs4+ziajH$w z`V`f~X)Rz<)E=jf+pyLb9c?*d0EmOPqOnkctD19jRr_$4XMZQMaj`8ScBaJMxadlV zzLXeHVsMRcZEol~p3=&bfaya{==LuV1R0*rXc z#}^$zjKKSO5id;CFq|+76Vu=ih<~5emWDKcs=CohsV;;*cvqKK^_|$95L+>B5~4jJ zIx5Bf1P}S1DscbH%ttHcoIvMP>=gO%b) zLJXiwLI;OR(U;&^sk@R90&QodXzxQ<08M%ea}e|EhXx&=v}A~QX2voBZ^HUcG^X_E zN{C$uze?=3#hx{yH7@pS5WAIVNr_#GV-*H@b3!zwIAm;HWhTJC+pQxIf}XZuH0l{^ zY7Koh+LdF|8g&_#EZpMsdB*C|@Vf3pAJ!)+mQ&EF*Omk!JV#tmm5@j^l*o6&Z> zrwA{o;FexaGB+f|rj)4H6ZiHMPd+UPv8PH;l{)j>S0&o2zs5B3ooLU0-S4gaPPEx# z-x}e>#a_&qI%VY7pPGTG-`iz|r=Nr%Ht}1AoLo3QTR2(3e3ng!?Nyp!O6*gjz3J-~ zI)hQZ*GS~^X#%qzWNCelb|Y7K^oN?B^Md3@Glcit>VRQF9pV3FR%X`0zK7A{40ZpKr>E(COetyclpiJ*A;SE+Jcx#mjN3EXQN*PU? z_IXZ_x^P4{ZwTk3>8x8OmUp@{n(!9au*JO5@blG%l2$3d4-)T-GX(wVes&OQ@m7Av zDOQ`#9g%ePR&jtu4%V;4p(=4WqXTbB9E|J0TP3=y#s0YHL{wh`d!RRcI>?E}LaGJl zUP!fmZq^#U-(>bOx|zP`Wwh%rqwy>!FQfl!z4$&krkByj@0TvF=3GX9k7e|IETg~w zwU*KMOP0}p#RB*~QnJ5d84b4NUtf3`4ZQQcj0SV_u#CQsRQH!$Mt?u=%jo-9Mt{%C z==(uJ98OW?m(eia^DU!ct>;}ve?KqFXvp@w%joZ4{xbUhb1$R8b?EmTR z`Tp|#Tt9Oe9hn6F(t%kZx=@auKV=z>Wb>Jp(f6^8{$8}_?_(ML z{p@A*{pVjse_wVPeILtcjPkukqJ?Gj{4Artf8k{`?6Hy)jX_Q{cp1GAu;y*Y;*YAz za2dVq-v_(VRsJ)kHMehEyMaCFNnT9n*Pe5EPkM`4O#invVlsJ~WajgK?S5d&MZuCf zJ}DA$QJD}bB^JfSl7z5RVntl6N{H1dk&cVC2~m?0>y+4-5}V?jy40t5n`(QtUuoZ+ z;sQJN1bXz|@?pJSeO&K&Vy|;FE-qDLLtTtji8nTgFI4%w*#DSFPR(i8KE=WG$eTW?1s6MgiN9xpDG}!b#mY|UojmGJX z1YJ+j?Ks^{cwX#5pZD&Mo|mGJxq;qG(XBX*C+Mvdy{Wf`FQn*9oK7WZI7M!p1`>2I zMLluqln?L<-(K&=B$V<1;d@dnp$GU*%cQWY#mcx?oe=9&q7GYidKVM>wAk~&-aNMC zFF+W<;#EtmNQ&1j-g#`c#J;51Z;69Ral{fs zNpai~r;}pD5@)11n-u4yINvMIS>mh|BS~?}62np)Ns2?3=t+w13PIvW>PWH{_3Nz7 zM+?kXAByK0NZ2!y;ZITJ_&o4UmdZ%j?m6wjc~s`$+?o^>pk;}Qq)1vqCdD#Kyp|Me zv=kdGu@RfOme`RLdo0nB6rGyAgO(Ubia|>pONx^g^MA$?=ab@sB}S#VXp7ObxRB*uEO9s~1}rgX3pXi_E*3*lz;MG!-k2P+#L=a~#Z~RGFk)X)^jd2K?^OL6kC&`pd$Vhh7T8nRNSZ9gVNkPr%iCYdbcyG5k8asC8GM#}<{T6Mz zj&TR&6@Lq2oVOsHd}yzhaahXM7RX&p_lA9#-IF_Vg-DAX}vq?H{(dg15F_?b;VkXvQQFp*C5cH+#phk1ZqQgl#0%R5) zP0~<+??jSLMzCIB{%s5xIb$;9eX`Q&5sLr>E_{y%FOiq%v6z?%FmJK4Fl1S7J#blc zNr$xb7{Ny@Es>;S77c^Eq*E4+BncWEP0~djh;!QSuUK>~N#hpX)L#B>lHS)I|A9rH zBt7pP4xgBh4zi^>KwQUmXs$0wkQbyN?8%6ME;e(IgB%I)$tt|n-f+*~xSA9zcp`(p zuF~GM+R~LZOKjHu+L#pEEYXB}RCK7^Da9^Jv`DcVVRJDLE(gorPw1y3vRQ~ z@C`b^BFw(3#{i}e+Y;4D9u*i(#qCHlLzRXj{_24F&_NKOp44K3TfgVvs|bfY=MW!X z`;kp?P@8^0TN&C#JbBBa_b|bYKL8XoM-K)(*c^e@gdX;xB=tz`gt|WzYN9U`$|fs% zV3ICbluObZ7F|!$4YrQwN%|0TXL?e^gDFQ@qADqtT4IeJ!&@ca)6=Ts5oS=#4TwZX z5rC3n*b*nCIF%HqG3!X4N_if|#3LWzP56`IJmyn9836X&Yq(Ke1Pra!Su)pdiAt~$*Il8 zc!rR)5rVl7OdO5*A-G#mPlcZd9ID{FbV$OXL+9FF5*1-%=mJAyu_ppRmnw~adD z2xpk7&5D<2$x>&)<@i{?fD`+Ifehs045~*{SppI7_=$(2!ON{fK zG-kVbk5>&18juQ(Yq0Q<3L1{n3BgJ8*#w953xYA0&G0_~;iwsNb ziizEodYxejXOY;8bF{?7&X~TJ2gZqUmrXHIi?xd-Ua7#mS%CyTCQ>mGj|+iNlzm_l z9Fy!sb1-w>=N*E`7(OxTit$PSY#)qKe~boFdUA@<8&GPD7ZQlkUT#v7$RH)7(gxOm$V*DA&Jm>7?X>q#-bTU@Uc*Yu^UmbelVmt$fyCeFvjS;T^v=#L2} z#f+rdL|08+m&}z#)Qtrk| z2+l8Cq}*l8ol@>#*QH6hUCL%#Hc7e7huA7*qb(bx+)~BtP0D&Hv$ou9%M9}f9Gh%e zC*?+4)>ZW`d)eq4!hMjuupFP)SXP~Dvvz0dS<857KHD9?)j$UcFYVH_|q zz+fGUN&T(GlQM401V+*o$dhE`h~;mhxl;W|Z!E#^oRldAWYD=pzuY3a3=|5dmln}Q zoG!xYXpAmY()k#ji?I#Q0M}wrM~-wlMyD$2BpRxG&QE~zY7iN&q@!qF0yvj#TlFIi zLSsu27m?QufbKHnVTVEC6>xtDmuygiuOD1OeckV=Sji+_dsRCmTJZy(Y_-fv`R;x4^G#*_f7FZMh9jXUpca+-}1cWgUGi?&NK z!VToP7>%L9L6;TYDU}2Lzy+m{=df z?ZmRf;&_D{9DCvt%g^4JI1m#9mBNjQp%}u4+%Lp%OdPKiCz7(umfg1ONy}ba9IjJ5cS7OV=t!GcxM za8E5*!I@vYUY%K*4I-6s)-Wv?*8>P_UXV7OV;=SWOoTRs~bAnnuB@fPxi} zC#O-cDxhFBZ37)(g4J}iU{yfD%5Z)HoL7U$ zFc+)}C|FISU8!Z+oCNfSQSvPf(E!?RY1XNx>&G+uLG>KV1?m; zwn)JWV+LK`^b1xPPxv+N7pySc09;zI!qAMP_J9ENw_C7+xiG|L6|CR@7HUv43s$i` z!5vm{_u|#dWAwvasy>rjaxPbm8>`+jb*leyNBqDyR(1T{f4KM$xX-U2uav;Y#PRgt zmcO-MxMwF6@1^~~hv3aqkc1uICW+T_{ol7u59bHZ+@*xav(GBKL!O!!?(|?U<`_uDa~1OFr9Rl)t1BLR`qH^RE8zd&uS)7oSWi3U=Dn zUwg-Et3rN{yLfF?k>6vk{P8IBL7kGz4$X=x?w)YHLRE zmq>i!ApVw&{uX-(y4k@imDDCj)j4WoM%9L(8yvMhgRhGe)yZEM!8h77YK@~-JL+{` zTwq=0=x??AIspT(IcmA1UUih6(cf(MK_SA@j9TKTY9ADRt1|iK`R}V z%&0^N%AWzrD9ce5juII~6R)6-9j$op``5(eS9ppnWnhnjn@Vd-=c}z zbwmw`|4CvhIgUv3Pc`3UJv&R8?1(uo%ThKwQk|uStoLe`HU;gGt0nCQULQ-^BYjKS zBeY8BM%#7Y9Je%j4UKfDl*5KBRcA-^u?z;?w@kanKWBS{+Yuv?fj8~ZtI>9SJ`%ZF zB59BWcPL>;ZkP&&{&w`WIIW@$sYUd%&juLxbkn`baY z@Qlo+6@}3lJWbJ;F2q@mmg#GHne(V-=AvbA8o8c@R)mT#SF~41u^fknmWGMgBCGX~ z)!0gVh1h&6l<3fK!qiK987k(gKKisGEoC$x;jN6lOkakXo0b`UQ7T3=+)}QWp{S1~ za+&rg_loK;N^y3tkO!mTtnX2#Jv^4Ma$< zFa#4qiBW8(eY3%@88v;8B0wDmzCKHbvmSb}cp4^(!Kf(;8O3HG{l1#IQ4{kYsAg9q zb!ztlZ}p9I80yee;Aea5a$D+XiB_3^-r$J_+S`!hBOn`GoYdFqUV%Hp1I|Y@*`B4{ zfXsU7TB_2r7;cTE>>PnyC6a1xOIkEnZ1MmbhI&y;My{yrrmx?ku^Da8)MS87MXX%X zH==Xj?h!lA#1>dB>fGj>ISF*mdJfz%jUgR%O7IhC5^iuXqRFU>N9}Ip@_j~gJKKv4 z%my>$G>nna*uq>j=b?uLb}?6@vd^U-gTZ*6IX8SpWk1vMGkd4xa@mes(6KA%h#!Z{GN9fQ^%zZ@ADd$_e3y{r z4z*rGEggb>B3P)7+c7?i957^)ufu#3P22!C0e2XE&lwoJ;jx7Ak{KXN_#P$xoMfJbM?~c$1}+lQa7}; zHqdC&MRPSgmU0;O)xBoO#k`q>Gw5YnLeA&z#h8O$A-ut+d!h09>@_zn#$S%si`%Ol ztx$?mTBiMH*o$YvqBe|whDS~7HSikWlfewvbi)L{fVKNJK$<`E;ZOVgVSj$0+gYJ zY;*5cS8Sy0o;wqZT9kbK`q&{It(^8+~WXhyen?13a)@;trJemXpeRV2b zl4LF9b~-~fmMrFTCQp5W+Ggd1PtNTyr#9u%xJzA^S1{oH+`^QDO+@(Rb33M6j@FCk z3LFb&=8+~Rm`%&H|BPILGeS(j3%T8k+v{cKc6y%R^-MP00-2H9?QP77MwIiSM;#;^ zb+HcMcG_#ewP z3bWB4eq3<1EnVk@$F2GF*02RJghN;zjp*#jpTZX+$?q(Zm}1+LXNOrZu=rLF>s_6d z>%QB(^fvg0KF3v^ZiiR(m|B>Xew$Hc#8@Yi5|}mCnz|9>49|AFpdn`;xtd(Ehvsi! zT-EKfkK?+%%+ah5d9kab4ay1S)@sp?Os`pZFG^P0mb%qyyrdSkTL?DdMt zRrRWcsRJM}YxTxgO?q0odea#?)0)vMtT~tVGHau`Xql_CIW+BhMfFT_h1C6OIrn=0 z6;&Czn75}?%jfLHT$O4g#0H~B>ce4sDXmZs5xD^TGVG-}oI&dsv{$2-)B<$)aYQ}3%ZVnlZD$SN^W>Ch4pALv3cS4iG|xIbTo{X*Q1*~<~(`}!NTnm ze!hSdX5sdUXr_lW><@GM1SYj6?^iFe^VDB1nR)S&?cZvNUCV_XJx)SQIu{(Dm=HeG zZ6Pxg!g4w^$>f5)j2^;c;Ju-Kj9v8B0q!5b`fI^n8LbUC&y#QCVNVXcnmtYTLgP!@ z!djr^>tAagE}fX=z3k^t^!b;d!{pQ%Z|OH#s+v0IE&qngANoc>RHpssh0Ie!`WWzP zVjP(BB`oa~j%VY4L9dyky0n+U79N9Mzap(AQ_8bvsKlO9pTwXpQ*r>r$M!cy%qjH| z0T}hQy?EYdN`2^qu>e!*1M&-&iZIxh3QGNo>1zNzC%AP~KSYx)M%DRmRvay@Zx9Y6hn zQXOi40Om(V>6@nU=nbJ@_j(*cn+Hz77=j20T?;vWyF~Q21b{bjd4i?jh%=?G1`5!_ z0T{0^>%}nvO}=S=qr3P+WlFsfbP3cj7V0`j1{g~5pBtG{m$fo9{vwZ;0v4~1ObmH{4Pc;X!AIC!LOo+3^PIFA;)~x_`upvhCXwStx+YKIW=LR!};V(hlXrksRbtvgN)P7b3NP(lyOq4~yci5^u(BTvw8aCck)bBq38g%dYO2FvZ>nj<pkxQbuWdZIiax(p zUz<{mKpDA7WeT_0`yMi-wvD7PYxn&&KPo;aP)%Qra-zW+}RHRGY&TT z>+&t-8~tO?1jE|Cr>Uk@c%RWdekZKbca z70kIAAdv1gpWJdS-IRK@L@AKK4kdU6D8o?>l*>X1mWGC35*Sz8rCQ6OLkY<7W7#49 zi)PBELSoVIs+zdw6z z#)IF~8$9^Er{P8a3%~S3D*|k)yQjykagPtVhkGK|*9O;@9lL(x?Xj90<2CPI8LPQ- z@!hdo5ioRFhnI5xWbK{@o*DyW>rRZ_x_uQSx7XYnyEb<5_E?S?XlkzMym_mpY0sXP z2n)4Auh))^z4PAK_+~8oHa>pw){UBhi?=Rc z8M{4J(@=A8xbyJoNQV%4Ps8<#SFg3!T)+6{SaZv^j>}hXU2MGiZUnYFK;H6#x`^P~ j6yREw&1P$QhkAMrpRVaXcx-s+;Qr&oJ>4~EX7K+HDeyoT diff --git a/.smallclaw/users/papa/workspace/music/SMOK_WAT/SMOK_WAT.mid b/.smallclaw/users/papa/workspace/music/SMOK_WAT/SMOK_WAT.mid deleted file mode 100644 index c15637793de267600d79679626edc8960396a981..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 56594 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�MtlA+I}@_6{FsMKWWGl@VOT1vP`T>DHt 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 diff --git a/.smallclaw/users/papa/workspace/music/Super Exciting Jazz/Super Exciting Jazz.mid b/.smallclaw/users/papa/workspace/music/Super Exciting Jazz/Super Exciting Jazz.mid deleted file mode 100644 index ba4322cfd99e938fcff6cc4bc7ae4db92e0bfc9a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 518 zcmeYb$w*;fU|?flWME-z@C_--2J)g9{xb^)mlmWJDY#Z7XO?82wG!M2pU5} b@C3so_b7%*c2SKEliYzJZ3o2Y0zj<*A?Ao} diff --git a/.smallclaw/users/papa/workspace/music/smallclaw_morning/smallclaw_morning.mid b/.smallclaw/users/papa/workspace/music/smallclaw_morning/smallclaw_morning.mid deleted file mode 100644 index c999be8cdb6f3275191d3f64dedbfdc88e596cec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 331 zcmeYb$w*;fU|?flWME=G;2Tnu4dih%{10SiU#P(FU!MUY#HhsZpIJ0GH!&y2IVZ7P z!8t#-Aip@XBr`vcVS-IcyF-Ib3c~~k5W@k;a0W4)fea52!vn~$1~IIG3_B3R4#+SE zG0c&rEI}fcKoPisCLj?Lpa@*b6eMDbEMfo>F#w9drQo{YR>0N3rHnwjjF5G~t-#|# VxRfJEyCYD$N6I_~kOzSw1ptVZY#IOn diff --git a/.smallclaw/users/papa/workspace/music/smallclaw_morning_full/smallclaw_morning_full.mid b/.smallclaw/users/papa/workspace/music/smallclaw_morning_full/smallclaw_morning_full.mid deleted file mode 100644 index 4c0f6e7f9237c7241d4506a33dc0ef1f82d44f03..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1091 zcmeH_F^a-q5QQf$Zsiq3g%A`8i9r!jBw~{$V38`7MT!L**%Pc-S((<`coc84``#AM z9|SL;-N*N4GV>3~i?7PeKFzZa@0J(eJ2S6kSF?JtIc|RzsnsbV1*{2cKt{*_bHW_3 zBrE|_!qkmsG&2}|a7;6XQ8b~MxF)8FVHBxLBUGa(VOQdIX~dHa6|LC5LVLD38z1-^ z+5IM6tNjuMJ_?3}As{AjPnix)2PPl{ZY0xV)I-!K^Z_9ubWNM44Wpq0nt^K+Y5LIUqv~v@(f`lz2aG6Q AbN~PV diff --git a/.smallclaw/users/papa/workspace/music/soldieroffortune/soldieroffortune.mid b/.smallclaw/users/papa/workspace/music/soldieroffortune/soldieroffortune.mid deleted file mode 100644 index 5638c6789eded5578a35a722682bff72a5b43674..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32011 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

mYZ>Q_&Q4O$L8 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 HV4?y5V?68M diff --git a/.smallclaw/workspace/memory/2026-06-21.md b/.smallclaw/workspace/memory/2026-06-21.md index 566b78d..2f4eddd 100644 --- a/.smallclaw/workspace/memory/2026-06-21.md +++ b/.smallclaw/workspace/memory/2026-06-21.md @@ -20,3 +20,17 @@ [16:22:07] **SmallClaw**: HEARTBEAT_OK [16:52:07] **User**: Check for anything important and reply HEARTBEAT_OK if nothing needs attention. [16:52:08] **SmallClaw**: HEARTBEAT_OK +[18:26:17] **User**: Check for anything important and reply HEARTBEAT_OK if nothing needs attention. +[18:26:19] **SmallClaw**: HEARTBEAT_OK +[19:42:01] **User**: Check for anything important and reply HEARTBEAT_OK if nothing needs attention. +[19:42:02] **SmallClaw**: HEARTBEAT_OK +[20:12:02] **User**: Check for anything important and reply HEARTBEAT_OK if nothing needs attention. +[20:12:05] **SmallClaw**: HEARTBEAT_OK +[21:22:20] **User**: Check for anything important and reply HEARTBEAT_OK if nothing needs attention. +[21:22:23] **SmallClaw**: HEARTBEAT_OK +[21:52:23] **User**: Check for anything important and reply HEARTBEAT_OK if nothing needs attention. +[21:52:24] **SmallClaw**: HEARTBEAT_OK +[08:14:25] **User**: Check for anything important and reply HEARTBEAT_OK if nothing needs attention. +[08:14:27] **SmallClaw**: HEARTBEAT_OK +[08:44:27] **User**: Check for anything important and reply HEARTBEAT_OK if nothing needs attention. +[08:44:29] **SmallClaw**: HEARTBEAT_OK diff --git a/src/gateway/routes-music.ts b/src/gateway/routes-music.ts index 5f3fa19..1ba4e34 100644 --- a/src/gateway/routes-music.ts +++ b/src/gateway/routes-music.ts @@ -274,4 +274,138 @@ app.delete('/api/music/workspace-file/:dir', (req, res) => { } }); +// POST /api/music/gp — save GP file to workspace/music/gp/ +app.post('/api/music/gp', express.raw({ type: '*/*', limit: '50mb' }), (req: any, res: any) => { + const user = req.user; + if (!user?.workspace) { res.status(403).json({ error: 'No workspace' }); return; } + try { + const buf: Buffer = req.body; + const rawName = decodeURIComponent((req.headers['x-filename'] || 'tab.gp') as string); + const safeName = rawName.replace(/[/\\:*?"<>|]/g, '_').trim() || 'tab.gp'; + const songName = safeName.replace(/\.[^.]+$/, ''); + const gpDir = path.join(user.workspace, 'music', 'gp', songName); + fs.mkdirSync(gpDir, { recursive: true }); + const destPath = path.join(gpDir, safeName); + fs.writeFileSync(destPath, buf); + res.json({ success: true, path: `/api/music/gp-file/${encodeURIComponent(songName)}/${encodeURIComponent(safeName)}` }); + } catch (e: any) { + res.status(500).json({ error: e.message }); + } +}); + +// GET /api/music/gp/list — list GP files from workspace/music/gp/ +app.get('/api/music/gp/list', (req: any, res: any) => { + const user = req.user; + if (!user?.workspace) { res.json({ files: [] }); return; } + const gpDir = path.join(user.workspace, 'music', 'gp'); + try { + if (!fs.existsSync(gpDir)) { res.json({ files: [] }); return; } + const entries: { name: string; file: string; path: string; mtime: number }[] = []; + for (const d of fs.readdirSync(gpDir, { withFileTypes: true })) { + if (!d.isDirectory()) continue; + const sub = path.join(gpDir, d.name); + for (const f of fs.readdirSync(sub).filter(f => /\.(gp[0-9x]?|gpx)$/i.test(f))) { + const fp = path.join(sub, f); + entries.push({ name: d.name, file: f, path: `/api/music/gp-file/${encodeURIComponent(d.name)}/${encodeURIComponent(f)}`, mtime: fs.statSync(fp).mtimeMs }); + } + } + entries.sort((a, b) => b.mtime - a.mtime); + res.json({ files: entries }); + } catch { res.json({ files: [] }); } +}); + +// GET /api/music/gp-file/:dir/:file — serve GP file from workspace +app.get('/api/music/gp-file/:dir/:file', (req: any, res: any) => { + const user = req.user; + if (!user?.workspace) { res.status(403).json({ error: 'No workspace' }); return; } + const filePath = path.join(user.workspace, 'music', 'gp', req.params.dir, req.params.file); + if (!filePath.startsWith(path.join(user.workspace, 'music', 'gp'))) { res.status(403).json({ error: 'Forbidden' }); return; } + if (!fs.existsSync(filePath)) { res.status(404).json({ error: 'Not found' }); return; } + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(req.params.file)}`); + fs.createReadStream(filePath).pipe(res); +}); + +// DELETE /api/music/gp-file/:dir — delete GP folder +app.delete('/api/music/gp-file/:dir', (req: any, res: any) => { + const user = req.user; + if (!user?.workspace) { res.status(403).json({ error: 'No workspace' }); return; } + const dirPath = path.join(user.workspace, 'music', 'gp', req.params.dir); + if (!dirPath.startsWith(path.join(user.workspace, 'music', 'gp') + path.sep)) { res.status(403).json({ error: 'Forbidden' }); return; } + if (!fs.existsSync(dirPath)) { res.status(404).json({ error: 'Not found' }); return; } + fs.rmSync(dirPath, { recursive: true, force: true }); + res.json({ success: true }); +}); + +// ── Drum patterns (save / list / load / delete) — workspace/drums/.json ─ +function drumDir(user: any): string | null { + if (!user?.workspace) return null; + return path.join(user.workspace, 'drums'); +} +function drumSafeName(raw: string): string { + return (raw || '').replace(/[/\\:*?"<>|]/g, '_').trim().slice(0, 60) || '패턴'; +} + +// GET /api/music/drums/list +app.get('/api/music/drums/list', (req: any, res: any) => { + const dir = drumDir(req.user); + if (!dir) { res.json({ files: [] }); return; } + try { + if (!fs.existsSync(dir)) { res.json({ files: [] }); return; } + const files = fs.readdirSync(dir) + .filter(f => f.endsWith('.json')) + .map(f => { + const fp = path.join(dir, f); + const st = fs.statSync(fp); + return { name: f.replace(/\.json$/, ''), mtime: st.mtimeMs }; + }) + .sort((a, b) => b.mtime - a.mtime); + res.json({ files }); + } catch { res.json({ files: [] }); } +}); + +// GET /api/music/drums/:name +app.get('/api/music/drums/:name', (req: any, res: any) => { + const dir = drumDir(req.user); + if (!dir) { res.status(403).json({ error: 'No workspace' }); return; } + const fp = path.join(dir, drumSafeName(req.params.name) + '.json'); + if (!fp.startsWith(dir)) { res.status(403).json({ error: 'Forbidden' }); return; } + if (!fs.existsSync(fp)) { res.status(404).json({ error: 'Not found' }); return; } + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + fs.createReadStream(fp).pipe(res); +}); + +// POST /api/music/drums/save body: { name, steps, bpm, swing, pattern } +app.post('/api/music/drums/save', (req: any, res: any) => { + const dir = drumDir(req.user); + if (!dir) { res.status(403).json({ error: 'No workspace' }); return; } + try { + const body = req.body || {}; + const name = drumSafeName(body.name); + const steps = Math.max(4, Math.min(32, parseInt(body.steps, 10) || 16)); + const bpm = Math.max(40, Math.min(300, parseInt(body.bpm, 10) || 120)); + const swing = Math.max(0, Math.min(70, parseFloat(body.swing) || 0)); + const swingDiv = body.swingDiv === '16' ? '16' : '8'; + const pattern = Array.isArray(body.pattern) ? body.pattern : []; + fs.mkdirSync(dir, { recursive: true }); + const fp = path.join(dir, name + '.json'); + if (!fp.startsWith(dir)) { res.status(403).json({ error: 'Forbidden' }); return; } + fs.writeFileSync(fp, JSON.stringify({ name, steps, bpm, swing, swingDiv, pattern }, null, 2)); + res.json({ success: true, name }); + } catch (e: any) { + res.status(500).json({ error: e.message }); + } +}); + +// DELETE /api/music/drums/:name +app.delete('/api/music/drums/:name', (req: any, res: any) => { + const dir = drumDir(req.user); + if (!dir) { res.status(403).json({ error: 'No workspace' }); return; } + const fp = path.join(dir, drumSafeName(req.params.name) + '.json'); + if (!fp.startsWith(dir)) { res.status(403).json({ error: 'Forbidden' }); return; } + if (!fs.existsSync(fp)) { res.status(404).json({ error: 'Not found' }); return; } + fs.rmSync(fp, { force: true }); + res.json({ success: true }); +}); + } // registerMusicRoutes diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index 413e9bc..8d69e5c 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -10584,6 +10584,29 @@ app.get('/api/bg-tasks', (_req, res) => { res.json({ success: true, tasks, heartbeatConfig }); }); +app.post('/api/bg-tasks', async (req: any, res: any) => { + const user = getSessionUser(req); + const { title, prompt } = req.body || {}; + if (!title || !prompt) { res.status(400).json({ success: false, error: 'title과 prompt가 필요합니다' }); return; } + try { + const ownerUsername = user?.username; + const sessionId = `bgt_manual_${Date.now()}`; + if (ownerUsername) { + try { ensureUserWorkspace(ownerUsername); } catch { /* non-fatal */ } + getSession(sessionId, ownerUsername); + } + const plan = [{ index: 0, id: 'step_1', title: '작업 실행', status: 'pending' as const, description: prompt }]; + const task = createTask({ title, prompt, sessionId, channel: 'web', plan, ownerUsername }); + appendJournal(task.id, { type: 'status_push', content: `수동 태스크 "${title}" 시작` }); + const runner = new BackgroundTaskRunner(task.id, handleChat, makeBroadcastForTask(task.id), telegramChannel); + runner.start().catch((err: any) => console.error(`[BgTask] Manual task ${task.id} error:`, err.message)); + broadcastWS({ type: 'bg_task_created', taskId: task.id, title }); + res.json({ success: true, taskId: task.id }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message }); + } +}); + app.get('/api/bg-tasks/:id', (req, res) => { const task = loadTask(req.params.id); if (!task) { res.status(404).json({ success: false, error: 'Task not found' }); return; } @@ -12521,6 +12544,250 @@ app.post('/api/models/test', async (req, res) => { }); registerMusicRoutes(app); + +// ── Guitar Pro tab search/download (guitarprotabs.org proxy) ───────────────── +function gpNormalizeArtist(name: string): { letter: string; slug: string } { + let n = name.trim().toLowerCase(); + // "The Eagles" → "eagles_(the)" + if (n.startsWith('the ')) { n = n.slice(4) + '_(the)'; } + const slug = n.replace(/[&]/g, 'and').replace(/[^a-z0-9_()]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, ''); + const letter = slug[0] || 'a'; + return { letter, slug }; +} + +async function gpFetch(url: string, referer?: string): Promise<{ ok: boolean; status: number; text?: string; buffer?: Buffer; contentType?: string }> { + return new Promise((resolve) => { + const https = require('https'); + const urlObj = new URL(url); + const opts = { + hostname: urlObj.hostname, + path: urlObj.pathname + urlObj.search, + method: 'GET', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Accept': '*/*', + ...(referer ? { 'Referer': referer } : {}) + } + }; + const req = https.request(opts, (res: any) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + const buf = Buffer.concat(chunks); + const ct = res.headers['content-type'] || ''; + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + const next = new URL(res.headers.location, url).href; + gpFetch(next, referer).then(resolve); + } else { + resolve({ ok: res.statusCode < 400, status: res.statusCode, buffer: buf, contentType: ct, text: ct.includes('text') ? buf.toString('utf8') : undefined }); + } + }); + }); + req.on('error', () => resolve({ ok: false, status: 0 })); + req.end(); + }); +} + +async function gpSearchGptabs(q: string): Promise<{ results: any[]; artistUrl: string; error?: string }> { + const searchR = await gpFetch(`https://guitarprotabs.org/?s=${encodeURIComponent(q)}`); + let baseUrl = ''; + if (searchR.ok && searchR.text) { + const qLower = q.toLowerCase().replace(/\s+/g, ''); + const re = /href="(https:\/\/guitarprotabs\.org\/[a-z]\/([^/]+)\/\d+\/)"/g; + let am; + while ((am = re.exec(searchR.text)) !== null) { + const slug = am[2].replace(/_/g, '').replace(/[^a-z0-9]/gi, '').toLowerCase(); + if (slug.includes(qLower) || qLower.includes(slug)) { baseUrl = am[1]; break; } + } + } + if (!baseUrl) { + const { letter, slug } = gpNormalizeArtist(q); + baseUrl = `https://guitarprotabs.org/${letter}/${slug}/1/`; + } + // Strip trailing page number to rebuild per-page URL + const urlBase = baseUrl.replace(/\/\d+\/$/, '/'); + const artistUrl = urlBase + '1/'; + + const results: any[] = []; + const rowRe = /]*itemprop[^>]*>([\s\S]*?)<\/tr>/g; + const MAX_PAGES = 10; + + for (let page = 1; page <= MAX_PAGES; page++) { + const r = await gpFetch(urlBase + page + '/'); + if (!r.ok || !r.text) break; + let m; + let pageCount = 0; + rowRe.lastIndex = 0; + while ((m = rowRe.exec(r.text)) !== null) { + const row = m[1]; + const href = row.match(/href="(https:\/\/guitarprotabs\.org\/[^"]+)"/); + const nameM = row.match(/itemprop="name">([^<]+)/); + const extM = row.match(/<\/td>\s*(\.[a-z0-9]+)<\/td>/i); + const dlM = row.match(/badge">([^<]+) { + const searchUrl = `https://www.gprotab.net/en/search/?q=${encodeURIComponent(q)}`; + const r = await gpFetch(searchUrl); + if (!r.ok || !r.text) return { results: [], searchUrl, error: '검색 실패' }; + // Match each tab link directly — more robust than block regex + const hrefRe = /href="(\/en\/tabs\/[^"]+)" class="tab-name"[^>]*>([^<]+)<\/a>/g; + const bandRe = /class="tab-band"[^>]*>([^<]+)<\/a>/g; + const results: any[] = []; + // Collect bands first (parallel structure to hrefs) + const bands: string[] = []; + let bm; + while ((bm = bandRe.exec(r.text)) !== null) bands.push(bm[1].trim()); + let hm; + let i = 0; + while ((hm = hrefRe.exec(r.text)) !== null) { + const band = bands[i] || ''; + const name = hm[2].trim(); + results.push({ + title: band ? `${band} - ${name}` : name, + url: 'https://www.gprotab.net' + hm[1], + ext: '', downloads: '', site: 'gprotab' + }); + i++; + } + return { results, searchUrl }; +} + +app.get('/api/gp-search', async (req: any, res: any) => { + const q = (req.query.q || req.query.artist || '').toString().trim(); + const site = (req.query.site || 'guitarprotabs').toString(); + if (!q) return res.status(400).json({ error: '검색어가 필요합니다' }); + try { + if (site === 'gprotab') { + const r = await gpSearchGprotab(q); + res.json(r); + } else { + const r = await gpSearchGptabs(q); + res.json(r); + } + } catch (e: any) { + res.status(500).json({ error: e.message }); + } +}); + +app.get('/api/gp-download', async (req: any, res: any) => { + const tabUrl = (req.query.url || '').toString(); + const isGptabs = tabUrl.startsWith('https://guitarprotabs.org/'); + const isGprotab = tabUrl.startsWith('https://www.gprotab.net/') || tabUrl.startsWith('https://gprotab.net/'); + if (!isGptabs && !isGprotab) return res.status(400).json({ error: '잘못된 URL' }); + + let dlUrl = tabUrl; + let referer = tabUrl; + if (isGptabs) { + dlUrl = tabUrl.endsWith('/') ? tabUrl + 'download/' : tabUrl + '/download/'; + } else { + dlUrl = tabUrl.includes('?') ? tabUrl + '&download' : tabUrl + '?download'; + } + + try { + const r = await gpFetch(dlUrl, referer); + if (!r.ok || !r.buffer) return res.status(502).json({ error: '다운로드 실패' }); + const isGp = r.buffer[1] === 0x46 && r.buffer[2] === 0x49; // .FICH magic + if (!isGp) return res.status(502).json({ error: '유효하지 않은 GP 파일' }); + const titleM = isGptabs + ? tabUrl.match(/\/([^/]+)_\d+\/?$/) + : tabUrl.match(/\/([^/]+)\/?$/); + const filename = (titleM ? titleM[1].replace(/[-_]/g, ' ') : 'tab') + '.gp'; + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(r.buffer); + } catch (e: any) { + res.status(500).json({ error: e.message }); + } +}); + +// ── MIDI download site search/download (midifind.com, bitmidi.com proxy) ────── +async function midiSearchMidifind(q: string): Promise<{ results: any[]; error?: string }> { + const r = await gpFetch(`https://midifind.com/search/?q=${encodeURIComponent(q)}&t=0`); + if (!r.ok || !r.text) return { results: [], error: '검색 실패' }; + const results: any[] = []; + // …title… + const re = /]*class="item"[^>]*>([\s\S]*?)<\/a>/g; + let m; + while ((m = re.exec(r.text)) !== null) { + const title = m[3].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + if (title) results.push({ title, url: 'https://midifind.com' + m[1], site: 'midifind' }); + } + return { results }; +} + +async function midiSearchBitmidi(q: string): Promise<{ results: any[]; error?: string }> { + const r = await gpFetch(`https://bitmidi.com/search?q=${encodeURIComponent(q)}`); + if (!r.ok || !r.text) return { results: [], error: '검색 실패' }; + const results: any[] = []; + // Result anchors: (attribute order varies) + const re = /]*)>/g; + let m; + while ((m = re.exec(r.text)) !== null) { + const attrs = m[1]; + const hrefM = attrs.match(/\bhref="(\/[^"]+-mid)"/); + const titleM = attrs.match(/\btitle="([^"]+\.mid)"/i); + if (hrefM && titleM) results.push({ title: titleM[1], url: 'https://bitmidi.com' + hrefM[1], site: 'bitmidi' }); + } + return { results }; +} + +app.get('/api/midi-search', async (req: any, res: any) => { + const q = (req.query.q || '').toString().trim(); + const site = (req.query.site || 'midifind').toString(); + if (!q) return res.status(400).json({ error: '검색어가 필요합니다' }); + try { + const r = site === 'bitmidi' ? await midiSearchBitmidi(q) : await midiSearchMidifind(q); + res.json(r); + } catch (e: any) { + res.status(500).json({ error: e.message }); + } +}); + +app.get('/api/midi-download', async (req: any, res: any) => { + const url = (req.query.url || '').toString(); + const site = (req.query.site || 'midifind').toString(); + let dlUrl = ''; + let referer = url; + if (site === 'midifind') { + const idM = url.match(/(\d+)$/); + if (!idM) return res.status(400).json({ error: '잘못된 URL' }); + dlUrl = `https://midifind.com/files/0-0-1-${idM[1]}-20`; + } else if (site === 'bitmidi') { + // detail page → find /uploads/{id}.mid + const page = await gpFetch(url); + if (!page.ok || !page.text) return res.status(502).json({ error: '다운로드 실패' }); + const upM = page.text.match(/href="(\/uploads\/\d+\.mid)"/); + if (!upM) return res.status(502).json({ error: '다운로드 링크 없음' }); + dlUrl = 'https://bitmidi.com' + upM[1]; + } else { + return res.status(400).json({ error: '잘못된 사이트' }); + } + try { + const r = await gpFetch(dlUrl, referer); + if (!r.ok || !r.buffer) return res.status(502).json({ error: '다운로드 실패' }); + const isMidi = r.buffer.length > 4 && r.buffer[0] === 0x4D && r.buffer[1] === 0x54 + && r.buffer[2] === 0x68 && r.buffer[3] === 0x64; // "MThd" + if (!isMidi) return res.status(502).json({ error: '유효하지 않은 MIDI 파일' }); + const fname = (req.query.name || 'download.mid').toString().replace(/[/\\:*?"<>|]/g, '_'); + res.setHeader('Content-Type', 'audio/midi'); + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fname)}`); + res.send(r.buffer); + } catch (e: any) { + res.status(500).json({ error: e.message }); + } +}); + registerLanguageRoutes(app); registerDentalRoutes(app); diff --git a/web-ui/app.js b/web-ui/app.js index c5a2928..f808675 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -525,24 +525,18 @@ function markSessionUnread(sessionId) { // ---- Mode switching ---- function setMode(mode) { - if (!['chat','bgtasks','schedule'].includes(mode)) mode = 'chat'; + if (!['chat','bgtasks'].includes(mode)) mode = 'chat'; currentMode = mode; - document.getElementById('btn-chat').classList.toggle('active', mode === 'chat'); - const bgtBtn = document.getElementById('btn-bgtasks'); - if (bgtBtn) bgtBtn.classList.toggle('active', mode === 'bgtasks'); - const schedBtn = document.getElementById('btn-schedule'); - if (schedBtn) schedBtn.classList.toggle('active', mode === 'schedule'); document.getElementById('chat-view').style.display = mode === 'chat' ? 'flex' : 'none'; const bgtView = document.getElementById('bgtasks-view'); if (bgtView) bgtView.style.display = mode === 'bgtasks' ? 'flex' : 'none'; - const schedView = document.getElementById('schedule-view'); - if (schedView) schedView.style.display = mode === 'schedule' ? 'flex' : 'none'; const aside = document.querySelector('aside'); const rightPanel = document.getElementById('right-panel'); if (aside) aside.style.display = ''; if (rightPanel) rightPanel.style.display = mode === 'chat' ? '' : 'none'; + // sidebar tab 하이라이트 동기화 + document.getElementById('tab-bgtasks')?.classList.toggle('active', mode === 'bgtasks'); if (mode === 'bgtasks') refreshBgTasks(); - if (mode === 'schedule') refreshSchedules(); } // --- Heartbeat / Tasks System ----------------------------------------------- @@ -739,12 +733,20 @@ function setSidebarTab(tab) { sidebarTab = tab; const toggle = (id, active) => document.getElementById(id)?.classList.toggle('active', active); toggle('tab-jobs', tab === 'jobs'); + toggle('tab-bgtasks', tab === 'bgtasks'); toggle('tab-skills', tab === 'skills'); toggle('tab-pptx', tab === 'pptx'); - document.getElementById('sidebar-jobs').style.display = tab === 'jobs' ? 'flex' : 'none'; + toggle('tab-schedule', tab === 'schedule'); + document.getElementById('sidebar-jobs').style.display = (tab === 'jobs' || tab === 'bgtasks') ? 'flex' : 'none'; document.getElementById('sidebar-skills').style.display = tab === 'skills' ? 'flex' : 'none'; document.getElementById('sidebar-pptx').style.display = tab === 'pptx' ? 'flex' : 'none'; + const schedEl = document.getElementById('sidebar-schedule'); + if (schedEl) schedEl.style.display = tab === 'schedule' ? 'flex' : 'none'; + // 메인 뷰 전환 + if (tab === 'bgtasks') { setMode('bgtasks'); } + else if (currentMode === 'bgtasks') { setMode('chat'); } if (tab === 'skills') { initSkillsUserSelector(); loadInstalledSkills(); } + if (tab === 'schedule') refreshSchedules(); } // ---- Agent mode toggle ---- @@ -6220,7 +6222,7 @@ function buildMusicActions(data, downloadName) { + '' + ''; html += ''; - html += ''; + html += ''; html += '⬇ MIDI'; } if (pdfUrl) html += '⬇ PDF'; @@ -6341,7 +6343,7 @@ function buildMidiPickerWidget(data, filename) { savedLine + '

' + '' + - '' + + '' + '' + '
' + playerPanel + @@ -7651,6 +7653,7 @@ setInterval(checkStatus, 10000); setInterval(loadSystemStats, 3000); setInterval(loadApprovals, 5000); setMode('chat'); // Start in chat mode +setSidebarTab('jobs'); // --------------------------------------------------------------------------- // BACKGROUND TASKS KANBAN SYSTEM @@ -7660,6 +7663,37 @@ let bgtTasks = []; // all task records from server let bgtOpenTaskId = null; // currently open panel task id let bgtHeartbeatConfig = { enabled: true, interval_minutes: 10 }; +function openNewBgTaskModal() { + const modal = document.getElementById('bgt-new-task-modal'); + if (modal) { modal.style.display = 'flex'; document.getElementById('bgt-new-title').focus(); } +} +function closeNewBgTaskModal() { + const modal = document.getElementById('bgt-new-task-modal'); + if (modal) modal.style.display = 'none'; + document.getElementById('bgt-new-title').value = ''; + document.getElementById('bgt-new-prompt').value = ''; +} +async function submitNewBgTask() { + const title = document.getElementById('bgt-new-title').value.trim(); + const prompt = document.getElementById('bgt-new-prompt').value.trim(); + if (!title || !prompt) { alert('제목과 작업 내용을 입력해주세요.'); return; } + const btn = document.getElementById('bgt-new-submit-btn'); + btn.textContent = '시작 중...'; btn.disabled = true; + try { + const data = await api('/api/bg-tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, prompt }) }); + if (data.success) { + closeNewBgTaskModal(); + await refreshBgTasks(); + } else { + alert('태스크 생성 실패: ' + (data.error || 'unknown')); + } + } catch (err) { + alert('오류: ' + err.message); + } finally { + btn.textContent = '▶ 시작'; btn.disabled = false; + } +} + function openBgtHeartbeatModal() { const modal = document.getElementById('bgt-heartbeat-modal'); const enabledEl = document.getElementById('bgt-hb-enabled'); diff --git a/web-ui/index.html b/web-ui/index.html index 1db4459..f130683 100644 --- a/web-ui/index.html +++ b/web-ui/index.html @@ -53,14 +53,7 @@ 🍒 CherryClaw -
- - - -
-
- @@ -115,6 +108,9 @@ @@ -154,16 +150,6 @@
--%
-
- ↓ -
- 0 B/s -
-
- ↑ -
- 0 B/s -
@@ -187,6 +173,23 @@ + + + @@ -423,6 +426,7 @@ style="border:1px solid var(--line);background:var(--panel);color:var(--muted);border-radius:8px;padding:5px 10px;font-size:11px;font-weight:600;cursor:pointer" title="작업 하트비트 설정" >? heartbeat: 10min + @@ -455,25 +459,6 @@ - - - + + + `).join('')} + `; + }).join('')} - +
MIDI 편집기 + +
+
+
+ +
+ + +
+ + + +
+ 양자화 + +
+ BPM + +
+ + + + +
+ +
+ MIDI 출력 + + +
+ +
+ +
+
+
트랙
+
+
+
+
+
bar
+ +
+
+ + +
+
+
vel
+ +
+
+
+
+ + + + 1:1:000 + 루프 + + In + Out + + +
+
+ +
+ +
+
+ + Guitar Pro + + +
+
+ 📤 MIDI 출력 + + ♩= + + BPM + 🔊 마스터 + + 100% + +
+ +
+
+
Guitar Pro 파일을 로딩 중...
+
+
+
+ + +
+
+ + 🥁 드럼 박스 + 샘플 로딩 전 + + + ♩= + + BPM + 🔊 + +
+ + + +
+
+ 리듬 + + 스윙 + + 0% (8분) +
+ 저장된 패턴 + + + + +
+
+ +
+
+ 🎤 루퍼 + + + + + + 길이 + +
+
+ 레이어 0 · 대기 +
+
+
● 녹음을 누르면 드럼 비트에 맞춰 녹음 시작 (1마디 카운트인 후 녹음). 재생 중 누르면 오버덥(겹쳐 녹음).
+
-
@@ -240,7 +589,7 @@ function toggleTheme() { const d=document.documentElement; const n=d.getAttribute('data-theme')==='dark'?'light':'dark'; d.setAttribute('data-theme',n); - try{localStorage.setItem('cherryclaw_theme',n);}catch{} + try{localStorage.setItem('cherryclaw_music_theme',n);}catch{} } // ── Resize handle ───────────────────────────────────────────────────────────── @@ -312,6 +661,7 @@ function saveModel() { const SID_KEY = 'music_app_session'; let skillContext = ''; let abortCtrl = null; +const _renderedMidiIds = new Set(); function getSessionId() { try { @@ -345,16 +695,107 @@ function clearStage() { } function openEditor(midiUrl, filename) { + // Stop any active playback + if (typeof playing !== 'undefined' && playing) doStop(); + if (_midiPlaying) { _midiPlaying = false; try { _spessaSeq?.pause(); } catch {} } + + // Hide other stages document.getElementById('stage-player').style.display = 'none'; + document.getElementById('stage-gp').style.display = 'none'; + const ed = document.getElementById('stage-editor'); ed.classList.add('visible'); document.getElementById('editor-title').textContent = '🎹 ' + (filename || 'MIDI 편집기'); - document.getElementById('editor-frame').src = '/midi-editor.html?url=' + encodeURIComponent(midiUrl); + + // Resize canvases after layout (they were hidden before) + requestAnimationFrame(() => { resize(); pushH(); updPos(); }); + + if (midiUrl) meLoadUrl(midiUrl); } function closeEditor() { + if (typeof playing !== 'undefined' && playing) doStop(); document.getElementById('stage-editor').classList.remove('visible'); - document.getElementById('editor-frame').src = ''; + const sp = document.getElementById('stage-player'); + sp.style.display = 'flex'; sp.style.flexDirection = 'column'; + sp.style.flex = '1'; sp.style.minHeight = '0'; sp.style.overflow = 'hidden'; +} + +// ── SpessaSynth SF2 MIDI 플레이어 ──────────────────────────────────────────── +let _spessaCtx = null, _spessaSynth = null, _spessaSeq = null, _spessaSfBuf = null; +let _spessaLoading = false; + +function midiSfGpStatus(msg) { + const el = document.getElementById('gp-loading'); + if (el) { el.style.display = 'block'; el.textContent = msg; } +} + +function _setMidiBtn(playing) { + const btn = document.getElementById('gp-play-btn'); + const loading = document.getElementById('gp-loading'); + if (playing) { + if (btn) btn.textContent = '⏸ 일시정지'; + if (loading) loading.style.display = 'none'; + } else { + if (btn) btn.textContent = '▶ 재생'; + } +} + +async function _ensureSpessa() { + if (_spessaSynth) return; + if (_spessaLoading) { await new Promise(r => setTimeout(r, 200)); return _ensureSpessa(); } + _spessaLoading = true; + try { + if (!window.SpessaSynth) { + await new Promise((res, rej) => { + const s = document.createElement('script'); + s.src = '/spessasynth.js'; + s.onload = res; s.onerror = () => rej(new Error('SpessaSynth 로드 실패')); + document.head.appendChild(s); + }); + } + const AC = window.AudioContext || window.webkitAudioContext; + if (!_spessaCtx) _spessaCtx = new AC({ latencyHint: 'playback' }); + if (_spessaCtx.state === 'suspended') await _spessaCtx.resume(); + + if (!_spessaSfBuf) { + midiSfGpStatus('사운드폰트 로딩 중...'); + const r = await fetch('/GeneralUser-GS.sf2'); + if (!r.ok) throw new Error('SF2 로드 실패: ' + r.status); + _spessaSfBuf = await r.arrayBuffer(); + } + + await _spessaCtx.audioWorklet.addModule('/spessasynth_processor.min.js'); + const { WorkletSynthesizer, Sequencer } = window.SpessaSynth; + _spessaSynth = new WorkletSynthesizer(_spessaCtx); + _spessaSynth.connect(_spessaCtx.destination); // AudioWorkletNode → output (the global connect-patch redirects this through the master FX chain) + await _spessaSynth.soundBankManager.addSoundBank(_spessaSfBuf.slice(0), 'gs'); + await _spessaSynth.isReady; + _spessaSeq = new Sequencer(_spessaSynth); + } finally { + _spessaLoading = false; + } +} + +// ── Guitar Pro (AlphaTab) ───────────────────────────────────────────────────── +let gpApi = null, gpStageMode = 'gp'; +const gpFiles = []; + +function showStageGp() { + document.getElementById('stage-player').style.display = 'none'; + document.getElementById('stage-editor').classList.remove('visible'); + document.getElementById('stage-gp').style.display = 'flex'; + document.getElementById('stage-gp').style.flexDirection = 'column'; + document.getElementById('stage-gp').style.flex = '1'; + // NOTE: MIDI access is NOT auto-initialized here. requestMIDIAccess() can + // crash the renderer via the OmniMIDI driver. It is only triggered when the + // user explicitly focuses the MIDI output dropdown (opt-in). +} + +function closeGpViewer() { + if (gpStageMode === 'midi' && _spessaSeq) { try{_spessaSeq.pause();}catch{} _midiPlaying = false; } + gpStageMode = 'gp'; + document.getElementById('stage-gp').style.display = 'none'; document.getElementById('stage-player').style.display = 'flex'; document.getElementById('stage-player').style.flexDirection = 'column'; document.getElementById('stage-player').style.flex = '1'; @@ -362,7 +803,1318 @@ function closeEditor() { document.getElementById('stage-player').style.overflow = 'hidden'; } -// ── MIDI file list ──────────────────────────────────────────────────────────── +function openGpFile(file) { + if (!file) return; + if (!window.alphaTab) { addChatMsg('system', '⚠️ AlphaTab 라이브러리 로딩 중... 잠시 후 다시 시도하세요.'); return; } + const reader = new FileReader(); + reader.onload = (e) => { + const name = file.name; + if (!gpFiles.find(f => f.name === name)) gpFiles.push({ name, buf: e.target.result }); + loadGpBuffer(e.target.result, name); + saveGpToServer(e.target.result.slice(0), name); + }; + reader.readAsArrayBuffer(file); +} + +async function saveGpToServer(buf, name) { + try { + await fetch('/api/music/gp', { + method: 'POST', + headers: { ...authH(), 'x-filename': encodeURIComponent(name), 'Content-Type': 'application/octet-stream' }, + body: buf + }); + loadGpFileList(); + } catch(e) { console.warn('GP 저장 실패:', e.message); } +} + +async function loadGpFileList() { + const el = document.getElementById('gp-file-list'); + try { + const r = await fetch('/api/music/gp/list', { headers: authH() }); + const data = await r.json(); + if (!data.files?.length) { el.innerHTML = '
저장된 파일 없음
'; return; } + el.innerHTML = data.files.map(f => ` +
+
+ 🎸 ${escHtml(f.name)} +
+ × +
`).join(''); + } catch { el.innerHTML = '
로딩 실패
'; } +} + +async function loadGpFromServer(path, name) { + try { + const r = await fetch(path, { headers: authH() }); + const buf = await r.arrayBuffer(); + if (!gpFiles.find(f => f.name === name)) gpFiles.push({ name, buf }); + loadGpBuffer(buf, name); + } catch(e) { addChatMsg('system', '❌ GP 로드 실패: ' + escHtml(e.message)); } +} + +async function deleteGpFile(name) { + if (!confirm(`"${name}" 삭제할까요?`)) return; + await fetch(`/api/music/gp-file/${encodeURIComponent(name)}`, { method: 'DELETE', headers: authH() }); + loadGpFileList(); +} + +function renderGpFileList() { + loadGpFileList(); +} + +let gpSite = 'guitarprotabs'; +function gpSetSite(site) { + gpSite = site; + document.getElementById('gp-site-gptabs').style.background = site === 'guitarprotabs' ? '#a855f7' : 'var(--panel)'; + document.getElementById('gp-site-gptabs').style.color = site === 'guitarprotabs' ? '#fff' : 'var(--muted)'; + document.getElementById('gp-site-gptabs').style.borderColor = site === 'guitarprotabs' ? '#a855f7' : 'var(--line)'; + document.getElementById('gp-site-gprotab').style.background = site === 'gprotab' ? '#a855f7' : 'var(--panel)'; + document.getElementById('gp-site-gprotab').style.color = site === 'gprotab' ? '#fff' : 'var(--muted)'; + document.getElementById('gp-site-gprotab').style.borderColor = site === 'gprotab' ? '#a855f7' : 'var(--line)'; + document.getElementById('gp-search-input').placeholder = site === 'gprotab' ? '곡명/아티스트 검색...' : '아티스트 검색...'; + document.getElementById('gp-search-results').innerHTML = '
아티스트 또는 곡명으로 검색
'; +} + +async function gpSearch() { + const q = document.getElementById('gp-search-input').value.trim(); + if (!q) return; + const el = document.getElementById('gp-search-results'); + el.innerHTML = '
검색 중...
'; + try { + const r = await fetch(`/api/gp-search?q=${encodeURIComponent(q)}&site=${gpSite}`, { headers: authH() }); + const data = await r.json(); + if (data.error && !data.results?.length) { + el.innerHTML = `
아티스트를 찾을 수 없습니다
`; return; + } + if (!data.results?.length) { + el.innerHTML = '
결과 없음
'; return; + } + el.innerHTML = data.results.map(r => ` +
+
🎸 ${escHtml(r.title)}
+
${escHtml(r.ext)} · ↓${escHtml(r.downloads)}
+
`).join(''); + } catch(e) { + el.innerHTML = `
오류: ${escHtml(e.message)}
`; + } +} + +async function gpDownloadAndOpen(tabUrl, title) { + const el = document.getElementById('gp-search-results'); + // highlight selected + el.querySelectorAll('.ms-file-item').forEach(d => d.style.background = ''); + event?.currentTarget?.style && (event.currentTarget.style.background = 'var(--brand-dim, rgba(168,85,247,.15))'); + addChatMsg('system', `🎸 ${escHtml(title)} 다운로드 중...`); + try { + const r = await fetch(`/api/gp-download?url=${encodeURIComponent(tabUrl)}`, { headers: authH() }); + if (!r.ok) throw new Error(await r.text()); + const buf = await r.arrayBuffer(); + const name = title + '.gp'; + if (!gpFiles.find(f => f.name === name)) gpFiles.push({ name, buf }); + loadGpBuffer(buf, name); + saveGpToServer(buf.slice(0), name); + addChatMsg('system', `✅ ${escHtml(title)} 열림 · 저장됨`); + } catch(e) { + addChatMsg('system', `❌ 다운로드 실패: ${escHtml(e.message)}`); + } +} + +// ── MIDI download site (midifind / bitmidi) ────────────────────────────────── +let mdlSite = 'midifind'; +function mdlSetSite(site) { + mdlSite = site; + const a = document.getElementById('mdl-site-midifind'); + const b = document.getElementById('mdl-site-bitmidi'); + const apply = (el, on) => { + el.style.background = on ? '#a855f7' : 'var(--panel)'; + el.style.color = on ? '#fff' : 'var(--muted)'; + el.style.borderColor = on ? '#a855f7' : 'var(--line)'; + }; + apply(a, site === 'midifind'); + apply(b, site === 'bitmidi'); + document.getElementById('mdl-search-input').placeholder = site === 'bitmidi' ? '곡명 검색...' : '곡명/아티스트 검색...'; + document.getElementById('mdl-search-results').innerHTML = '
곡명 또는 아티스트로 검색
'; +} + +async function mdlSearch() { + const q = document.getElementById('mdl-search-input').value.trim(); + if (!q) return; + const el = document.getElementById('mdl-search-results'); + el.innerHTML = '
검색 중...
'; + try { + const r = await fetch(`/api/midi-search?q=${encodeURIComponent(q)}&site=${mdlSite}`, { headers: authH() }); + const data = await r.json(); + if (data.error && !data.results?.length) { + el.innerHTML = `
${escHtml(data.error)}
`; return; + } + if (!data.results?.length) { + el.innerHTML = '
결과 없음
'; return; + } + el.innerHTML = data.results.map(r => ` +
+
🎵 ${escHtml(r.title)}
+
`).join(''); + } catch(e) { + el.innerHTML = `
오류: ${escHtml(e.message)}
`; + } +} + +async function mdlDownload(siteUrl, title) { + const el = document.getElementById('mdl-search-results'); + el.querySelectorAll('.ms-file-item').forEach(d => d.style.background = ''); + event?.currentTarget?.style && (event.currentTarget.style.background = 'var(--brand-dim, rgba(168,85,247,.15))'); + const safeName = title.replace(/[/\\:*?"<>|]/g, '_').slice(0, 80); + addChatMsg('system', `🎵 ${escHtml(title)} 다운로드 중...`); + try { + const r = await fetch(`/api/midi-download?url=${encodeURIComponent(siteUrl)}&site=${mdlSite}&name=${encodeURIComponent(safeName + '.mid')}`, { headers: authH() }); + if (!r.ok) throw new Error(await r.text()); + const blob = await r.blob(); + const filename = safeName + '.mid'; + const file = new File([blob], filename, { type: 'audio/midi' }); + const form = new FormData(); form.append('file', file); + const up = await fetch('/api/music/midi', { method:'POST', headers: authH(), body: form }); + const data = await up.json(); + if (!up.ok) throw new Error(data.error || '등록 실패'); + const { html } = buildMidiPickerWidget(data, filename); + addToStage(html, filename); + addChatMsg('system', `✅ ${escHtml(title)} 다운로드 완료 · 스테이지에 추가`); + loadMidiFiles(); + } catch(e) { + addChatMsg('system', `❌ 다운로드 실패: ${escHtml(e.message)}`); + } +} + +// ── GP 오디오 효과 체인 ────────────────────────────────────── +let gpFx = null; +let gpMidiOut = null; + +function patchAudioForGpFx() { + // Use large playback buffer to reduce audio underruns + const _OrigAC = window.AudioContext || window.webkitAudioContext; + if (!_OrigAC._patched) { + // Patch AudioNode.connect once (global) so any connect(ctx.destination) + // is redirected into our master FX chain input — applies reverb/chorus/ + // delay/compression to every audio source (AlphaTab SF2, drums, looper). + if (!_origConnect) { + _origConnect = AudioNode.prototype.connect; + AudioNode.prototype.connect = function(dest, ...rest) { + const r = _destRedirect.get(dest); + if (r) return _origConnect.call(this, r, ...rest); + return _origConnect.call(this, dest, ...rest); + }; + } + window.AudioContext = window.webkitAudioContext = function(opts) { + const ctx = new _OrigAC({ latencyHint: 'playback', ...opts }); + try { buildAudioFxChain(ctx); } catch(e) { console.warn('fx chain', e); } + return ctx; + }; + window.AudioContext.prototype = _OrigAC.prototype; + window.AudioContext._patched = true; + } + gpFx = {}; +} + +// ── Global master FX chain (reverb · chorus · delay · compression) ─────────── +let gpFxChains = []; +const _destRedirect = new WeakMap(); +let _origConnect = null; +let gpFxCtrl = { + reverb: { on: false, mix: 30 }, + chorus: { on: false, mix: 30 }, + delay: { on: false, mix: 25, time: 300 }, + comp: { on: false, amt: 0 }, +}; + +function makeImpulse(ctx, dur, decay) { + const rate = ctx.sampleRate, len = Math.max(1, Math.floor(rate * dur)); + const buf = ctx.createBuffer(2, len, rate); + for (let c = 0; c < 2; c++) { + const d = buf.getChannelData(c); + for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay); + } + return buf; +} + +function buildAudioFxChain(ctx) { + const input = ctx.createGain(); input.gain.value = 1; + const dry = ctx.createGain(); dry.gain.value = 1; + const mixer = ctx.createGain(); mixer.gain.value = 1; + + // Reverb — convolver with synthetic impulse response + const revSend = ctx.createGain(); revSend.gain.value = 0; + const conv = ctx.createConvolver(); conv.buffer = makeImpulse(ctx, 2.2, 2.5); + const revWet = ctx.createGain(); revWet.gain.value = 1; + input.connect(revSend); revSend.connect(conv); conv.connect(revWet); revWet.connect(mixer); + + // Chorus — short modulated delay (LFO on delayTime) + const choSend = ctx.createGain(); choSend.gain.value = 0; + const choDelay = ctx.createDelay(0.1); choDelay.delayTime.value = 0.025; + const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 1.2; + const lfoGain = ctx.createGain(); lfoGain.gain.value = 0.003; + lfo.connect(lfoGain); lfoGain.connect(choDelay.delayTime); lfo.start(); + const choWet = ctx.createGain(); choWet.gain.value = 1; + input.connect(choSend); choSend.connect(choDelay); choDelay.connect(choWet); choWet.connect(mixer); + + // Delay — feedback echo + const dlySend = ctx.createGain(); dlySend.gain.value = 0; + const dlyDelay = ctx.createDelay(2.0); dlyDelay.delayTime.value = 0.3; + const fb = ctx.createGain(); fb.gain.value = 0; + const dlyWet = ctx.createGain(); dlyWet.gain.value = 1; + input.connect(dlySend); dlySend.connect(dlyDelay); + dlyDelay.connect(fb); fb.connect(dlyDelay); + dlyDelay.connect(dlyWet); dlyWet.connect(mixer); + + // Dry path + input.connect(dry); dry.connect(mixer); + + // Compressor — master bus, transparent when off (ratio 1, threshold 0) + const comp = ctx.createDynamicsCompressor(); + comp.threshold.value = 0; comp.ratio.value = 1; comp.knee.value = 6; + comp.attack.value = 0.003; comp.release.value = 0.25; + mixer.connect(comp); + _origConnect.call(comp, ctx.destination); // bypass redirect → real output + + // NOW redirect: every future connect(ctx.destination) feeds `input` + _destRedirect.set(ctx.destination, input); + + const chain = { ctx, input, revSend, choSend, dlySend, dlyDelay, fb, comp }; + gpFxChains.push(chain); + applyFxCtrl(chain); +} + +function applyFxCtrl(ch) { + const c = gpFxCtrl; + ch.revSend.gain.value = c.reverb.on ? (c.reverb.mix / 100) * 0.6 : 0; + ch.choSend.gain.value = c.chorus.on ? (c.chorus.mix / 100) * 0.7 : 0; + ch.dlySend.gain.value = c.delay.on ? (c.delay.mix / 100) * 0.5 : 0; + ch.dlyDelay.delayTime.value = Math.max(0.02, (c.delay.time || 300) / 1000); + ch.fb.gain.value = c.delay.on ? 0.38 : 0; + const a = c.comp.on ? c.comp.amt / 100 : 0; + ch.comp.threshold.value = -50 * a; // 0 → 0dB (transparent), 1 → -50dB + ch.comp.ratio.value = 1 + 11 * a; // 1 → 12:1 +} +function applyFxCtrlAll() { gpFxChains.forEach(applyFxCtrl); } + +function setFxOn(key, on) { gpFxCtrl[key].on = on; applyFxCtrlAll(); } +function setFxMix(key, v) { gpFxCtrl[key].mix = parseInt(v, 10) || 0; applyFxCtrlAll(); } +function setFxTime(v) { gpFxCtrl.delay.time = parseInt(v, 10) || 0; applyFxCtrlAll(); } +function setFxComp(v) { gpFxCtrl.comp.amt = parseInt(v, 10) || 0; applyFxCtrlAll(); } +function toggleGpFxPanel() { + const p = document.getElementById('gp-fx-panel'); + if (p) p.style.display = p.style.display === 'none' ? 'flex' : 'none'; +} +function toggleMeFxPanel() { + const p = document.getElementById('me-fx-panel'); + if (p) p.style.display = p.style.display === 'none' ? 'flex' : 'none'; +} + + +// ── MIDI 외부 출력 ──────────────────────────────────────────── +let _midiAccess = null; +let _midiInitPending = false; + +async function initMidiOut() { + const sel = document.getElementById('gp-midi-out'); + if (!navigator.requestMIDIAccess) { + sel.innerHTML = ''; + return; + } + // Already initialized — just refresh the port list + if (_midiAccess) { refreshMidiPorts(); return; } + if (_midiInitPending) return; + _midiInitPending = true; + try { + // Wrap in a race with timeout — some MIDI drivers hang or crash on access. + // Note: a native driver crash (e.g. OmniMIDI) kills the renderer regardless + // of this timeout; this only guards against a JS-level hang. + const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 3000)); + _midiAccess = await Promise.race([ + navigator.requestMIDIAccess({ sysex: false }), + timeout + ]); + refreshMidiPorts(); + _midiAccess.onstatechange = refreshMidiPorts; + } catch(e) { + sel.innerHTML = ``; + } finally { + _midiInitPending = false; + } +} + +function refreshMidiPorts() { + const sel = document.getElementById('gp-midi-out'); + const cur = sel.value; + const ports = [['', '-- 내장 SF2 --']]; + _midiAccess?.outputs?.forEach(p => ports.push([p.id, p.name])); + sel.innerHTML = ports.map(([id, name]) => + `` + ).join(''); +} + +function setGpTempo(val) { + const bpm = Math.max(20, Math.min(400, parseInt(val) || 0)); + if (!bpm) return; + document.getElementById('gp-tempo').value = bpm; + const orig = gpApi?.score?.tempo; + if (gpApi && orig) gpApi.playbackSpeed = bpm / orig; +} + +function selectMidiOut(portId) { + gpMidiOut = portId ? _midiAccess?.outputs?.get(portId) || null : null; + if (gpApi) gpApi.masterVolume = gpMidiOut ? 0 : 1; + const mv = document.getElementById('gp-master-vol'); + if (mv) { mv.value = gpMidiOut ? 0 : 100; const l = document.getElementById('gp-master-vol-val'); if (l) l.textContent = (gpMidiOut ? 0 : 100) + '%'; } + if (gpMidiOut) { + addChatMsg('system', `🎹 MIDI 포트 "${escHtml(gpMidiOut.name)}" 선택됨 — SF2 음소거`); + sendMidiProgramChanges(); + } +} + +// Send GM program changes for all tracks using score data +function sendMidiProgramChanges() { + if (!gpMidiOut || !gpApi?.score?.tracks) return; + for (const track of gpApi.score.tracks) { + const info = track.playbackInfo; + if (!info) continue; + const pch = (info.primaryChannel ?? 0) & 0x0F; + const sch = (info.secondaryChannel ?? pch) & 0x0F; + const prog = (info.program ?? 0) & 0x7F; + // MIDI spec: Bank Select must come BEFORE Program Change + const vol = Math.min(127, Math.round((info.volume ?? 13) / 16 * 127)); + const pan = Math.min(127, Math.round((info.balance ?? 8) / 16 * 127)); + for (const ch of [...new Set([pch, sch])]) { + gpMidiOut.send([0xB0 | ch, 0, 0]); // Bank Select MSB + gpMidiOut.send([0xB0 | ch, 32, 0]); // Bank Select LSB + gpMidiOut.send([0xC0 | ch, prog]); // Program Change + gpMidiOut.send([0xB0 | ch, 7, vol]); // CC7 Volume + gpMidiOut.send([0xB0 | ch, 10, pan]); // CC10 Pan + } + } +} + +function setupMidiForwarding() { + if (!gpApi) return; + + // AlphaTab internal enum: NoteOn=0x80, NoteOff=0x90 (swapped vs MIDI standard) + // Filter uses these internal values + try { gpApi.midiEventsPlayedFilter = [0x80, 0x90, 0xB0, 0xC0, 0xE0]; } catch(e) {} + + gpApi.midiEventsPlayed.on(args => { + if (!gpMidiOut) return; + for (const e of args.events) { + try { + const ch = (e.channel ?? 0) & 0x0F; + const t = e.type ?? 0; + + if (e.noteKey !== undefined) { + // Swap bytes: AlphaTab 0x80=NoteOn / 0x90=NoteOff → MIDI 0x90=NoteOn / 0x80=NoteOff + const status = (t === 0x80 ? 0x90 : 0x80) | ch; + gpMidiOut.send([status, e.noteKey & 0x7F, (e.noteVelocity ?? 0) & 0x7F]); + } else if (e.controllerNumber !== undefined) { + gpMidiOut.send([0xB0 | ch, e.controllerNumber & 0x7F, (e.controllerValue ?? 0) & 0x7F]); + } else if (e.program !== undefined) { + gpMidiOut.send([0xC0 | ch, e.program & 0x7F]); + } else if (t === 0xE0 && e.value !== undefined) { + const v = e.value & 0x3FFF; + gpMidiOut.send([0xE0 | ch, v & 0x7F, (v >> 7) & 0x7F]); + } + } catch(err) { console.warn('MIDI send:', err.message); } + } + }); + + // Send program changes now (in case port already selected) + sendMidiProgramChanges(); +} + +// ───────────────────────────────────────────────────────────── + +function loadGpBuffer(buf, name) { + showStageGp(); + document.getElementById('gp-title').textContent = '🎸 ' + name; + document.getElementById('gp-loading').style.display = 'block'; + document.getElementById('gp-main').innerHTML = ''; + document.getElementById('gp-track-list').innerHTML = ''; + document.getElementById('gp-tempo').value = ''; + gpMuted.clear(); + if (gpApi) { try { gpApi.destroy(); } catch(e){} gpApi = null; } + + const container = document.getElementById('gp-main'); + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + + // ArrayBuffer → Blob URL (AlphaTab 1.8 requires URL or Uint8Array, not raw ArrayBuffer) + const blob = new Blob([new Uint8Array(buf)], { type: 'application/octet-stream' }); + const blobUrl = URL.createObjectURL(blob); + + const settings = { + file: blobUrl, + display: { + layoutMode: alphaTab.LayoutMode.Page, + scale: 0.9, + resources: isDark ? { + staffLineColor: new alphaTab.model.Color(107, 114, 128), + barSeparatorColor: new alphaTab.model.Color(107, 114, 128), + mainGlyphColor: new alphaTab.model.Color(229, 231, 235), + secondaryGlyphColor: new alphaTab.model.Color(156, 163, 175), + scoreInfoColor: new alphaTab.model.Color(229, 231, 235), + } : {} + }, + player: { + enablePlayer: true, + enableCursor: true, + soundFont: '/GeneralUser-GS.sf2', + scrollElement: document.getElementById('gp-viewport') + } + }; + + try { + patchAudioForGpFx(); // intercept AudioNode→destination before AlphaTab creates its audio graph + gpApi = new alphaTab.AlphaTabApi(container, settings); + setGpMasterVol(100); + gpApi.renderFinished.on(() => { + URL.revokeObjectURL(blobUrl); + document.getElementById('gp-loading').style.display = 'none'; + renderGpTracks(); + const bpm = gpApi?.score?.tempo; + if (bpm) document.getElementById('gp-tempo').value = bpm; + }); + gpApi.playerStateChanged.on((e) => { + const btn = document.getElementById('gp-play-btn'); + if (btn) btn.textContent = e.state === alphaTab.synth.PlayerState.Playing ? '⏸ 일시정지' : '▶ 재생'; + }); + gpApi.soundFontLoaded.on(() => { setupMidiForwarding(); }); + gpApi.error.on((e) => { + URL.revokeObjectURL(blobUrl); + document.getElementById('gp-loading').textContent = '⚠️ 로딩 실패: ' + (e.message || e); + }); + } catch(err) { + URL.revokeObjectURL(blobUrl); + document.getElementById('gp-loading').textContent = '⚠️ 오류: ' + err.message; + } +} + +function renderGpTracks() { + if (!gpApi || !gpApi.score) return; + const tracks = gpApi.score.tracks; + if (!tracks || !tracks.length) return; + const el = document.getElementById('gp-track-list'); + const chips = tracks.map((t, i) => { + const muted = gpMuted.has(i); + const v = Math.round((t.playbackInfo?.volume ?? 11) / 16 * 100); + return ` + ${escHtml(t.name || '트랙 ' + (i+1))} + ${muted ? '🔇' : '🔊'} + + ${v} + `; + }).join(''); + el.innerHTML = `전체${chips}`; +} +function setGpMasterVol(v) { + const val = Math.max(0, Math.min(200, parseInt(v, 10) || 0)); + document.getElementById('gp-master-vol').value = val; + const lbl = document.getElementById('gp-master-vol-val'); if (lbl) lbl.textContent = val + '%'; + if (gpApi) gpApi.masterVolume = val / 100; +} +function setGpTrackVol(i, v) { + if (!gpApi?.score?.tracks?.[i]) return; + const val = Math.max(0, Math.min(100, parseInt(v, 10) || 0)) / 100; + gpApi.changeTrackVolume([gpApi.score.tracks[i]], val); + gpVolLabel(i, Math.round(val * 100)); +} +function gpVolLabel(i, v) { + const lbl = document.getElementById('gp-vol-val-' + i); + if (lbl) lbl.textContent = Math.round(v); +} + +const gpMuted = new Set(); +function gpToggleMute(idx) { + if (!gpApi || !gpApi.score) return; + const track = gpApi.score.tracks[idx]; + if (!track) return; + const mute = !gpMuted.has(idx); + if (mute) gpMuted.add(idx); else gpMuted.delete(idx); + try { gpApi.changeTrackMute([track], mute); } catch(e) { console.warn('mute:', e.message); } + renderGpTracks(); +} + +function gpSoloTrack(idx) { + if (!gpApi || !gpApi.score) return; + // 이미 이 트랙만 선택된 상태면 전체로 돌아감 + const allChips = document.querySelectorAll('.gp-track-chip:not(#gp-chip-all)'); + const onChips = Array.from(allChips).filter(c => c.classList.contains('on')); + if (onChips.length === 1 && onChips[0].id === 'gp-chip-' + idx) { + gpAllTracks(); return; + } + allChips.forEach(c => c.classList.remove('on')); + document.getElementById('gp-chip-all').classList.remove('on'); + document.getElementById('gp-chip-' + idx).classList.add('on'); + gpApi.renderTracks([gpApi.score.tracks[idx]]); +} + +function gpAllTracks() { + if (!gpApi || !gpApi.score) return; + document.querySelectorAll('.gp-track-chip').forEach(c => c.classList.add('on')); + gpApi.renderTracks(gpApi.score.tracks); +} + +let _midiPlaying = false; +function gpTogglePlay() { + if (gpStageMode === 'midi') { + if (!_spessaSeq) return; + try { + if (_midiPlaying) { + _spessaSeq.pause(); _midiPlaying = false; _setMidiBtn(false); midiSfGpStatus('일시정지'); + } else { + if (_spessaCtx && _spessaCtx.state === 'suspended') _spessaCtx.resume(); + _spessaSeq.play(); _midiPlaying = true; _setMidiBtn(true); + } + } catch(e) { console.error('gpTogglePlay midi:', e); } + return; + } + if (!gpApi) return; + gpApi.playPause(); +} + +function gpStop() { + if (gpStageMode === 'midi') { + if (_spessaSeq) try { _spessaSeq.pause(); } catch{} + _midiPlaying = false; _setMidiBtn(false); midiSfGpStatus('정지'); + return; + } + if (!gpApi) return; + gpApi.stop(); +} + +// ── Shared AudioContext (drum box + looper) ─────────────────────────────────── +let _ac = null; +function getAudioCtx() { + if (!_ac) { + const AC = window.AudioContext || window.webkitAudioContext; + _ac = new AC({ latencyHint: 'playback' }); + } + if (_ac.state === 'suspended') _ac.resume(); + return _ac; +} + +// ── Drum box ────────────────────────────────────────────────────────────────── +const DRUM_VOICES = [ + { name: '킥', note: 36, synth: synthKick }, + { name: '스네어', note: 38, synth: synthSnare }, + { name: '클로즈햇', note: 42, synth: (t,v)=>synthHat(t,v,false) }, + { name: '오픈햇', note: 46, synth: (t,v)=>synthHat(t,v,true ) }, + { name: '클랩', note: 39, synth: synthClap }, + { name: '로우탐', note: 45, synth: (t,v)=>synthTom(t,v,90,55) }, + { name: '하이탐', note: 48, synth: (t,v)=>synthTom(t,v,180,110) }, + { name: '심벌', note: 49, synth: synthCrash }, +]; +let drumPattern = []; // [voiceIdx][step] bool +let drumSteps = 16; +let drumBpm = 120; +let drumPlaying = false; +let drumStep = 0; +let drumStartTime = 0; +let drumNextTime = 0; +let drumTimer = null; +let drumRaf = null; +let drumMaster = null; +let drumSamples = {}; // voiceIdx -> [AudioBuffer] (round-robin variants) +let drumVarIdx = []; // per-voice variant counter +let drumSamplesLoaded = false; +let drumSampleLoading = false; + +function drumNoiseBuf() { + const ctx = getAudioCtx(); + if (!ctx._noiseBuf) { + const len = ctx.sampleRate; // 1s + const buf = ctx.createBuffer(1, len, ctx.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1; + ctx._noiseBuf = buf; + } + return ctx._noiseBuf; +} +function drumMasterGain() { + if (!drumMaster) { + const ctx = getAudioCtx(); + drumMaster = ctx.createGain(); + drumMaster.gain.value = 0.8; + drumMaster.connect(ctx.destination); + } + return drumMaster; +} +async function drumLoadSamples() { + if (drumSamplesLoaded || drumSampleLoading) return; + drumSampleLoading = true; + const st = document.getElementById('drum-sample-status'); + if (st) st.textContent = '샘플 로딩 중…'; + try { + const ctx = getAudioCtx(); + const r = await fetch('/drums/manifest.json', { headers: authH() }); + if (!r.ok) throw new Error('manifest ' + r.status); + const map = await r.json(); + let total = 0, ok = 0; + for (const k in map) total += map[k].length; + for (const k in map) { + const vi = parseInt(k, 10); + drumSamples[vi] = []; + for (const fname of map[k]) { + try { + const ar = await fetch('/drums/' + fname, { headers: authH() }); + if (!ar.ok) continue; + const ab = await ar.arrayBuffer(); + const buf = await ctx.decodeAudioData(ab.slice(0)); + drumSamples[vi].push(buf); + ok++; + } catch (e) { console.warn('drum sample decode fail', fname, e.message); } + } + } + drumSamplesLoaded = true; + if (st) st.textContent = `샘플 ${ok}/${total} · 실제음`; + } catch (e) { + if (st) st.textContent = '샘플 로딩 실패 (합성 사용)'; + console.warn('drum samples load fail', e.message); + } finally { + drumSampleLoading = false; + } +} +function drumTriggerVoice(vi, time, vel) { + const list = drumSamples[vi]; + if (list && list.length) { + const ctx = getAudioCtx(); + const idx = (drumVarIdx[vi] || 0) % list.length; + drumVarIdx[vi] = idx + 1; + const src = ctx.createBufferSource(); + src.buffer = list[idx]; + src.playbackRate.value = 1; + const g = ctx.createGain(); + g.gain.value = (vel == null ? 1 : vel); + src.connect(g).connect(drumMasterGain()); + src.start(time); + return; + } + DRUM_VOICES[vi].synth(time, vel == null ? 1 : vel); +} +function noiseSrc(t) { + const ctx = getAudioCtx(); + const s = ctx.createBufferSource(); + s.buffer = drumNoiseBuf(); + s.start(t); + return s; +} +function synthKick(t, v) { + const ctx = getAudioCtx(); + const osc = ctx.createOscillator(), g = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(150, t); + osc.frequency.exponentialRampToValueAtTime(50, t + 0.12); + g.gain.setValueAtTime(0.9 * v, t); + g.gain.exponentialRampToValueAtTime(0.0001, t + 0.18); + osc.connect(g).connect(drumMasterGain()); + osc.start(t); osc.stop(t + 0.2); +} +function synthSnare(t, v) { + const ctx = getAudioCtx(); + // noise body + const n = noiseSrc(t); + const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1800; bp.Q.value = 0.7; + const ng = ctx.createGain(); ng.gain.setValueAtTime(0.7*v, t); ng.gain.exponentialRampToValueAtTime(0.0001, t+0.18); + n.connect(bp).connect(ng).connect(drumMasterGain()); + // tone + const osc = ctx.createOscillator(), og = ctx.createGain(); + osc.type = 'triangle'; osc.frequency.value = 180; + og.gain.setValueAtTime(0.4*v, t); og.gain.exponentialRampToValueAtTime(0.0001, t+0.1); + osc.connect(og).connect(drumMasterGain()); + osc.start(t); osc.stop(t+0.12); +} +function synthHat(t, v, open) { + const ctx = getAudioCtx(); + const n = noiseSrc(t); + const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 7000; + const g = ctx.createGain(); + const dur = open ? 0.3 : 0.05; + g.gain.setValueAtTime(0.35*v, t); g.gain.exponentialRampToValueAtTime(0.0001, t+dur); + n.connect(hp).connect(g).connect(drumMasterGain()); +} +function synthClap(t, v) { + const ctx = getAudioCtx(); + const offsets = [0, 0.012, 0.024, 0.04]; + for (const off of offsets) { + const n = noiseSrc(t+off); + const bp = ctx.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=1500; bp.Q.value=0.5; + const g = ctx.createGain(); + const dur = off === offsets[3] ? 0.12 : 0.03; + g.gain.setValueAtTime(0.5*v, t+off); g.gain.exponentialRampToValueAtTime(0.0001, t+off+dur); + n.connect(bp).connect(g).connect(drumMasterGain()); + } +} +function synthTom(t, v, f0, f1) { + const ctx = getAudioCtx(); + const osc = ctx.createOscillator(), g = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(f0, t); osc.frequency.exponentialRampToValueAtTime(f1, t+0.18); + g.gain.setValueAtTime(0.6*v, t); g.gain.exponentialRampToValueAtTime(0.0001, t+0.25); + osc.connect(g).connect(drumMasterGain()); + osc.start(t); osc.stop(t+0.3); +} +function synthCrash(t, v) { + const ctx = getAudioCtx(); + const n = noiseSrc(t); + const hp = ctx.createBiquadFilter(); hp.type='highpass'; hp.frequency.value=4000; + const g = ctx.createGain(); + g.gain.setValueAtTime(0.35*v, t); g.gain.exponentialRampToValueAtTime(0.0001, t+0.6); + n.connect(hp).connect(g).connect(drumMasterGain()); +} + +function drumStepDur() { return 60 / drumBpm / 4; } // 16분음 + +function drumRenderGrid() { + const grid = document.getElementById('drum-grid'); + let html = ''; + for (let vi = 0; vi < DRUM_VOICES.length; vi++) { + const voice = DRUM_VOICES[vi]; + let cells = ''; + for (let s = 0; s < drumSteps; s++) { + const on = drumPattern[vi] && drumPattern[vi][s]; + const beat = (s % 4 === 0) ? ' beat' : ''; + cells += ``; + } + html += `
${voice.name}
${cells}
`; + } + grid.innerHTML = html; +} + +function drumToggle(vi, s, rightClick) { + drumPattern[vi] = drumPattern[vi] || new Array(drumSteps).fill(false); + if (rightClick) { // right-click = preview only, don't toggle + drumTriggerVoice(vi, getAudioCtx().currentTime, 1); + return; + } + drumPattern[vi][s] = !drumPattern[vi][s]; + const cell = document.getElementById(`dc-${vi}-${s}`); + if (cell) cell.classList.toggle('on', drumPattern[vi][s]); + if (drumPattern[vi][s]) drumTriggerVoice(vi, getAudioCtx().currentTime, 1); +} + +function drumPreview(vi) { drumTriggerVoice(vi, getAudioCtx().currentTime, 1); } + +function drumSetSteps(n) { + drumStop(); + if (loopRecording) loopStopRecord(); + if (loopPlaying) loopStop(); + const prev = drumPattern; + drumSteps = parseInt(n, 10) || 16; + drumPattern = DRUM_VOICES.map((_, vi) => { + const row = new Array(drumSteps).fill(false); + if (prev[vi]) for (let s = 0; s < Math.min(drumSteps, prev[vi].length); s++) row[s] = !!prev[vi][s]; + return row; + }); + drumRenderGrid(); +} + +function drumSetBpm(v) { + const nb = Math.max(40, Math.min(300, parseInt(v, 10) || 120)); + const changed = nb !== drumBpm; + drumBpm = nb; + document.getElementById('drum-bpm').value = drumBpm; + if (changed) { if (loopRecording) loopStopRecord(); if (loopPlaying) loopStop(); loopRenderLayers(); } +} + +function drumSetVol(v) { drumMasterGain().gain.value = (parseInt(v, 10) || 0) / 100; } + +function drumClear() { + drumPattern = DRUM_VOICES.map(() => new Array(drumSteps).fill(false)); + drumRenderGrid(); +} + +// ── Rhythm library (리듬별 패턴) ───────────────────────────────────────────── +// voice order: [0]킥 [1]스네어 [2]클로즈햇 [3]오픈햇 [4]클랩 [5]로우탐 [6]하이탐 [7]심벌 +const DRUM_RHYTHMS = { + rock: { label: '🤘 록 (Rock)', bpm: 120, swing: 0, swingDiv: '8', pat: { 0:[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } }, + pop: { label: '🎤 팝 (Pop)', bpm: 100, swing: 0, swingDiv: '8', pat: { 0:[1,0,0,0,1,0,0,0,1,0,1,0,1,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } }, + funk: { label: '🪩 펑크 (Funk)', bpm: 100, swing: 56, swingDiv: '16', pat: { 0:[1,0,0,1,0,0,1,0,0,0,1,0,0,1,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], 3:[0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0] } }, + jazz: { label: '🎷 재즈 (Jazz swing)', bpm: 140, swing: 62, swingDiv: '8', pat: { 0:[1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0] } }, + blues: { label: '🎸 블루스 셔플', bpm: 90, swing: 66, swingDiv: '8', pat: { 0:[1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } }, + trap: { label: '🔥 트랩 (Trap)', bpm: 70, swing: 60, swingDiv: '16', pat: { 0:[1,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0], 1:[0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], 2:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], 3:[0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0] } }, + hiphop: { label: '🎧 힙합 붐밥', bpm: 90, swing: 54, swingDiv: '8', pat: { 0:[1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } }, + reggae: { label: '🏝️ 레게 (One-drop)', bpm: 80, swing: 52, swingDiv: '8', pat: { 0:[0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], 1:[0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } }, + disco: { label: '🕺 디스코 (4-on-floor)', bpm: 120, swing: 0, swingDiv: '8', pat: { 0:[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], 3:[0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1] } }, + bossa: { label: '🌴 보사노바', bpm: 130, swing: 50, swingDiv: '8', pat: { 0:[1,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0], 1:[0,0,0,1,0,0,1,0,0,0,0,1,0,0,1,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } }, +}; +let drumSwing = 0; // 0..70 % — swing amount +let drumSwingDiv = '8'; // '8' = 8th-note swing (jazz/blues/hiphop/reggae/bossa), '16' = 16th-note swing (funk/trap, MPC-style) +function drumInitRhythmSelect() { + const sel = document.getElementById('drum-rhythm'); + if (!sel || sel.options.length) return; + sel.innerHTML = '' + + Object.entries(DRUM_RHYTHMS).map(([k, r]) => ``).join(''); +} +function drumLoadRhythm(key) { + const r = DRUM_RHYTHMS[key]; if (!r) return; + drumSetSteps(16); + drumBpm = r.bpm; document.getElementById('drum-bpm').value = r.bpm; + drumSwing = r.swing; drumSwingDiv = r.swingDiv || '8'; + document.getElementById('drum-swing').value = r.swing; + drumUpdateSwingLabel(); + drumPattern = DRUM_VOICES.map((_, vi) => { + const row = new Array(drumSteps).fill(false); + const src = r.pat[vi]; + if (src) for (let s = 0; s < Math.min(drumSteps, src.length); s++) row[s] = !!src[s]; + return row; + }); + drumRenderGrid(); +} +function drumSetSwing(v) { + drumSwing = Math.max(0, Math.min(70, parseFloat(v) || 0)); + drumUpdateSwingLabel(); +} +function drumUpdateSwingLabel() { + const el = document.getElementById('drum-swing-val'); + if (el) el.textContent = drumSwing + '%' + (drumSwingDiv === '16' ? ' (16분)' : ' (8분)'); +} + +// ── Saved patterns (server workspace) ───────────────────────────────────────── +async function drumRefreshSavedList() { + const sel = document.getElementById('drum-saved'); + if (!sel) return; + try { + const r = await fetch('/api/music/drums/list', { headers: authH() }); + const data = await r.json(); + const cur = sel.value; + const files = data.files || []; + sel.innerHTML = (files.length ? files : [{name:'', mtime:0}]).map((f, i) => + i === 0 && !files.length ? '' : + ``).join(''); + if (cur && files.some(f => f.name === cur)) sel.value = cur; + } catch {} +} +async function drumSavePattern() { + let name = prompt('패턴 이름을 입력하세요:', '내 패턴'); + if (!name) return; + name = name.trim().slice(0, 60); + if (!name) return; + const body = { name, steps: drumSteps, bpm: drumBpm, swing: drumSwing, swingDiv: drumSwingDiv, pattern: drumPattern }; + try { + const r = await fetch('/api/music/drums/save', { + method: 'POST', headers: authH({ 'Content-Type': 'application/json' }), body: JSON.stringify(body) }); + const data = await r.json(); + if (!r.ok) throw new Error(data.error || '저장 실패'); + addChatMsg('system', `💾 드럼 패턴 "${escHtml(name)}" 저장됨`); + await drumRefreshSavedList(); + document.getElementById('drum-saved').value = name; + } catch (e) { addChatMsg('system', '❌ 저장 실패: ' + escHtml(e.message)); } +} +async function drumLoadSaved(name) { + try { + const r = await fetch('/api/music/drums/' + encodeURIComponent(name), { headers: authH() }); + if (!r.ok) throw new Error('불러오기 실패 ' + r.status); + const d = await r.json(); + drumSetSteps(d.steps || 16); + drumBpm = d.bpm || 120; document.getElementById('drum-bpm').value = drumBpm; + drumSwing = d.swing || 0; drumSwingDiv = (d.swingDiv === '16' ? '16' : '8'); + document.getElementById('drum-swing').value = drumSwing; drumUpdateSwingLabel(); + drumPattern = DRUM_VOICES.map((_, vi) => { + const row = new Array(drumSteps).fill(false); + const src = d.pattern && d.pattern[vi]; + if (src) for (let s = 0; s < Math.min(drumSteps, src.length); s++) row[s] = !!src[s]; + return row; + }); + drumRenderGrid(); + addChatMsg('system', `📂 패턴 "${escHtml(name)}" 불러옴 · 편집 후 다시 저장 가능`); + } catch (e) { addChatMsg('system', '❌ 불러오기 실패: ' + escHtml(e.message)); } +} +function drumLoadSavedSel() { + const name = document.getElementById('drum-saved').value; + if (name) drumLoadSaved(name); +} +async function drumDeleteSavedSel() { + const name = document.getElementById('drum-saved').value; + if (!name) return; + if (!confirm(`패턴 "${name}" 삭제할까요?`)) return; + try { + const r = await fetch('/api/music/drums/' + encodeURIComponent(name), { method: 'DELETE', headers: authH() }); + if (!r.ok) throw new Error('삭제 실패'); + await drumRefreshSavedList(); + addChatMsg('system', `🗑 패턴 "${escHtml(name)}" 삭제됨`); + } catch (e) { addChatMsg('system', '❌ 삭제 실패: ' + escHtml(e.message)); } +} + +function drumScheduleStep(step, time) { + for (let vi = 0; vi < DRUM_VOICES.length; vi++) { + if (drumPattern[vi] && drumPattern[vi][step]) drumTriggerVoice(vi, time, 1); + } +} + +function drumScheduler() { + const ctx = getAudioCtx(); + const AHEAD = 0.1; + const sd = drumStepDur(); + // 8th-note swing: delay upbeat-8th group (step%4>=2) by swing/100 of a 16th. ~67% ≈ triplet. + // 16th-note swing (MPC): delay odd 16th steps (step%2===1) by swing/100 of a 32nd (sd/2). + const swingOff = drumSwing / 100 * (drumSwingDiv === '16' ? sd / 2 : sd); + const swingTest = drumSwingDiv === '16' ? (s => s % 2 === 1) : (s => s % 4 >= 2); + while (drumNextTime < ctx.currentTime + AHEAD) { + const t = drumNextTime + (swingTest(drumStep) ? swingOff : 0); + drumScheduleStep(drumStep, t); + drumNextTime += sd; + drumStep = (drumStep + 1) % drumSteps; + } + if (drumPlaying) drumTimer = setTimeout(drumScheduler, 25); +} + +function drumTogglePlay() { + if (drumPlaying) drumStop(); + else drumPlay(); +} +function drumPlay() { + const ctx = getAudioCtx(); + if (ctx.state === 'suspended') ctx.resume(); + drumPlaying = true; + drumStep = 0; + drumStartTime = ctx.currentTime + 0.06; + drumNextTime = drumStartTime; + document.getElementById('drum-play-btn').textContent = '⏸ 일시정지'; + drumScheduler(); + drumDrawLoop(); +} +function drumStop() { + drumPlaying = false; + if (drumTimer) { clearTimeout(drumTimer); drumTimer = null; } + if (drumRaf) { cancelAnimationFrame(drumRaf); drumRaf = null; } + const btn = document.getElementById('drum-play-btn'); + if (btn) btn.textContent = '▶ 재생'; + document.querySelectorAll('.drum-cell.cur').forEach(c => c.classList.remove('cur')); +} +function drumDrawLoop() { + const ctx = getAudioCtx(); + const elapsed = ctx.currentTime - drumStartTime; + let cur = Math.floor(elapsed / drumStepDur()); + if (cur < 0) cur = 0; + cur = cur % drumSteps; + if (cur !== drumDrawLast) { + document.querySelectorAll('.drum-cell.cur').forEach(c => c.classList.remove('cur')); + for (let vi = 0; vi < DRUM_VOICES.length; vi++) { + const cell = document.getElementById(`dc-${vi}-${cur}`); + if (cell) cell.classList.add('cur'); + } + drumDrawLast = cur; + } + if (drumPlaying) drumRaf = requestAnimationFrame(drumDrawLoop); +} +let drumDrawLast = -1; + +// ── Drum pattern → MIDI export ──────────────────────────────────────────────── +function vlen(n) { + const out = []; + let v = n; + while (true) { + out.unshift(v & 0x7F); + if (v < 0x80) break; + out[0] |= 0x80; + v >>= 7; + } + return out; +} +function drumBuildMidi() { + const TPQ = 480, STEP = 240, DUR = 120, CH = 9, VEL = 100; + const evs = []; // {tick, bytes} + for (let s = 0; s < drumSteps; s++) { + for (let vi = 0; vi < DRUM_VOICES.length; vi++) { + if (drumPattern[vi] && drumPattern[vi][s]) { + const note = DRUM_VOICES[vi].note; + const tick = s * STEP; + evs.push({ tick, bytes: [0x99, note, VEL] }); + evs.push({ tick: tick + DUR, bytes: [0x89, note, 0] }); + } + } + } + evs.sort((a, b) => a.tick - b.tick); + const body = []; + let lastTick = 0; + for (const e of evs) { + const delta = e.tick - lastTick; lastTick = e.tick; + for (const b of vlen(delta)) body.push(b); + for (const b of e.bytes) body.push(b); + } + // end of track + for (const b of vlen(0)) body.push(b); + body.push(0xFF, 0x2F, 0x00); + // tracks + const mtrk = [0x4D, 0x54, 0x72, 0x6B]; + const len = body.length; + mtrk.push((len>>>24)&0xFF, (len>>>16)&0xFF, (len>>>8)&0xFF, len&0xFF); + const mthd = [0x4D,0x54,0x68,0x64, 0x00,0x00,0x00,0x06, 0x00,0x00, 0x00,0x01, (TPQ>>8)&0xFF, TPQ&0xFF]; + return new Uint8Array([...mthd, ...mtrk, ...body]); +} + +async function drumExportMidi() { + const hasAny = drumPattern.some(row => row && row.some(Boolean)); + if (!hasAny) { addChatMsg('system', '패턴이 비어 있습니다. 셀을 클릭해 드럼을 채우세요.'); return; } + const bytes = drumBuildMidi(); + const blob = new Blob([bytes], { type: 'audio/midi' }); + const filename = '드럼패턴.mid'; + const file = new File([blob], filename, { type: 'audio/midi' }); + addChatMsg('system', '🥁 드럼 패턴 MIDI 내보내는 중...'); + try { + const form = new FormData(); form.append('file', file); + const r = await fetch('/api/music/midi', { method: 'POST', headers: authH(), body: form }); + const data = await r.json(); + if (!r.ok) throw new Error(data.error || '등록 실패'); + const { html } = buildMidiPickerWidget(data, filename); + closeDrums(); + addToStage(html, filename); + loadMidiFiles(); + addChatMsg('system', '✅ 드럼 패턴을 MIDI로 내보냈습니다 · 스테이지에 추가됨'); + } catch (e) { addChatMsg('system', '❌ 내보내기 실패: ' + escHtml(e.message)); } +} + +// ── Looper (mic, beat-synced to drum box) ───────────────────────────────────── +let loopLayers = []; // {buffer, gain, source, muted, vol, dur, loopEnd} +let loopPlaying = false; +let loopRecording = false; +let loopBars = 1; // loop length in bars (1 bar = 16 steps) +let micStream = null, micSource = null, loopAnalyser = null, loopRecorder = null; +let loopRecChunks = [], loopRecTimer = null, loopRecStartTimer = null, loopMeterRaf = null, loopMasterGain = null; + +function loopMaster() { + if (!loopMasterGain) { + const ctx = getAudioCtx(); + loopMasterGain = ctx.createGain(); + loopMasterGain.gain.value = 1; + loopMasterGain.connect(ctx.destination); + } + return loopMasterGain; +} +async function loopEnsureMic() { + if (micStream) return true; + try { + micStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const ctx = getAudioCtx(); + micSource = ctx.createMediaStreamSource(micStream); + loopAnalyser = ctx.createAnalyser(); + loopAnalyser.fftSize = 512; + micSource.connect(loopAnalyser); + // analyser is a sink (not connected to destination) — no feedback + loopStartMeter(); + return true; + } catch (e) { addChatMsg('system', '❌ 마이크 접근 실패: ' + escHtml(e.message)); return false; } +} +function loopStartMeter() { + if (loopMeterRaf) return; + const fill = document.getElementById('loop-meter-fill'); + const data = new Uint8Array(loopAnalyser.fftSize); + const tick = () => { + loopAnalyser.getByteTimeDomainData(data); + let sum = 0; + for (let i = 0; i < data.length; i++) { const v = (data[i]-128)/128; sum += v*v; } + const rms = Math.sqrt(sum / data.length); + if (fill) fill.style.width = Math.min(100, rms * 200) + '%'; + loopMeterRaf = requestAnimationFrame(tick); + }; + tick(); +} + +// ── Beat sync helpers ── +function loopBarDur() { return 16 * drumStepDur(); } // one 4/4 bar (sec) +function loopLen() { return loopBars * loopBarDur(); } // total loop length (sec) +// next drum bar-boundary time (multiples of 16 steps from drumStartTime). Falls back +// to ctx.currentTime+0.05 when drums aren't playing (standalone loop transport). +function drumNextBarTime() { + const ctx = getAudioCtx(); + if (!drumPlaying) return ctx.currentTime + 0.05; + const barDur = loopBarDur(); + const elapsed = ctx.currentTime - drumStartTime; + const k = Math.floor(elapsed / barDur); + let t = drumStartTime + (k + 1) * barDur; + if (t - ctx.currentTime < 0.03) t += barDur; // too close to boundary → next one + return t; +} +function loopSetBars(v) { loopBars = Math.max(1, Math.min(8, parseInt(v, 10) || 1)); loopRenderLayers(); } + +function loopRenderLayers() { + const el = document.getElementById('loop-layers'); + if (!el) return; + if (!loopLayers.length) { + el.innerHTML = '
● 녹음을 누르면 드럼 비트에 맞춰 녹음 시작 (1마디 카운트인 후 녹음). 재생 중 누르면 오버덥(겹쳐 녹음).
'; + } else { + el.innerHTML = loopLayers.map((L, i) => ` +
+ 레이어 ${i+1} + + + ${loopLen().toFixed(1)}s + +
`).join(''); + } + const st = document.getElementById('loop-status'); + if (st) st.textContent = `레이어 ${loopLayers.length} · ${loopRecording?'녹음 중':loopPlaying?'재생 중':'대기'} · ${loopLen().toFixed(1)}s`; +} +function loopMute(i) { + const L = loopLayers[i]; if (!L) return; + L.muted = !L.muted; L.gain.gain.value = L.muted ? 0 : L.vol; + loopRenderLayers(); +} +function loopVol(i, v) { + const L = loopLayers[i]; if (!L) return; + L.vol = (parseInt(v,10)||0)/100; if (!L.muted) L.gain.gain.value = L.vol; +} +function loopDel(i) { + const L = loopLayers[i]; if (!L) return; + if (L.source) { try { L.source.stop(); } catch{} } + try { L.gain.disconnect(); } catch{} + loopLayers.splice(i, 1); + if (!loopLayers.length) { loopPlaying = false; } + loopRenderLayers(); +} +function loopUndo() { + if (!loopLayers.length) return; + loopDel(loopLayers.length - 1); +} +function loopClear() { + for (const L of loopLayers) { if (L.source) { try{L.source.stop();}catch{} } try{L.gain.disconnect();}catch{} } + loopLayers = []; loopPlaying = false; + const pb = document.getElementById('loop-play-btn'); if (pb) pb.textContent = '▶ 재생'; + loopRenderLayers(); +} + +async function loopToggleRecord() { + if (loopRecording) loopStopRecord(); + else await loopStartRecord(); +} +async function loopStartRecord() { + const ctx = getAudioCtx(); + if (ctx.state === 'suspended') ctx.resume(); + if (!(await loopEnsureMic())) return; + // Beat-synced: drums are the click. Auto-start if stopped (gives 1-bar count-in). + const wasPlaying = drumPlaying; + if (!wasPlaying) drumPlay(); + // play existing layers so you hear them while overdubbing + if (loopLayers.length && !loopPlaying) loopPlay(); + const recLen = loopLen(); + let startAt = drumNextBarTime(); + if (!wasPlaying) startAt += loopBarDur(); // fresh start → wait 1 bar (count-in) before recording + const recBtn = document.getElementById('loop-rec-btn'); + recBtn.classList.add('recording'); recBtn.textContent = '■ 녹음중지'; + loopRecording = true; + loopRenderLayers(); + const delayMs = Math.max(0, (startAt - ctx.currentTime) * 1000); + loopRecStartTimer = setTimeout(() => { + if (!loopRecording) return; // cancelled during count-in + loopRecChunks = []; + const mime = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : ''; + loopRecorder = mime ? new MediaRecorder(micStream, { mimeType: mime }) : new MediaRecorder(micStream); + loopRecorder.ondataavailable = e => { if (e.data && e.data.size) loopRecChunks.push(e.data); }; + loopRecorder.onstop = async () => { + const blob = new Blob(loopRecChunks, { type: loopRecChunks[0]?.type || 'audio/webm' }); + try { + const ab = await blob.arrayBuffer(); + const buf = await ctx.decodeAudioData(ab.slice(0)); + addLoopLayer(buf, recLen); + } catch (e) { addChatMsg('system', '❌ 녹음 디코딩 실패: ' + escHtml(e.message)); } + }; + loopRecorder.start(); + // stop after recLen + tail so buffer ≥ recLen (clean loopEnd, seamless loop) + loopRecTimer = setTimeout(() => { if (loopRecording) loopStopRecord(); }, (recLen + 0.15) * 1000); + }, delayMs); +} +function loopStopRecord() { + loopRecording = false; + if (loopRecStartTimer) { clearTimeout(loopRecStartTimer); loopRecStartTimer = null; } + if (loopRecTimer) { clearTimeout(loopRecTimer); loopRecTimer = null; } + if (loopRecorder && loopRecorder.state !== 'inactive') loopRecorder.stop(); + const btn = document.getElementById('loop-rec-btn'); + if (btn) { btn.classList.remove('recording'); btn.textContent = '● 녹음'; } + loopRenderLayers(); +} +function addLoopLayer(buf, loopEnd) { + const ctx = getAudioCtx(); + const g = ctx.createGain(); + g.gain.value = 1; + g.connect(loopMaster()); + const layer = { buffer: buf, gain: g, source: null, muted: false, vol: 1, dur: buf.duration, + loopEnd: loopEnd || buf.duration }; + loopLayers.push(layer); + // immediately play in sync with the drum beat (phase-aligned, no gap) + if (loopPlaying) { const s = loopAlignedStart(); startLayerSource(layer, s.when, s.offset); } + else loopPlay(); + loopRenderLayers(); +} +// Returns {when, offset} so a layer starts phase-aligned to the drum bar grid: +// plays from the current position within the loop, locking to the beat with no wait. +function loopAlignedStart() { + const ctx = getAudioCtx(); + if (!drumPlaying) return { when: ctx.currentTime + 0.05, offset: 0 }; + const barDur = loopBarDur(); + const len = loopLen(); + const elapsed = ctx.currentTime - drumStartTime; + const k = Math.floor(elapsed / barDur); + const lastBoundary = drumStartTime + k * barDur; + const when = ctx.currentTime + 0.03; + let offset = (when - lastBoundary) % len; + if (offset < 0) offset += len; + return { when, offset }; +} +function startLayerSource(L, when, offset) { + const ctx = getAudioCtx(); + const src = ctx.createBufferSource(); + src.buffer = L.buffer; src.loop = true; src.loopStart = 0; + src.loopEnd = Math.min(L.loopEnd, L.buffer.duration); + src.connect(L.gain); + src.start(when, offset || 0); + L.source = src; +} +function loopPlay() { + const ctx = getAudioCtx(); + if (ctx.state === 'suspended') ctx.resume(); + if (!loopLayers.length) return; + const s = loopAlignedStart(); + for (const L of loopLayers) startLayerSource(L, s.when, s.offset); + loopPlaying = true; + const pb = document.getElementById('loop-play-btn'); if (pb) pb.textContent = '⏸ 정지'; + loopRenderLayers(); +} +function loopTogglePlay() { + if (loopPlaying) loopStop(); + else loopPlay(); +} +function loopStop() { + for (const L of loopLayers) { if (L.source) { try{L.source.stop();}catch{} L.source = null; } } + loopPlaying = false; + const pb = document.getElementById('loop-play-btn'); if (pb) pb.textContent = '▶ 재생'; + loopRenderLayers(); +} + +// ── Stage switching: drums (with built-in looper) ───────────────────────────── +function hideAllStages() { + document.getElementById('stage-player').style.display = 'none'; + document.getElementById('stage-editor').classList.remove('visible'); + document.getElementById('stage-gp').style.display = 'none'; + document.getElementById('stage-drums').style.display = 'none'; +} +function showStageDrums() { + hideAllStages(); + const el = document.getElementById('stage-drums'); + el.style.display = 'flex'; el.style.flexDirection = 'column'; el.style.flex = '1'; el.style.minHeight = '0'; + if (!drumPattern.length) drumSetSteps(drumSteps); + drumInitRhythmSelect(); + drumRefreshSavedList(); + drumLoadSamples(); + loopRenderLayers(); +} +function closeDrums() { + drumStop(); + if (loopRecording) loopStopRecord(); + if (loopPlaying) loopStop(); + document.getElementById('stage-drums').style.display = 'none'; + restorePlayerStage(); +} +function restorePlayerStage() { + const p = document.getElementById('stage-player'); + p.style.display = 'flex'; p.style.flexDirection = 'column'; p.style.flex = '1'; p.style.minHeight = '0'; p.style.overflow = 'hidden'; +} + function escHtml(s){return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');} function escAttr(s){return String(s||'').replace(/'/g,"\\'");} @@ -447,8 +2199,21 @@ function addChatMsg(role, html, raw) { function renderMd(text) { return text .replace(/&/g,'&').replace(//g,'>') - .replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { + .replace(/```([\w-]*)\n?([\s\S]*?)```/g, (_, lang, code) => { if (lang === 'abc' || lang === 'lilypond') return buildAbcWidget(code.trim(), lang); + if (lang === 'midi-widget') { + try { + const d = JSON.parse(code.trim()); + if (d.midi_url && d.id) { + const fname = d.filename || 'output.mid'; + if (!_renderedMidiIds.has(d.id)) { + _renderedMidiIds.add(d.id); + setTimeout(() => { const {html:sh}=buildMidiPickerWidget(d,fname); addToStage(sh,fname); loadMidiFiles(); }, 0); + } + return '↑ 스테이지에서 보기'; + } + } catch {} + } return `
${code}
`; }) .replace(/`([^`]+)`/g,'$1') @@ -466,11 +2231,10 @@ function processMidiWidgets(html) { return html.replace(/
([\s\S]*?)<\/code><\/pre>/g, (match, inner) => {
     const dec = inner.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
     let data; try { data = JSON.parse(dec); } catch { return match; }
-    if (data && data.type === 'midi-widget' && data.midi_url) {
+    if (data && data.midi_url && data.id && !_renderedMidiIds.has(data.id)) {
+      _renderedMidiIds.add(data.id);
       const filename = data.filename || 'output.mid';
-      const { html: whtml } = buildMidiPickerWidget(data, filename);
-      // Also add to stage
-      setTimeout(() => addToStage(whtml.replace(/id="(midi-[^"]+)"/g, (m,id) => `id="${id}-s"`).replace(/onclick="([^"]*)'(midi-[^']+)'/g, (m,fn,id) => `onclick="${fn}'${id}-s'`), data.filename || 'output.mid'), 0);
+      setTimeout(() => { const {html:sh}=buildMidiPickerWidget(data,filename); addToStage(sh,filename); loadMidiFiles(); }, 0);
       return '↑ 스테이지에서 보기';
     }
     return match;
@@ -566,6 +2330,7 @@ async function callChat(message) {
 function clearChat() {
   if (!confirm('대화를 지울까요?')) return;
   document.getElementById('chat-msgs').innerHTML = '';
+  _renderedMidiIds.clear();
   try { sessionStorage.removeItem(SID_KEY); } catch {}
   addChatMsg('system', '🎵 새 대화가 시작되었습니다.');
 }
@@ -600,23 +2365,41 @@ const midiRouter = {
   _parse(buf) {
     const u8=new Uint8Array(buf),dv=new DataView(buf);
     if(dv.getUint32(0)!==0x4D546864)return[];
-    const tpq=dv.getUint16(12),ntrk=dv.getUint16(10);let tempo=500000;const ev=[];
+    const tpq=dv.getUint16(12),ntrk=dv.getUint16(10);
     const rv=p=>{let v=0,b;do{b=u8[p++];v=(v<<7)|(b&0x7F);}while(b&0x80);return{v,p};};
+    // Pass 1: collect events with absolute tick + global tempo map.
+    // (Type-1 MIDI: each track's tick counter is absolute from song start, so a
+    // single global tempo map keyed by tick is valid across all tracks.)
+    const ev=[],tmap=[{tk:0,tp:500000}];
     let p=14;
     for(let t=0;tu8.length||dv.getUint32(p)!==0x4D54726B)break;
       const tl=dv.getUint32(p+4);p+=8;const e=p+tl;let tk=0,rs=0;
       while(p=3)tempo=(u8[p]<<16)|(u8[p+1]<<8)|u8[p+2];p+=l.v;}
+        const ty=b&0xF0,ch=b&0x0F;
+        if(ty===0x90||ty===0x80){const n=u8[p++],vl=u8[p++];ev.push({tk,msg:[ty|ch,n,vl]});}
+        else if(ty===0xA0||ty===0xB0||ty===0xE0){ev.push({tk,msg:[b,u8[p],u8[p+1]]});p+=2;}
+        else if(ty===0xC0){ev.push({tk,msg:[0xC0|ch,u8[p++]]});}
+        else if(ty===0xD0){ev.push({tk,msg:[b,u8[p++]]});}
+        else if(b===0xFF){const mt=u8[p++];const l=rv(p);p=l.p;
+          if(mt===0x51&&l.v>=3){tmap.push({tk,tp:(u8[p]<<16)|(u8[p+1]<<8)|u8[p+2]});}
+          p+=l.v;
+        }
         else if(b===0xF0||b===0xF7){const l=rv(p);p=l.p+l.v;}else p++;
       }p=e;
     }
+    // Pass 2: integrate the tempo map to convert ticks → ms (handles tempo changes
+    // correctly; the old single-tempo formula broke songs with rit./accel.).
+    tmap.sort((a,b)=>a.tk-b.tk);
+    ev.sort((a,b)=>a.tk-b.tk);
+    let mi=0,ms=0,lastTk=0,cur=tmap[0].tp;
+    for(const e of ev){
+      while(mi+1a.ms-b.ms);return ev;
   },
   async play(url,outId,cb){
@@ -655,8 +2438,9 @@ function buildMusicActions(data,dl){
   if(data.saved_to)h+='
💾 '+escHtml(data.saved_to)+'
'; if(url){ h+=''; - h+=''; - h+=''; + h+=''; + h+=''; + h+=''; h+='⬇ MIDI'; } if(pdf)h+='⬇ PDF'; @@ -679,13 +2463,51 @@ function musicPageNav(nid,d){ if(lbl)lbl.textContent=nx+'/'+tot;if(pv)pv.disabled=nx<=1;if(nt)nt.disabled=nx>=tot; } +async function playMidiInternally(url, filename) { + try { + addChatMsg('system', '🎵 "' + (filename || 'MIDI') + '" 로딩 중…'); + + gpStageMode = 'midi'; + showStageGp(); + document.getElementById('gp-title').textContent = '🎵 ' + (filename || 'MIDI'); + document.getElementById('gp-track-list').innerHTML = ''; + document.getElementById('gp-main').innerHTML = ''; + document.getElementById('gp-play-btn').textContent = '▶ 재생'; + midiSfGpStatus('초기화 중...'); + + const [, midiResp] = await Promise.all([ + _ensureSpessa(), + fetch(url, { headers: authH() }) + ]); + if (!midiResp.ok) throw new Error('HTTP ' + midiResp.status); + const midiBuf = await midiResp.arrayBuffer(); + + midiSfGpStatus('MIDI 파싱 중...'); + const { BasicMIDI } = window.SpessaSynth; + const midi = BasicMIDI.fromArrayBuffer(midiBuf, filename || 'track.mid'); + + if (_spessaCtx && _spessaCtx.state === 'suspended') await _spessaCtx.resume(); + _spessaSeq.loadNewSongList([midi]); + _spessaSeq.eventHandler.addEvent('songEnded', 'ui', () => { + _midiPlaying = false; _setMidiBtn(false); midiSfGpStatus('재생 완료'); + }); + + _midiPlaying = true; + _spessaSeq.play(); + _setMidiBtn(true); + } catch(e) { + addChatMsg('system', '⚠️ MIDI 재생 오류: ' + e.message); + console.error('playMidiInternally:', e); + } +} + function buildMidiPickerWidget(data,filename){ const wid='midi-'+Math.random().toString(36).slice(2,10),url=data.midi_url,rid='mr-'+Math.random().toString(36).slice(2,10); const saved=data.saved_to?'
💾 '+escHtml(data.saved_to)+'
':''; const pp=''; const si=(data.has_svg&&data.svg_url)?buildSvgPagesHtml(data)+buildMusicActions(data,filename):''; const sa=''; - const html='
🎵 '+escHtml(filename)+'
'+saved+'
'+pp+sa+'
'; + const html='
🎵 '+escHtml(filename)+'
'+saved+'
'+pp+sa+'
'; return {wid,html}; } @@ -733,13 +2555,453 @@ function toggleMusicWidget(id){ } function closeMusicWidget(id){const el=document.getElementById(id);if(!el)return;(el.closest('.music-widget')||el).remove();} +// ════════════════════════════════════════════════════════════════════════════ +// Piano Roll (MIDI Editor — inline) +// ════════════════════════════════════════════════════════════════════════════ +const KEY_W = 56, RULE_H = 24, VEL_H = 70; +const COLORS = ['#e06c75','#98c379','#61afef','#c678dd','#e5c07b','#56b6c2','#d19a66','#be5046','#a6e3a1','#89dceb','#cba6f7','#f9e2af','#74c7ec','#fab387']; +const GM_NAMES = ['Piano','Bright Piano','E.Grand','Honky-Tonk','E.Piano1','E.Piano2','Harpsichord','Clavinet','Celesta','Glockenspiel','Music Box','Vibraphone','Marimba','Xylophone','Tubular Bells','Dulcimer','Hammond Organ','Perc.Organ','Rock Organ','Church Organ','Reed Organ','Accordion','Harmonica','Bandoneon','Nylon Guitar','Steel Guitar','Jazz Guitar','Clean Guitar','Muted Guitar','Overd.Guitar','Dist.Guitar','Harmonics','Acoustic Bass','Finger Bass','Pick Bass','Fretless Bass','Slap Bass1','Slap Bass2','Synth Bass1','Synth Bass2','Violin','Viola','Cello','Contrabass','Tremolo Str','Pizzicato','Harp','Timpani','Str Ensemble1','Str Ensemble2','Synth Str1','Synth Str2','Choir Aahs','Voice Oohs','Synth Choir','Orch.Hit','Trumpet','Trombone','Tuba','Muted Trumpet','French Horn','Brass Section','Synth Brass1','Synth Brass2','Soprano Sax','Alto Sax','Tenor Sax','Baritone Sax','Oboe','English Horn','Bassoon','Clarinet','Piccolo','Flute','Recorder','Pan Flute','Bottle Blow','Shakuhachi','Whistle','Ocarina','Square Lead','Sawtooth Lead','Calliope Lead','Chiffer Lead','Charang Lead','Solo Vox','5th Sawtooth','Bass+Lead','Fantasia','Warm Pad','Polysynth','Choir Pad','Bowed Pad','Metallic Pad','Halo Pad','Sweep Pad','Rain','Soundtrack','Crystal','Atmosphere','Brightness','Goblins','Echoes','Sci-Fi','Sitar','Banjo','Shamisen','Koto','Kalimba','Bag Pipe','Fiddle','Shanai','Tinkle Bell','Agogo','Steel Drums','Woodblock','Taiko Drum','Melodic Tom','Synth Drum','Rev.Cymbal','Guitar Fret','Breath Noise','Seashore','Bird Tweet','Telephone','Helicopter','Applause','Gunshot']; +const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']; +const BLACK_K = new Set([1,3,6,8,10]); + +let midi = mkMidi(); +let view = { scrollX:0, scrollY:57*8, pxPerTick:0.15, noteH:8 }; +let tool = 'sel', qTicks = 120, activeTi = 0; +let sel = new Set(), hist = [], histIdx = -1, drag = null; +let ph = 0, playing = false; +let meMidiAccess = null, meMidiOut = null; +let sfMode = true; +let _sfLoopEn = false, _sfLoopIn = 0, _sfLoopOut = 0; +let animId = null, playT0 = 0, playTick0 = 0, nextSchedTick = 0; +const SCHED_AHEAD_MS = 300; +let velDrag = null, clip = []; + +function mkMidi() { + return { format:1, tpq:480, tracks:[], tempos:[{tick:0,uspb:500000}], timeSigs:[{tick:0,num:4,den:4}] }; +} + +// Canvases — resolved lazily when the editor stage is first visible +let rulerC, keysC, rollC, velC, rCtx, kCtx, pCtx, vCtx; +function meInitCanvases() { + if (rulerC) return; + rulerC = document.getElementById('ruler-canvas'); + keysC = document.getElementById('keys-canvas'); + rollC = document.getElementById('roll-canvas'); + velC = document.getElementById('vel-canvas'); + rCtx = rulerC.getContext('2d'); + kCtx = keysC.getContext('2d'); + pCtx = rollC.getContext('2d'); + vCtx = velC.getContext('2d'); + meBindRollEvents(); +} + +function meZoomToFitBars(bars) { + if (!rollC || !rollC.width) return; + const barTicks = (midi.tpq || 480) * (midi.timeSigs[0]?.num || 4); + view.pxPerTick = Math.max(0.02, Math.min(4, rollC.width / (barTicks * bars))); +} + +function resize() { + const isFirst = !rulerC; + meInitCanvases(); + const wrap = document.getElementById('roll-wrap'); + const rr = document.getElementById('roll-row'); + const W = wrap.clientWidth - KEY_W; + const H = rr.clientHeight; + rulerC.width = W; rulerC.height = RULE_H; + keysC.height = H; + rollC.width = W; rollC.height = H; + velC.width = W; velC.height = VEL_H; + if (isFirst && W > 0) meZoomToFitBars(8); + renderAll(); +} +window.addEventListener('resize', () => { + if (document.getElementById('stage-editor')?.classList.contains('visible')) resize(); +}); + +// MIDI Parser +function parseMidi(buf) { + const u8=new Uint8Array(buf),dv=new DataView(buf); + if(dv.getUint32(0)!==0x4D546864) throw new Error('Not a MIDI file'); + const format=dv.getUint16(8),ntrk=dv.getUint16(10),tpq=dv.getUint16(12); + const res={format,tpq,tracks:[],tempos:[],timeSigs:[]}; + const readVL=(pos)=>{let val=0,b;do{b=u8[pos++];val=(val<<7)|(b&0x7F);}while(b&0x80);return{val,pos};}; + let p=14; + for(let t=0;tu8.length||dv.getUint32(p)!==0x4D54726B) break; + const tlen=dv.getUint32(p+4);p+=8;const end=p+tlen; + let tick=0,rs=0;const open={},notes=[],pcs=[],ccs=[];let name=`Track ${t+1}`,ch=-1; + while(p0)open[`${c}_${n}`]={tick,vel:v,ch:c};else{const o=open[`${c}_${n}`];if(o){notes.push({tick:o.tick,dur:Math.max(1,tick-o.tick),pitch:n,vel:o.vel,ch:o.ch});delete open[`${c}_${n}`];}}} + else if(typ===0x80){const n=u8[p++];p++;if(ch<0)ch=c;const o=open[`${c}_${n}`];if(o){notes.push({tick:o.tick,dur:Math.max(1,tick-o.tick),pitch:n,vel:o.vel,ch:o.ch});delete open[`${c}_${n}`];}} + else if(typ===0xA0){p+=2;}else if(typ===0xB0){const cc=u8[p++],val=u8[p++];ccs.push({tick,cc,val,ch:c});} + else if(typ===0xC0){pcs.push({tick,prog:u8[p++],ch:c});if(ch<0)ch=c;} + else if(typ===0xD0){p++;}else if(typ===0xE0){p+=2;} + else if(b===0xFF){const mt=u8[p++];const ll=readVL(p);p=ll.pos;if(mt===0x03)try{name=new TextDecoder().decode(u8.slice(p,p+ll.val));}catch{}else if(mt===0x51&&ll.val>=3)res.tempos.push({tick,uspb:(u8[p]<<16)|(u8[p+1]<<8)|u8[p+2]});else if(mt===0x58&&ll.val>=4)res.timeSigs.push({tick,num:u8[p],den:1<0||pcs.length>0){ + const chs=[...new Set(notes.map(n=>n.ch))].sort((a,b)=>a-b); + if(chs.length>1){for(const c of chs){const cN=notes.filter(n=>n.ch===c),cP=pcs.filter(pc=>pc.ch===c),cC=ccs.filter(cc=>cc.ch===c);if(cN.length||cP.length)res.tracks.push({name:`${name}/ch${c+1}`,channel:c,notes:cN,pcs:cP,ccs:cC,muted:false,solo:false,color:COLORS[res.tracks.length%COLORS.length]});}} + else res.tracks.push({name,channel:ch>=0?ch:t%16,notes,pcs,ccs,muted:false,solo:false,color:COLORS[res.tracks.length%COLORS.length]}); + } + p=end; + } + if(!res.tempos.length)res.tempos.push({tick:0,uspb:500000}); + if(!res.timeSigs.length)res.timeSigs.push({tick:0,num:4,den:4}); + return res; +} + +// MIDI Writer +function writeVL(n){if(n===0)return[0];const b=[];while(n>0){b.unshift(n&0x7F);n>>>=7;}for(let i=0;ia.tick-b.tick))tevs.push({tick:ts.tick,bytes:[0xFF,0x58,0x04,ts.num,Math.round(Math.log2(ts.den)),24,8]}); + for(const t of [...state.tempos].sort((a,b)=>a.tick-b.tick))tevs.push({tick:t.tick,bytes:[0xFF,0x51,0x03,(t.uspb>>16)&0xFF,(t.uspb>>8)&0xFF,t.uspb&0xFF]}); + tevs.sort((a,b)=>a.tick-b.tick); + const tb=[];let pv=0;for(const e of tevs){tb.push(...writeVL(e.tick-pv),...e.bytes);pv=e.tick;} + tb.push(0x00,0xFF,0x2F,0x00);trks.push(tb); + for(const trk of state.tracks){ + const evs=[]; + for(const pc of(trk.pcs||[])){const pc_c=(pc.ch??trk.channel);evs.push({tick:pc.tick,bytes:[0xC0|pc_c,pc.prog]});} + for(const cc of(trk.ccs||[])){const cc_c=(cc.ch??trk.channel);evs.push({tick:cc.tick,bytes:[0xB0|cc_c,cc.cc,cc.val]});} + for(const n of trk.notes){const nc=(n.ch??trk.channel);evs.push({tick:n.tick,bytes:[0x90|nc,n.pitch,n.vel]});evs.push({tick:n.tick+n.dur,bytes:[0x80|nc,n.pitch,0]});} + evs.sort((a,b)=>a.tick-b.tick); + const bytes=[];pv=0;for(const e of evs){bytes.push(...writeVL(e.tick-pv),...e.bytes);pv=e.tick;} + bytes.push(0x00,0xFF,0x2F,0x00);trks.push(bytes); + } + let total=14;for(const t of trks)total+=8+t.length; + const buf=new ArrayBuffer(total),dv=new DataView(buf),u8=new Uint8Array(buf);let p=0; + dv.setUint32(p,0x4D546864);p+=4;dv.setUint32(p,6);p+=4; + dv.setUint16(p,1);p+=2;dv.setUint16(p,trks.length);p+=2;dv.setUint16(p,state.tpq);p+=2; + for(const t of trks){dv.setUint32(p,0x4D54726B);p+=4;dv.setUint32(p,t.length);p+=4;u8.set(t,p);p+=t.length;} + return buf; +} + +// Coordinate helpers +const tX = tick => tick*view.pxPerTick - view.scrollX; +const pY = pitch => (127-pitch)*view.noteH - view.scrollY; +const xT = x => (x+view.scrollX)/view.pxPerTick; +const yP = y => 127-Math.floor((y+view.scrollY)/view.noteH); +const qz = tick => qTicks>0?Math.round(tick/qTicks)*qTicks:Math.round(tick); +const bpm = () => Math.round(60000000/(midi.tempos[0]?.uspb||500000)); +const mspT= () => (midi.tempos[0]?.uspb||500000)/midi.tpq/1000; + +// Rendering +function renderAll(){renderRuler();renderKeys();renderRoll();renderVel();} +function renderRuler(){ + if(!rulerC)return; + const W=rulerC.width,H=RULE_H; + rCtx.fillStyle='#1a1a2a';rCtx.fillRect(0,0,W,H); + const tpb=midi.tpq*(midi.timeSigs[0]?.num||4); + const startBar=Math.floor(view.scrollX/(tpb*view.pxPerTick)); + const endBar=startBar+Math.ceil(W/(tpb*view.pxPerTick))+2; + rCtx.font='10px monospace';rCtx.textBaseline='middle'; + for(let bar=startBar;bar<=endBar;bar++){ + const x=bar*tpb*view.pxPerTick-view.scrollX; + rCtx.strokeStyle='#45475a';rCtx.lineWidth=1;rCtx.beginPath();rCtx.moveTo(x+.5,0);rCtx.lineTo(x+.5,H);rCtx.stroke(); + if(x>=0&&x40){for(let b=1;b<(midi.timeSigs[0]?.num||4);b++){const bx=x+b*midi.tpq*view.pxPerTick;rCtx.strokeStyle='#313244';rCtx.lineWidth=.5;rCtx.beginPath();rCtx.moveTo(bx+.5,H/2);rCtx.lineTo(bx+.5,H);rCtx.stroke();}} + } + const phx=tX(ph);if(phx>=0&&phx<=W){rCtx.fillStyle='#f38ba8';rCtx.fillRect(phx-1,0,2,H);} +} +function renderKeys(){ + if(!keysC)return; + const W=KEY_W,H=keysC.height; + kCtx.fillStyle='#1a1a2a';kCtx.fillRect(0,0,W,H); + for(let p=0;p<=127;p++){const y=pY(p);if(y+view.noteH<0||y>H)continue;const isB=BLACK_K.has(p%12),isC=p%12===0;kCtx.fillStyle=isB?'#2a2a3a':(isC?'#3a3a5a':'#e8e8f0');kCtx.fillRect(0,y,W-(isB?14:0),view.noteH);kCtx.strokeStyle='#45475a';kCtx.lineWidth=.5;kCtx.strokeRect(.5,y+.5,W-(isB?14:0)-1,view.noteH-1);if(!isB&&view.noteH>=7){kCtx.fillStyle=isC?'#89b4fa':'#6c7086';kCtx.font=`${Math.min(9,view.noteH-1)}px monospace`;kCtx.textAlign='right';kCtx.textBaseline='middle';kCtx.fillText(NOTE_NAMES[p%12]+(isC?Math.floor(p/12)-1:''),W-16,y+view.noteH/2);}} +} +function renderRoll(){ + if(!rollC)return; + const W=rollC.width,H=rollC.height; + pCtx.fillStyle='#1e1e2e';pCtx.fillRect(0,0,W,H); + const nh=view.noteH; + for(let p=127;p>=0;p--){const y=pY(p);if(y+nh<0||y>H)continue;const isB=BLACK_K.has(p%12),isC=p%12===0;pCtx.fillStyle=isB?'#191926':(isC?'#1e1e38':'#1e1e2e');pCtx.fillRect(0,y,W,nh);if(isC){pCtx.strokeStyle='#2e2e50';pCtx.lineWidth=.5;pCtx.beginPath();pCtx.moveTo(0,y+.5);pCtx.lineTo(W,y+.5);pCtx.stroke();}} + const tpb=midi.tpq*(midi.timeSigs[0]?.num||4); + const s0=Math.floor(view.scrollX/(tpb*view.pxPerTick)); + const s1=s0+Math.ceil(W/(tpb*view.pxPerTick))+2; + for(let bar=s0;bar<=s1;bar++){const x=bar*tpb*view.pxPerTick-view.scrollX;pCtx.strokeStyle='#313244';pCtx.lineWidth=1;pCtx.beginPath();pCtx.moveTo(x+.5,0);pCtx.lineTo(x+.5,H);pCtx.stroke();if(view.pxPerTick*midi.tpq>16){for(let b=1;b<(midi.timeSigs[0]?.num||4);b++){const bx=x+b*midi.tpq*view.pxPerTick;pCtx.strokeStyle='#252535';pCtx.lineWidth=.5;pCtx.beginPath();pCtx.moveTo(bx+.5,0);pCtx.lineTo(bx+.5,H);pCtx.stroke();}}} + if(view.pxPerTick*midi.tpq>60){const subT=midi.tpq/4;const subS=Math.floor(view.scrollX/(subT*view.pxPerTick));const subE=subS+Math.ceil(W/(subT*view.pxPerTick))+2;for(let i=subS;i<=subE;i++){if(i%(4*(midi.timeSigs[0]?.num||4))===0)continue;if(i%4===0)continue;const x=i*subT*view.pxPerTick-view.scrollX;pCtx.strokeStyle='#232333';pCtx.lineWidth=.5;pCtx.beginPath();pCtx.moveTo(x+.5,0);pCtx.lineTo(x+.5,H);pCtx.stroke();}} + const anySolo=midi.tracks.some(t=>t.solo); + for(let ti=0;tiW||y+nh<0||y>H)continue;const isSel=sel.has(n);pCtx.fillStyle=isSel?'#fff':trk.color;pCtx.fillRect(x,y+1,w,nh-2);if(isSel){pCtx.strokeStyle=trk.color;pCtx.lineWidth=1;pCtx.strokeRect(x+.5,y+1.5,w-1,nh-3);}else if(w>5){pCtx.fillStyle='rgba(0,0,0,.25)';pCtx.fillRect(x+w-3,y+1,3,nh-2);}}} + if(drag?.type==='box'){const{x0,y0,x1,y1}=drag;pCtx.strokeStyle='#89b4fa';pCtx.lineWidth=1;pCtx.setLineDash([4,2]);pCtx.strokeRect(Math.min(x0,x1),Math.min(y0,y1),Math.abs(x1-x0),Math.abs(y1-y0));pCtx.setLineDash([]);} + const phx=tX(ph);if(phx>=0&&phx<=W){pCtx.fillStyle='#f38ba8';pCtx.fillRect(phx-1,0,2,H);} +} +function renderVel(){ + if(!velC)return; + const W=velC.width,H=VEL_H; + vCtx.fillStyle='#16161e';vCtx.fillRect(0,0,W,H); + vCtx.strokeStyle='#252535';vCtx.lineWidth=.5; + [.25,.5,.75].forEach(f=>{vCtx.beginPath();vCtx.moveTo(0,H*f);vCtx.lineTo(W,H*f);vCtx.stroke();}); + const anySolo=midi.tracks.some(t=>t.solo); + for(const trk of midi.tracks){if(trk.muted||(anySolo&&!trk.solo))continue;for(const n of trk.notes){const x=tX(n.tick);if(x<-4||x>W+4)continue;const bH=Math.max(2,Math.round((n.vel/127)*(H-4)));vCtx.fillStyle=sel.has(n)?'#fff':trk.color+'bb';vCtx.fillRect(x-1,H-bH-2,3,bH);}} +} + +// Track Panel +function renderTracks(){ + const list=document.getElementById('track-list');if(!list)return; + list.innerHTML=''; + midi.tracks.forEach((trk,i)=>{ + const prog=trk.pcs?.[0]?.prog,progName=prog!==undefined?(trk.channel===9?'Drums':GM_NAMES[prog]||`Prog ${prog}`):''; + const div=document.createElement('div');div.className='ti'+(i===activeTi?' sel':'');div.onclick=()=>{activeTi=i;renderTracks();}; + div.innerHTML=`
${trk.name}
ch${trk.channel+1}
${progName?`
${progName}
`:''}`; + list.appendChild(div); + }); +} +function cycleColor(i){const idx=COLORS.indexOf(midi.tracks[i].color);midi.tracks[i].color=COLORS[(idx+1)%COLORS.length];renderAll();renderTracks();} +function toggleMute(i){pushH();midi.tracks[i].muted=!midi.tracks[i].muted;renderAll();renderTracks();sfReloadSong();} +function toggleSolo(i){pushH();midi.tracks[i].solo=!midi.tracks[i].solo;renderAll();renderTracks();sfReloadSong();} +function delTrack(i){pushH();midi.tracks.splice(i,1);if(activeTi>=midi.tracks.length)activeTi=Math.max(0,midi.tracks.length-1);renderAll();renderTracks();} +function setTrackProg(i,prog){pushH();if(!midi.tracks[i].pcs.length)midi.tracks[i].pcs.push({tick:0,prog});else midi.tracks[i].pcs[0].prog=prog;renderTracks();} +function doAddTrack(){pushH();const ch=midi.tracks.length%16;midi.tracks.push({name:`Track ${midi.tracks.length+1}`,channel:ch,notes:[],pcs:[{tick:0,prog:0}],muted:false,solo:false,color:COLORS[midi.tracks.length%COLORS.length]});activeTi=midi.tracks.length-1;renderAll();renderTracks();} + +// Tools +function setTool(t){tool=t;['sel','draw','erase'].forEach(n=>document.getElementById('t-'+n)?.classList.toggle('active',n===t));} +function setQ(v){qTicks=+v;} +function setBpm(v){pushH();const b=Math.max(20,Math.min(300,v||120));midi.tempos=[{tick:0,uspb:Math.round(60000000/b)}];const el=document.getElementById('bpm-in');if(el)el.value=b;} +function zoomX(f){const mid=view.scrollX+(rollC?.width||0)/2;view.pxPerTick=Math.max(.02,Math.min(4,view.pxPerTick*f));view.scrollX=Math.max(0,mid-(rollC?.width||0)/2);renderAll();} +function zoomY(f){view.noteH=Math.max(3,Math.min(24,Math.round(view.noteH*f)));renderAll();} + +// Hit Test +function hitNote(cx,cy){const tick=xT(cx),pitch=yP(cy);const anySolo=midi.tracks.some(t=>t.solo);for(let i=midi.tracks.length-1;i>=0;i--){const trk=midi.tracks[i];if(trk.muted||(anySolo&&!trk.solo))continue;for(const n of trk.notes)if(n.pitch===pitch&&tick>=n.tick&&tickt.solo);for(let i=midi.tracks.length-1;i>=0;i--){const trk=midi.tracks[i];if(trk.muted||(anySolo&&!trk.solo))continue;for(const n of trk.notes){if(n.pitch!==pitch)continue;if(Math.abs(xT(cx)-(n.tick+n.dur))<=RZ)return{n,trk,ti:i};}}return null;} + +// Mouse Events — bound lazily after canvas init +let mdown=false; +function meBindRollEvents(){ + rollC.addEventListener('contextmenu',e=>e.preventDefault()); + rollC.addEventListener('mousedown',e=>{ + if(e.button!==0&&e.button!==2)return;mdown=true;e.preventDefault(); + const cx=e.offsetX,cy=e.offsetY; + if(e.button===2||tool==='erase'){const h=hitNote(cx,cy);if(h){pushH();h.trk.notes.splice(h.trk.notes.indexOf(h.n),1);sel.delete(h.n);renderAll();renderVel();}drag={type:'erase'};return;} + if(tool==='draw'){const pitch=yP(cy);if(pitch<0||pitch>127)return;if(activeTi>=midi.tracks.length)doAddTrack();pushH();const tick=Math.max(0,qz(xT(cx)));const dur=qTicks>0?qTicks:midi.tpq/4;const n={tick,dur,pitch,vel:80};midi.tracks[activeTi].notes.push(n);sel=new Set([n]);drag={type:'draw',n,startTick:tick};renderAll();return;} + const rh=hitResize(cx,cy);if(rh){if(!sel.has(rh.n)){if(!e.shiftKey)sel.clear();sel.add(rh.n);}pushH();drag={type:'resize',n:rh.n,startX:cx,origDur:rh.n.dur,origTick:rh.n.tick};return;} + const h=hitNote(cx,cy);if(h){if(!e.shiftKey&&!sel.has(h.n))sel.clear();sel.add(h.n);pushH();drag={type:'move',startX:cx,startY:cy,origNotes:[...sel].map(n=>({n,tick:n.tick,pitch:n.pitch}))};renderAll();return;} + if(!e.shiftKey)sel.clear();drag={type:'box',x0:cx,y0:cy,x1:cx,y1:cy};renderAll(); + }); + rollC.addEventListener('mousemove',e=>{ + const cx=e.offsetX,cy=e.offsetY; + if(!mdown||!drag){if(tool==='sel'){const rh=hitResize(cx,cy);rollC.style.cursor=rh?'ew-resize':(hitNote(cx,cy)?'grab':'default');}else{rollC.style.cursor=tool==='draw'?'crosshair':'cell';}return;} + if(drag.type==='erase'){const h=hitNote(cx,cy);if(h){h.trk.notes.splice(h.trk.notes.indexOf(h.n),1);sel.delete(h.n);renderAll();renderVel();}return;} + if(drag.type==='draw'){const minDur=Math.max(1,qTicks>0?qTicks:1);const endTick=Math.max(drag.startTick+minDur,qz(xT(cx)));drag.n.dur=endTick-drag.startTick;renderAll();return;} + if(drag.type==='resize'){const minDur=Math.max(1,qTicks>0?qTicks:1);drag.n.dur=Math.max(minDur,qz(xT(cx))-drag.n.tick||minDur);renderAll();renderVel();return;} + if(drag.type==='move'){const dtick=qz(xT(cx))-qz(xT(drag.startX));const dpitch=yP(cy)-yP(drag.startY);for(const{n,tick:ot,pitch:op}of drag.origNotes){n.tick=Math.max(0,ot+dtick);n.pitch=Math.max(0,Math.min(127,op+dpitch));}renderAll();return;} + if(drag.type==='box'){drag.x1=cx;drag.y1=cy;renderRoll();return;} + }); + rollC.addEventListener('mouseup',e=>{ + mdown=false; + if(drag?.type==='box'){const{x0,y0,x1,y1}=drag;const t0=xT(Math.min(x0,x1)),t1=xT(Math.max(x0,x1));const p0=yP(Math.max(y0,y1)),p1=yP(Math.min(y0,y1));if(!e.shiftKey)sel.clear();for(const trk of midi.tracks)for(const n of trk.notes)if(n.tickt0&&n.pitch>=p0&&n.pitch<=p1)sel.add(n);renderAll();} + drag=null;renderVel(); + }); + rollC.addEventListener('wheel',e=>{ + e.preventDefault(); + if(e.ctrlKey){const f=e.deltaY<0?1.2:.8,bt=xT(e.offsetX);view.pxPerTick=Math.max(.02,Math.min(4,view.pxPerTick*f));view.scrollX=Math.max(0,bt*view.pxPerTick-e.offsetX);} + else if(e.shiftKey){view.scrollX=Math.max(0,view.scrollX+e.deltaY);} + else{view.scrollY=Math.max(0,Math.min(127*view.noteH-rollC.height*.5,view.scrollY+e.deltaY));} + renderAll(); + },{passive:false}); + rulerC.addEventListener('click',e=>{ph=Math.max(0,qz(xT(e.offsetX)));renderRuler();renderRoll();updPos();}); + velC.addEventListener('mousedown',e=>{velDrag={startY:e.offsetY};e.preventDefault();}); + velC.addEventListener('mousemove',e=>{ + if(!velDrag)return;const x=e.offsetX;const anySolo=midi.tracks.some(t=>t.solo); + for(const trk of midi.tracks){if(trk.muted||(anySolo&&!trk.solo))continue;for(const n of trk.notes){if(Math.abs(tX(n.tick)-x)<4)n.vel=Math.max(1,Math.min(127,Math.round((1-(e.offsetY/(VEL_H-4)))*127)));}} + renderVel(); + }); + velC.addEventListener('mouseup',()=>{if(velDrag){pushH();velDrag=null;}}); + keysC.addEventListener('click',e=>{ + const pitch=yP(e.offsetY);if(pitch<0||pitch>127)return; + const ch=midi.tracks[activeTi]?.channel??0;const prog=midi.tracks[activeTi]?.pcs?.[0]?.prog??0; + if(sfMode){sfPreviewNote(ch,prog,pitch,80);return;} + if(!meMidiOut)return; + try{meMidiOut.send(new Uint8Array([0x90|ch,pitch,80]));}catch{} + setTimeout(()=>{try{meMidiOut.send(new Uint8Array([0x80|ch,pitch,0]));}catch{}},300); + }); +} + +// Undo/Redo +function pushH(){const snap=JSON.stringify(midi);hist=hist.slice(0,histIdx+1);hist.push(snap);if(hist.length>60)hist.shift();histIdx=hist.length-1;} +function doUndo(){if(histIdx<=0)return;histIdx--;midi=JSON.parse(hist[histIdx]);sel.clear();const el=document.getElementById('bpm-in');if(el)el.value=bpm();renderAll();renderTracks();} +function doRedo(){if(histIdx>=hist.length-1)return;histIdx++;midi=JSON.parse(hist[histIdx]);sel.clear();const el=document.getElementById('bpm-in');if(el)el.value=bpm();renderAll();renderTracks();} + +// File I/O +function openFileDialog(){document.getElementById('file-in')?.click();} +async function handleOpen(inp){ + const f=inp.files[0];if(!f)return; + try{const buf=await f.arrayBuffer();midi=parseMidi(buf);sel.clear();hist=[];histIdx=-1;pushH();const el=document.getElementById('bpm-in');if(el)el.value=bpm();view.scrollX=0;view.scrollY=57*view.noteH;meZoomToFitBars(8);renderAll();renderTracks();meStatus('로드: '+f.name);}catch(e){alert('읽기 실패: '+e.message);} + inp.value=''; +} +async function meLoadUrl(url){ + try{ + const r=await fetch(url,{headers:authH()});if(!r.ok)throw new Error(r.status); + const buf=await r.arrayBuffer(); + midi=parseMidi(buf);sel.clear();hist=[];histIdx=-1;pushH(); + const el=document.getElementById('bpm-in');if(el)el.value=bpm(); + view.scrollX=0;view.scrollY=57*view.noteH;meZoomToFitBars(8); + renderAll();renderTracks();meStatus('로드 완료'); + }catch(e){meStatus('로드 실패: '+e.message);} +} +function doSave(){ + const buf=writeMidi(midi); + const a=document.createElement('a'); + a.href=URL.createObjectURL(new Blob([buf],{type:'audio/midi'})); + const titleEl=document.getElementById('editor-title'); + a.download=((titleEl?.textContent||'').replace('🎹 ','').replace(/\.midi?$/,'')||'edited')+'.mid'; + document.body.appendChild(a);a.click();setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove();},1000); + meStatus('저장 완료'); +} + +// MIDI Output +async function meInitMidiOut(){ + if(!navigator.requestMIDIAccess)return; + try{meMidiAccess=await navigator.requestMIDIAccess();meMidiAccess.onstatechange=()=>meRefreshMidiOuts();meRefreshMidiOuts();}catch{} +} +function meRefreshMidiOuts(){ + if(!meMidiAccess){meInitMidiOut();return;} + const sel=document.getElementById('midi-out-sel');if(!sel)return; + const cur=sel.value; + sel.innerHTML=''; + meMidiAccess.outputs.forEach((out,id)=>{const o=document.createElement('option');o.value=id;o.textContent=out.name;if(id===cur)o.selected=true;sel.appendChild(o);}); + if(cur&&cur!=='__sf2')meMidiOut=meMidiAccess.outputs.get(cur)||null; + meApplyOutSel(cur); +} +function meApplyOutSel(val){if(val==='__sf2'){sfMode=true;meMidiOut=null;}else{sfMode=false;meMidiOut=meMidiAccess?.outputs.get(val)||null;}} +document.addEventListener('change',e=>{if(e.target.id==='midi-out-sel')meApplyOutSel(e.target.value);}); +document.addEventListener('focus',e=>{if(e.target.id==='midi-out-sel'&&!meMidiAccess)meInitMidiOut();},true); + +// SpessaSynth — shared with music-app +function _tickToSec(tick){const tpq=midi.tpq||480;let sec=0,lastTick=0,uspb=(midi.tempos[0]?.uspb||500000);for(const t of(midi.tempos||[])){if(t.tick>=tick)break;sec+=(Math.min(t.tick,tick)-lastTick)/tpq*(uspb/1e6);lastTick=t.tick;uspb=t.uspb;}sec+=(tick-lastTick)/tpq*(uspb/1e6);return sec;} +function _secToTick(sec){const tpq=midi.tpq||480;let rem=sec,tick=0,uspb=(midi.tempos[0]?.uspb||500000);for(let i=1;i<(midi.tempos||[]).length;i++){const seg=(midi.tempos[i].tick-(midi.tempos[i-1]?.tick||0))/tpq*(uspb/1e6);if(rem<=seg)break;rem-=seg;tick=midi.tempos[i].tick;uspb=midi.tempos[i].uspb;}return tick+rem*tpq/(uspb/1e6);} + +function buildSpessaMidi(){ + const{BasicMIDI}=window.SpessaSynth; + const anySolo=midi.tracks.some(t=>t.solo); + // Use the editor's own writeMidi on a filtered copy — avoids MIDIBuilder API ambiguity + const filtered={...midi,tracks:midi.tracks.filter(t=>!t.muted&&!(anySolo&&!t.solo))}; + return BasicMIDI.fromArrayBuffer(writeMidi(filtered)); +} + +function sfReloadSong(){ + if(!_spessaSeq||!playing||!sfMode)return; + const t=_spessaSeq.currentTime; + _spessaSeq.loadNewSongList([buildSpessaMidi()]); + _spessaSeq.currentTime=t; + _spessaSeq.play(); +} + +async function sfPlay(){ + if(!midi.tracks.length){meStatus('재생할 트랙이 없습니다');return;} + await _ensureSpessa(); + if(_spessaCtx?.state==='suspended')await _spessaCtx.resume(); + const tpb=midi.tpq*(midi.timeSigs[0]?.num||4); + _sfLoopEn=document.getElementById('loop-en')?.checked||false; + _sfLoopIn=(+(document.getElementById('loop-in')?.value||1)-1)*tpb; + _sfLoopOut=(+(document.getElementById('loop-out')?.value||5))*tpb; + const midiData=buildSpessaMidi(); + _spessaSeq.loadNewSongList([midiData]); + _spessaSeq.currentTime=_tickToSec(ph); + if(_sfLoopEn)_spessaSeq.loopCount=-1;else _spessaSeq.loopCount=0; + _spessaSeq.eventHandler.addEvent('songEnded','editor',()=>{playing=false;const btn=document.getElementById('play-btn');if(btn)btn.textContent='▶';if(animId){cancelAnimationFrame(animId);animId=null;}}); + _spessaSeq.play(); + playing=true; + const btn=document.getElementById('play-btn');if(btn)btn.textContent='⏸'; + if(animId)cancelAnimationFrame(animId); + animId=requestAnimationFrame(sfLoop); +} +function sfLoop(){ + if(!playing||!_spessaSeq)return; + const sec=_spessaSeq.currentTime; + ph=Math.round(_secToTick(sec)); + if(_sfLoopEn&&ph>=_sfLoopOut)_spessaSeq.currentTime=_tickToSec(_sfLoopIn); + updPos(); + const phx=tX(ph); + if(rollC&&phx>rollC.width*.8)view.scrollX+=rollC.width*.4; + else if(phx<20&&view.scrollX>0)view.scrollX=Math.max(0,view.scrollX-rollC.width*.4); + renderRuler();renderRoll(); + animId=requestAnimationFrame(sfLoop); +} +function sfPreviewNote(ch,prog,pitch,vel){ + if(!_spessaSynth){return sfOscPreview(ch,pitch);} + try{if(_spessaCtx?.state==='suspended')_spessaCtx.resume();_spessaSynth.programChange(ch,prog);_spessaSynth.noteOn(ch,pitch,vel);setTimeout(()=>{try{_spessaSynth.noteOff(ch,pitch,0);}catch{}},400);}catch(e){sfOscPreview(ch,pitch);} +} +function sfOscPreview(ch,pitch){ + try{const AC=window.AudioContext||window.webkitAudioContext,ctx=new AC();const o=ctx.createOscillator(),g=ctx.createGain();o.type=(ch===9)?'square':'triangle';o.frequency.value=440*Math.pow(2,(pitch-69)/12);const t=ctx.currentTime;g.gain.setValueAtTime(0.0001,t);g.gain.exponentialRampToValueAtTime(0.25,t+0.01);g.gain.exponentialRampToValueAtTime(0.0001,t+0.4);o.connect(g).connect(ctx.destination);o.start(t);o.stop(t+0.42);setTimeout(()=>ctx.close(),700);}catch{} +} + +function doTogglePlay(){playing?doStop():doPlay();} +function doRewind(){doStop();ph=0;renderRuler();renderRoll();updPos();} +function doStop(){ + playing=false; + const btn=document.getElementById('play-btn');if(btn)btn.textContent='▶'; + if(animId){cancelAnimationFrame(animId);animId=null;} + if(sfMode&&_spessaSeq){try{_spessaSeq.pause();}catch{}} + if(meMidiOut){try{meMidiOut.clear();}catch{}for(let c=0;c<16;c++)try{meMidiOut.send(new Uint8Array([0xB0|c,123,0]));}catch{}} +} +function scheduleChunk(mspt,loopEn,loopOut){ + const now=performance.now();const schedUntilTick=playTick0+(now-playT0+SCHED_AHEAD_MS)/mspt;const anySolo=midi.tracks.some(t=>t.solo); + for(const trk of midi.tracks){if(trk.muted||(anySolo&&!trk.solo))continue;for(const n of trk.notes){if(n.tick=schedUntilTick)continue;if(loopEn&&n.tick>=loopOut)continue;const on=playT0+(n.tick-playTick0)*mspt;const off=playT0+(n.tick+n.dur-playTick0)*mspt;try{meMidiOut.send(new Uint8Array([0x90|trk.channel,n.pitch,n.vel]),on);}catch{}try{meMidiOut.send(new Uint8Array([0x80|trk.channel,n.pitch,0]),off);}catch{}}} + nextSchedTick=schedUntilTick; +} +function doPlay(){ + if(sfMode){sfPlay();return;} + if(!meMidiOut){alert('MIDI 출력 기기를 선택하세요.');return;} + playing=true;const btn=document.getElementById('play-btn');if(btn)btn.textContent='⏸'; + playTick0=ph;playT0=performance.now();nextSchedTick=ph; + const mspt=mspT();const loopEn=document.getElementById('loop-en')?.checked||false; + const loopIn=(+(document.getElementById('loop-in')?.value||1)-1)*midi.tpq*(midi.timeSigs[0]?.num||4); + const loopOut=(+(document.getElementById('loop-out')?.value||5))*midi.tpq*(midi.timeSigs[0]?.num||4); + try{meMidiOut.send(new Uint8Array([0xF0,0x7E,0x7F,0x09,0x01,0xF7]));}catch{} + for(const trk of midi.tracks){const c=trk.channel;if(trk.pcs?.length){try{meMidiOut.send(new Uint8Array([0xB0|c,0,0]));}catch{}try{meMidiOut.send(new Uint8Array([0xB0|c,32,0]));}catch{}try{meMidiOut.send(new Uint8Array([0xC0|c,trk.pcs[0].prog]));}catch{}}for(const cc of(trk.ccs||[])){if(cc.tick===0)try{meMidiOut.send(new Uint8Array([0xB0|c,cc.cc,cc.val]));}catch{}}} + const maxTick=Math.max(0,...midi.tracks.flatMap(t=>t.notes.map(n=>n.tick+n.dur))); + function loop(){if(!playing)return;const now=performance.now();ph=playTick0+(now-playT0)/mspt;if(loopEn&&ph>=loopOut){doStop();ph=loopIn;doPlay();return;}if(ph>=maxTick){doStop();return;}scheduleChunk(mspt,loopEn,loopOut);updPos();const phx=tX(ph);if(rollC&&phx>rollC.width*.8)view.scrollX+=rollC.width*.4;else if(phx<20&&view.scrollX>0)view.scrollX=Math.max(0,view.scrollX-rollC.width*.4);renderRuler();renderRoll();animId=requestAnimationFrame(loop);} + animId=requestAnimationFrame(loop); +} + +// Keyboard shortcuts (only active when editor stage is visible) +document.addEventListener('keydown',e=>{ + if(!document.getElementById('stage-editor')?.classList.contains('visible'))return; + if(e.target.tagName==='INPUT'||e.target.tagName==='SELECT')return; + const ctrl=e.ctrlKey||e.metaKey; + if(ctrl&&e.key==='z'){e.preventDefault();doUndo();return;} + if(ctrl&&(e.key==='y'||(e.shiftKey&&e.key==='Z'))){e.preventDefault();doRedo();return;} + if(ctrl&&e.key==='a'){e.preventDefault();sel=new Set(midi.tracks.flatMap(t=>t.notes));renderAll();return;} + if(ctrl&&e.key==='c'){clip=[...sel].map(n=>({...n}));meStatus(`${clip.length}개 복사`);return;} + if(ctrl&&e.key==='d'){e.preventDefault();sel=new Set([...sel].map(n=>({...n})));if(activeTin.tick));const ns=clip.map(n=>({...n,tick:n.tick-minT+ph}));if(activeTi!sel.has(n));sel.clear();renderAll();renderVel();return;} + if(e.key==='v'||e.key==='V')setTool('sel'); + if(e.key==='p'||e.key==='P')setTool('draw'); + if(e.key==='e'||e.key==='E')setTool('erase'); + if(e.key===' '){e.preventDefault();doTogglePlay();} + if(e.key==='Home')doRewind(); + if(e.key==='ArrowLeft'||e.key==='ArrowRight'||e.key==='ArrowUp'||e.key==='ArrowDown'){if(!sel.size)return;e.preventDefault();pushH();const dt=e.key==='ArrowLeft'?-(qTicks||midi.tpq/4):e.key==='ArrowRight'?(qTicks||midi.tpq/4):0;const dp=e.key==='ArrowUp'?1:e.key==='ArrowDown'?-1:0;for(const n of sel){n.tick=Math.max(0,n.tick+dt);n.pitch=Math.max(0,Math.min(127,n.pitch+dp));}renderAll();} +}); + +// Utilities +function updPos(){const tpb=midi.tpq*(midi.timeSigs[0]?.num||4);const bar=Math.floor(ph/tpb)+1,beat=Math.floor((ph%tpb)/midi.tpq)+1,tick=Math.floor(ph%midi.tpq);const el=document.getElementById('pos-disp');if(el)el.textContent=`${bar}:${beat}:${String(tick).padStart(3,'0')}`;} +function meStatus(msg){const el=document.getElementById('status-txt');if(!el)return;el.textContent=msg;setTimeout(()=>{if(el.textContent===msg)el.textContent='';},3000);} + // ── Init ────────────────────────────────────────────────────────────────────── (async()=>{ + patchAudioForGpFx(); // install FX chain + destination redirect before any AudioContext is created const ok = await checkAuth(); if (!ok) return; - await Promise.all([loadSkill(), loadModels(), loadMidiFiles()]); - addChatMsg('system', '🎵 음악 스튜디오에 오신 것을 환영합니다!'); - document.getElementById('chat-input').focus(); + await Promise.all([loadSkill(), loadModels(), loadMidiFiles(), loadGpFileList()]); + // 외부 진입: ?edit= (또는 호환용 ?url=) 로 에디터 스테이지 자동 오픈 + const _params = new URLSearchParams(location.search); + const _editUrl = _params.get('edit') || _params.get('url'); + if (_editUrl) { + const _name = decodeURIComponent(_editUrl.split('/').pop().replace(/\.midi?$/i, '')) || 'MIDI'; + openEditor(_editUrl, _name); + } else { + addChatMsg('system', '🎵 음악 스튜디오에 오신 것을 환영합니다!'); + document.getElementById('chat-input').focus(); + } })(); diff --git a/web-ui/styles.css b/web-ui/styles.css index 3faf454..c4111b4 100644 --- a/web-ui/styles.css +++ b/web-ui/styles.css @@ -201,6 +201,8 @@ grid-area: sidebar; display: flex; flex-direction: column; + position: relative; + z-index: 10; } #sidebar-tabs { @@ -1870,7 +1872,7 @@ body { padding: 0; gap: 0; - grid-template-rows: 52px 1fr 56px; + grid-template-rows: auto 1fr 56px; grid-template-columns: 1fr; grid-template-areas: "header" @@ -1882,6 +1884,34 @@ border-left: none; border-right: none; border-top: none; + flex-wrap: wrap; + padding: 8px 12px 0; + gap: 6px; + height: auto; + } + /* 앱 버튼 스트립 — 두 번째 행, 가로 스크롤 */ + header .mode-toggle { + flex: 0 0 100%; + order: 10; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + border: none; + background: transparent; + border-radius: 0; + padding: 4px 0 8px; + margin-left: 0 !important; + gap: 6px; + /* 오른쪽 페이드 힌트 */ + -webkit-mask-image: linear-gradient(to right, black 80%, transparent 100%); + mask-image: linear-gradient(to right, black 80%, transparent 100%); + } + header .mode-toggle::-webkit-scrollbar { display: none; } + header .mode-toggle .mode-btn { + flex-shrink: 0; + white-space: nowrap; + padding: 6px 12px; + font-size: 12px; } aside { display: none; } .status-bar { display: none; }