From d92bcf7716d672e2f3272d2229f77eea9715f351 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:52:20 +0700 Subject: [PATCH] feat(player): add AAudio hi-res, WavPack DSD and DAC volume --- android/app/build.gradle.kts | 10 + android/app/src/androidTest/assets/README.md | 10 + .../app/src/androidTest/assets/dsd-pattern.wv | Bin 0 -> 12966 bytes .../com/zarz/spotiflac/HiResPlaybackTest.kt | 122 +++++ android/app/src/main/cpp/CMakeLists.txt | 27 + android/app/src/main/cpp/native_audio.cpp | 281 +++++++++++ .../main/kotlin/com/zarz/spotiflac/DsdFile.kt | 14 +- .../kotlin/com/zarz/spotiflac/DsdSource.kt | 89 ++++ .../com/zarz/spotiflac/HiResPlayback.kt | 186 +++++++ .../com/zarz/spotiflac/MainActivitySafIo.kt | 4 +- .../kotlin/com/zarz/spotiflac/NativeAudio.kt | 21 + .../com/zarz/spotiflac/UsbBitPerfectPlugin.kt | 20 +- .../com/zarz/spotiflac/UsbDirectPlayback.kt | 42 +- assets/licenses/oboe.txt | 202 ++++++++ assets/licenses/wavpack.txt | 25 + lib/l10n/arb/app_en.arb | 19 +- lib/l10n/arb/app_id.arb | 19 +- lib/main.dart | 6 + lib/models/settings.dart | 8 + lib/models/settings.g.dart | 4 + lib/providers/settings_provider.dart | 18 +- lib/screens/main_shell.dart | 44 +- .../settings/library_settings_page.dart | 121 ++++- lib/services/music_playback_deck.dart | 68 ++- lib/services/music_player_service.dart | 16 +- lib/widgets/mornye_volume_control.dart | 30 +- pubspec.yaml | 2 + .../crates/mobile/src/usb_audio/README.md | 72 ++- .../crates/mobile/src/usb_audio/mod.rs | 30 ++ .../crates/mobile/src/usb_audio/transport.rs | 90 +++- .../crates/mobile/src/usb_audio/volume.rs | 460 ++++++++++++++++++ test/music_player_automix_test.dart | 113 ++++- 32 files changed, 2118 insertions(+), 55 deletions(-) create mode 100644 android/app/src/androidTest/assets/README.md create mode 100644 android/app/src/androidTest/assets/dsd-pattern.wv create mode 100644 android/app/src/androidTest/kotlin/com/zarz/spotiflac/HiResPlaybackTest.kt create mode 100644 android/app/src/main/cpp/CMakeLists.txt create mode 100644 android/app/src/main/cpp/native_audio.cpp create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/DsdSource.kt create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/HiResPlayback.kt create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/NativeAudio.kt create mode 100644 assets/licenses/oboe.txt create mode 100644 assets/licenses/wavpack.txt create mode 100644 rust_backend/crates/mobile/src/usb_audio/volume.rs diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e08d113d..36146a90 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -36,6 +36,13 @@ android { buildConfig = true } + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } + sourceSets.getByName("main") { java.srcDir("src/rust/kotlin") java.srcDir(rustBackendDir.resolve("target/bindings/kotlin")) @@ -76,6 +83,9 @@ android { versionCode = flutter.versionCode versionName = flutter.versionName multiDexEnabled = true + externalNativeBuild { + cmake { arguments += "-DANDROID_STL=c++_static" } + } ndk { abiFilters.clear() diff --git a/android/app/src/androidTest/assets/README.md b/android/app/src/androidTest/assets/README.md new file mode 100644 index 00000000..923ac327 --- /dev/null +++ b/android/app/src/androidTest/assets/README.md @@ -0,0 +1,10 @@ +# Synthetic DSD fixture + +`dsd-pattern.wv` is original test data, not a music recording. WavPack 5.9.0 +encoded a stereo DSD64 DSF with 16,384 bytes per channel and 4,096-byte channel +blocks. Normalized MSB-first byte `i` of channel `c` is `(i * 17 + c * 29) & 255`. +The DSF input stores those bytes with reversed bits (LSB-first). + +This tests lossless WavPack DSD decoding, channel order, seek alignment, native +LE/BE packing and DoP packing against known bytes. It is packaged only in the +instrumentation test APK. diff --git a/android/app/src/androidTest/assets/dsd-pattern.wv b/android/app/src/androidTest/assets/dsd-pattern.wv new file mode 100644 index 0000000000000000000000000000000000000000..e3f461b1cb9775c98d3477490d65ef5e7b586c2d GIT binary patch literal 12966 zcmd6NQ;aCgvhLWnZQHhO+qP|+YwVddwr$(CZJmGbeR6Z&Zf@?&sZMn<>FRXSm3-aq zZVuMDOaK4~AOHXW0{`&8NZ=pFz)Ab{SLv|;GI2HoAf^*h79qg-SL`3s3HZ18Ka-iQ z3jxc2TY f&P*IVgN*HfdBaZZT+**e-ca#T?}CY{-c}zJ46KtqA&xF2>=8Lnh#hP zPzBf+(EC3|-~Z76<^NxA`~T1C|L^twKc@e$-~RvSan%912@{Lq};H@v}_BuC8ccL>YrFDWApHXdsiCu`TIwUHODRQ$p6?d78L0BE94a$<_ zut_sKL;1!q@v}OBqgtD{%LP2vDQqE8=gFA3(eG3n0Oi|5k#Eha5qVlEoTWKi!*{4J z3|bGU&$I!dO97wd2;w|cl>%y5D|l!TfCzj$5HQJJ1W?uZtJv6Vs7gR!&RhIQw4jo0 zpE$_ccT!Z*5CmWZVkaFE{P0QaEe%1lVyW%~4#alICx|7h%-#M3<(Y>yuhUToq3|T0 zcjmKBFYPC*u!>R+cUnZ$mk<37t`97sSp7qXh@#5Gr+`8ih|nM|Eorx^7~tvyDVMo_ zVYxE4fj>XEvGr6x_&_u`FvE2yLwH^<27>L*vlyEgzVwE9i)j;2I(5pEf&?C7=7!NS zA8frkM-o`CY#qnKn^INoTYa36b?(1ZWIG-6>Z|Z1R($7pxHX-y8k48A;rHM*9FPjn z-@*7on?K%1~dfv&OS7k&b9FGiR)#8l>~bE+&|x>83>wpFSy*N1$aq-L<)m zVq93TJT`t(osdHG>4id$q?Pt37To#-NFj0_!#?dzlI#I@F%eSqct`t2j43D}v0kB? zxHuJE+>bK~;LFaC571ZL_0$Huyu;od{vFHeSBI9&^oNKa=gMJ~qLyV7V%D*iyxw4; zaqE*32zu^85j5r?-lf&^rpU6xbx7DOmS}+&M5;Wdd`_K3UX|dr{@l5NT&IlB)4~GQ zzowAXFt6*cl=wlg>efU|jq+s`|L)MC9OeAP;*AQRY9!XKJNY;_RwPj;6TXww*N!+( z!+ZL5>|yU$cctMh$@KEg5>3=+lO$8e=dUCLBE(3-^s2&T=aHmLj3|2O4P}@|3;d>F zo$TGwu<+Y3N&~D!pKL=}v)>|W4w~xr=F~B$0!~+z0Ns#{sR!IW(We#yWC5<0s{B6X z;m8VJPm;^#b#6BozaBzuIJ_sd6U@*|YHh=zlFWRfEw!8$qXPldlak0P*5HpHP)|Ak zjXy_+qSyNA`dH~as4y3#%P zm*?{$Q`GXw@^MlN>5Q@V8Mn(LVjus_CpsF<0S#zgGZISVDlH){OvQ%9*CD0L|!AG<`*>80DsS z)3GjFa$49}IS#oB9u9D}yH8aClF_9jC&wU)#vYzAKyn=?3>KFZ9m@m7isTUlft;oD zdL(mnVHrVnyWr1r+tNngQ3D@gyvC$tRJa2@Po?;TF%!Q}m)v_jFn4ZQRclRwPcjj- z8rhiN?J&@}E3GoG{C9hKdq_92v!s6{LR3tL|4Djpk6&;@G$*5-xNG8|lm;r)d~^(3ZD_BZWfyDO9z*F|sHl;aMCqk;RWr^3~q z0Va*(K>+6-Mpn|yda z*0Kg@M-yNS)8(pyD%2xn{PUkMdrT-bgepiFzEI=O6penSjD+UaeZ)WGgAA3_Kh1aP zMB=iR6^GCL_BUrhFkZ&EpCAp%A4&YANF0^iDfCdeOx3%WOj zs2i=MG;Xb|HSNbD_g4e@rfwh=25&*WVotzRU8Z@I*nmenmy!5?=1pH&QTe2t{6^?H z$0=NJK!tgrz7-`4-y4F_xqYOO^zqHdibEm`C{h)OwfFt!Rz|4&y2_p-t?df1&@hm< zc^cx$>S3A()K)MtIPnY(^YH%CvQz}N)Yc8EMVMd_L{4OT+Ai@sHGNn8xr$L`;hhAo zLoceBoE!|PbBVMf0!JAynCJp&!~h{O#7$S}YZD*6pF9#Dn_4G{x8LmNYDc+ekTH1# zCRu7JEh-t~Q;V@dvbWsw9-q)w$f@72u}S4(c)v#N_&E&xG(eoo{`m$E44AS{HIZO8 zuDFXK{G&ddjs`%(Zt2rdG4GE)UvlejVHB4fC_W2RJ+ujw4gopk2=7b*XeW{+r%?|8txFg~8id8=3|k{NWGi1^k|_?$*=yRTj0QN~&@RHh)~ zS8}+)o5!11X?vfW#?L#v7TmtzF2z3Fz?XTz z$vtkyE^d0|D^VHt?I?7(NLRMlVoY|5Mf+(jn7>9qqi&9EC+0)CKuHuR67^yudJ^iR zFZTJF95V{dpKej}VR02{W1N|EfJDePP25@}p|bN~Hu%)Pk2rop-_yjk0WOle4iWjJ zGS_fD1k?Lre;c?PtC{lQQ{397I&jo(F9M&q=dNzQcgjM6O?v^*C(T!ToUW7O{QM)z z*V)b$$yjNi39`{65|4UA9!Bm6BYcQnKA*}DZenUAOKCR^BsQYLFAc0Q_` z&?-Uhzw!#TbVf)m7ekst7uER0h(-!V00ido8v6(>_tnG$UO59LkD) zMHUVTD_mlmvuj>RC1H{(D(I6eT?7gbj!7~#@jg5)xIKpg&Z;Bwsx8jBs)`!m{3=kc zM-JC6XR3q${HX&@Z{VPZy?hz>hUYlZY0=~=SOQlBMLPYwEkAFM(UrVvzkQslHsti% zm_w6Ss?ZTaR?`~+Yudn7%-UEtQRBFqXm-R&2x1_c5`i=^2O_9WYGC!BmG4$+Lvb_r;?!FP&ePN#T4EXE-96s)h(msEx7@0 z8g3dxPox4XfpYaPwH}$*QLTuo<-TtUlRi{?^Yd8&ye>kaJ_c1zvM+2zmKnod#|5?MKL!dO8E@I zm@pRl`yP~=hStbQ3O4`T7h8oRE;s$N$Ta&b@*Ah1Zg1l-9vy2-=}&31yrtyr)1~{{ zDG~biB9Aj{&|>E=wK%73p;E-&T1;T?6Dwunm56CWFIKokEVFvI8?wDvJo+qBp@ zN`iubyXcCPk?gI=eF(HhnG+&K%4uG8)SMA&T%)HNm=#w_F2P$6`3@bv{dPPS5+7Owqj)8DriFCF!@zwyE|EK!|1w^Yn<^FNpXhPdp`3kr#OUeC1I+ zu(?t^SBf-eyHu3lx)~6-#mps)6|I`n7a?7%f-0y5FvRZqoiGEndj@`chChQBKech# zNtm-HrSD`8`eD;zz@GhR7M_D<0;&$hh{DLj2|gF5GO<-oLFzhBWm`DPE?YxBa(XD0 zs66GhV)v^APCHaLFQQ8Mn^LsdNXwMIS(#b%Ptmk;0Nq1es6qVl)fw;!TUy%V97vbb z=!C@7ayZ>1TmeF-e?|(`)ntwSJZkD;2sp|MKn<$U$UZ=tSh|&d^g4c0OU=GpkEca9 zRRdRGLEn-PIzRJLSdj3!^ZAaxwgSYRH^@r#7rbwt1-2NESaUlPBf=}3Ox*lY2^L4- z47;ApCW~OaCgYI0 zwbz?mtT#BtkJfMJeA36(O$$0*6@gOFIkK>*PmDjab{1@9L&N!1?+J*QamELZCsyys zJa(VJ?^d_*XZMb-WcXzOk9f!3x*nt>MRq7e_nw-!RX%WtMU|=d{}4(4idExr9ONW; zuiH0f{!6NbYW~tnzpMBVm*me|bTof+T($<)PjoB7i9DoN{)hs? zUi3H50Tl2xDu(?h4tr#Ve1gGu;VNh8%Y71uE7nK|t)d$Po~+Y*XjH6F_s^MkgqlPQ z!ntO&#*R@h)^x?5_)rH^>3j&$)a0qqJP7-?L501{CuMZ` zo5;h6KOpqFKtCy9dn8=OsPiI8Q$ehb^XS{RqG`GaSoEUCNtRgn;GH*Hfzn^WCxy!@ z5R$vvY~yoZkF+v|@ljjUeLxjhM|uFFmhn35IOsFiVXpXziKcw5Rb|wxZHNb*m{dRQ zRY*T~EY!jpyZq(Iqa+@LH3~eUw)G0zoo^d>5PJ6P1I-3Rh?mrS^f|?WWNC;YFHRn5 zbs+I&8vMinuBZs~HrG-!LX6Ug9b2LsufxnABG(Y_WGoIt3;l>xBipAM{mPiB8d2E{ zfo9JR?|sn2bi6V;l{zSwdNA)}$FY30y^A&MgwGXH!Qu@vjYO4a5M2y&Ph>~|gsvIN zoevMDx@=fJHz;L@Z%jv4{cGP8g!qX!1@qmpS9}XeGV9Ar&z4bwrsEHi@QIUy;?&Mk` zrsdEGKSw7I>_wq`*bWwImcnVN1;>n|N|25hsw6 z##CQF4kmPb@Dwy*-Ls>p!peRq>u8*H!)I{5RMn8&{X26x`mKrGjV0YB4?KP+m+^4` zv)*XOQc>y5E|k<&yYdIV^t%RcnWz18T0#VXPrJ_;QVX7&podg>l*4jG#C9LI{x|Xn z=@enI0nc9i)t-3$1}$Z<%sLD^hmSz=@fKc(%!}0s`G^_^Js~^luhzgih;}i4?G0cS z4&u5}tf5bFQ*c~yh%ECA7;;>KbtVh2>N zWfbJJ#lIpGn#tNxsZz9Tf2!eZ1u$R}Ym*7o9t^62=%skFSle%k(*iTq^T(s?yyq4e zheU0!tL1Ebva5dn`USA7d$a=*_)=F!j1r2<*I5dplAUxkw7}8c7aHiKjU2aIb47_+ zeRY6&9veiU+ot$x^4$1ou+^&U{GDhjC0Mmro@r)bBSpQL)S^&CLG1Wx_4J}3TfhN# zpPJv!DgYG~3jld>x9*qyZtTwi%S=W8G zXOYjzlgd_%lFTn$PtvI|IdoN&oJ7ZB(&FfL3~x#7MVSYy{cPb*Vp=RpsDd@8+Y6WM zMq?=g`D)a#S3=t!hjNRssznw_gs)l#TPktnt<4@4qj=n0(Wl>+Ed>TZ4*o*g+2ty* zB{kAZNWviVGwF`G4LkcgrMM352>yvhkJ*2%TMAW$2FP0O-sygz`PWWfoY>nat1inUTBC!pVgeG66lPM1&UQgjK^Hrux{b zm(|?acI-~^GVkMvTUq2xCE`>+x#vHiNl>MT*$n+3r4Fq2CM_?|L&<_+chOz>tSC52 z>7%&{(!RJ*S)|n3_HS|?$DKuF^-^i6su+$ys8aem`9qkp!axqpz0ZRQA|>fN&nhsy zOSP#H7dT*)Z+*3V>`>;4De(o4UAeNV{ao&(of16Q;o?F{Up@$k4h1)rIV9Q_!7`J#vK2OSH^N zTFX>(PIMg$HteQ1oPua_(9QVJ!aX!8I)<+l^A?z*Tr21dX0HohpIg1u!<~Y05R7cvuwzL(#m2Np`sEXkST_ zweV|zCn$yjRAc;yE#5N)HKG=h=w=o+p^7>{v`H}_2jO6Bm}In{iUX`%{u|Z2z`cpgxI>qb9b)7C$|NcZt>@DzSc(3*h&y` zWLcZSEcQZs3=336?@lZk`nC9bIqw>+0*!)*uYnZC)mJf7#@Q#cQ4rx2DTM3qrp_l0 zdL;?1&JDTc)|03;r1os>G$8IvPdVBTh7O(?d*TV!_42QZwe%rmijt}EwzN4C`jSchjkU){ zx^xNC>~*IIV5NlwHe`jCM5ZKKz=bF%sf|7Lq+8tyom>WtmYp*Piu%aKX0i~MS(tW} zkR{Hg_;VGLI9pc`l$ReBY#$^YokbCm;3Il!{6sebN-DQjm9Pg{5|ofX0HhA3J+3!KrtdBZ;I@VRjQSu4(TX zR&g9>1D?f}1;VY4B3aiVM%}Dph72I5^|S^`(VJo~$(Bx1MSVlRc2)EpLsQwHky8-g zEQxOj5y=#%!^(rI1GKbRynH0g!T+GrhsZMW)v{L=AqM>0q#CM3$(!p|-nOw41y9i9 z+)vIFB_rxKgQc90&~U{qjv|K{5D@mHLc?HSu7Znj7Asx`eRRe^I%YJ<-Ic0E9d;+I z$St^o-zxX#1a_!s{zEVjO*_bdWNk|_KeYU7lqs2uY%&o@z=%}PF+!)rUeJzXyUK{axsD4yWh_SxA5`~6dGmyYqr#|J|l1vjxjSjJ?gb5E(VLt z>e%ve`eC}=+J{P>Ane4LD#j9{zWyiqEUNU8DxM5qB_R`zW(CVzDll5iMvJu=p>5W{ zGR;a4(-LipVFK~3h7r*U`r(9b>5BGustb-VEUt`higW$}Qzy`-V?dtEIUUU=ixq3( zq+8o>j54$bBVI;CU#NC6Ky!X4OO1}84&4&4Gz5Uur4|^3o=Xb#2A=^(V9Nb9(;igs zsB2M*ym_aR)}Q#H%e2-ZKpiGl15COU&qWJ&-g_U4@^&F zOa=eIF+q!XYaF#Rg;dJZS0h9Z?oQPJf&*8_{=E6uyeOD z{z-n94QTspaMeyb(pcMSdrcN(HkQ5BZ);*lNwo9N6fgQ`kSqhAN2VBn>n?!2JJ4We zWRGmA13i8neucrxJUCOSMEE2lX-7bx=nlvp#6&9>J;3Wa{QKBojW2gLIF7Nh-6!UgJv@*`pReXv*wV2^w9Aon37Sc4GE7o*$3}O~RF>KD=?ZM6 zq+KpeMphB=jL1PeOYeRWS!1-EO!vpCH5Kl6<9KR%d4PP01RQ13@@mqgvIu;&V6gek zilkpZx?wIA*1&V^q?!a{!rK`^QG}L{q5y!(Pd4Mo#qcx^pEWuc#fQFwUl^(NDXolt z{l7TweT%HLYp)*Z54B%oDDuY?Gt(f-vC8@H&XLs$b?M zHKDS7ZROZK%BB}C`x@m0<8jkGN34F&bcAFO0$wkT9f@qji~8!4JKY}6$a_ARO_4+3 z6|J2RvRO8?*=a-K37a76@U`cxSTCgD1)R4KHE+tS6?@*KXk{Lw2+mkux^0e1gzZa9 z2C$SP6}&1MFL~%ka#O1?>2-bh#lR3SM1 zGP$26!k9?;Yr4n^&>e52rQsBCPD^q?Mf7uU!f(Nh3KPU#t9GqM=jVCsLg-Nt3yUN3Y}Y+Y5qb%M;E8nuO$Y*9%Wkvd!%Q<(l@3+EuCT z;onw>%WEW1)^UmP-)BDP>5AMb!TxH-@h{);w4;sCCBe*gP!mF_!vsl}t|RD06Msq1 zt5%?7jw^OzK*Z<8$8C7Td8mB-p54B8mXvPL=~qaL+uI@_ESL=`U&LrrsNJ8#orkAo zcd(cKN+AB&5!e#ZMY!F(Y_VDkMuJNhK5<=c)3YPad6x_yyUnSu-yy1Q4x}38*eX$? z3YQFX87sXHCItDiA)vlUH$&-!us@U(Os73VfG2m^#gamFCD&vQYcMKkqz%$5G@Gz@ z)tSp?^WkG45RX<1{$$ablrp1iU}&~B#hXVf^P&8?*EpkT>P@Dl-9Gq&j$akf5Tx%{ zhGLXhTD`1!-zd&nJ)~cS-ZG#8G-8;1chR0|?0gaNUKi4N>lN}z|5)NW-t*uJqa9)R zSS<)k4YwWvYlF^+BbWep-=PB6iQa(H_BTf9bIghZn>W5^#@d`lcg!h1@!|HF3TbU# zyHFI*4_zt7_FIl=YDKh=>Zxw%vo3>9?2w8!mH}6Kfku^+#4a0N&CxO+Jx;TWow+p- z@7s(-*BMF(g~mt7)tJycK)?5FVE%f|MZS@R9Lg6nDRYcSDvByLn;3l@S7sz+mG#uI=sESKGa}no+8Bl=qR1sEjnW*S3xSJeK z;US9r)E({ZZ{NK)icXGnzQ2oDFhL5&V@j|lWRLcqmKub}lEblj z6V!&WW0k;VXIGZh-?&Chsdi|%uG6@F=Wwr0@fHI&ii5vz#6Ka*h_%Pn=4nAYDNpuQ z2zQA>R_*X-fB~4-*UCdr*7@s7{gtcCR!onAcAd?Kd9wsh4x~!7fz%`fpSI|%ti0T| zoX>9_UTHxE=n z5Vfw7BgPL$3p%rRm@^M*(RcAiIO=t!?>_XG znD{brkJ`N)AokfmY&pa;VC^d*NS7%FUK=f!kDEg&01KWTsNj+#bd*!k62HkFg6L^WZG%}TPEKP0j zu%rF3GzRU=r&RoA_jCwozaaXf*cFGre|V=EefcGF694@RN3RU-2mTG`n3=;H>}&Za z4smkqVTIo^NC!&{XZID0M+*HW)2yg2Kf5}@t>nt162^AR-UggABJX+cge{1R&^Uhd zhChfbWN8v@Pu>a{hstd8J}`i1`w_PEF5ohmvJ2PKN zW>>kuUWmP_XUZt`>^!mUu7_EIQ`cT*d~RBGS`1 z%-^W~nfr}rpt-K zW3KwSwOq<8_BgBAM5i%%zeym?uM-;4Ua z)VG~=zoy!cQJ(<_&5J!c z;vl#QbNox^!l~FbTJFBM8#HPap0~$GmH=MuU7?P5>7cqJ@`$wIWu>2i>{2meym>n1$8P)Y$+O_*@OaFz9H&3NoLx zuDn}^$gkt2Q2u-( zAUK`o8UvbY7@RmHezvq#CqV$V9)B0g!a({ZNo`=qvf)1qN`u+1*6xL4u$d_ldvB4|Mt%^E2k2RVKdeWS&Fy%mbc>!KC+U z8*PJxYYe86zW_kbo&nKj#R0P5_|9i{%nlf?V)(#eDSCnIisB7kOUa3`y!E5h4iP98 z+Xo{`PnKz}d~pbh=K>5(XrijUOrF3uUez4_(ptDsu z*npTG`iShRRk1)^j#0gx_P}+KTbQ8ATfYCPbTLa+HsozeV3^=FyYPIwAC|U`pdPur zLFf=!>80&7h?(Ekxh)Z8$6Lpu+|ik}2Ew*2kt)@Q6`UxX(9s>sJ%~N8r!fh-RkjEr zGsY6%Daq$Q+;X%@%|}i9CNGt-r@`#$cXjEp*O=MhjiKbMDuLwnb<$uJqe}aGb4N+o z9SO>(ipNdVC9a#X!++2yLPuTcgYP2(>EccrJgbe5i{o&CM1Be&=Zs2DX)Y-mecK;~ zvlWp2+=G%P0(1?^rLEg~*yU7L$!&m&ZY*{hK&$~)!+T6svW7)<1@T+_^*s3W;=DUl z$m>s=u$!x;P2z`pnHP~;U$jvY1XrU){;B6Y(dlh2ZsyKgMkD__u5QM}9T3`t#V+^r zeFphYqlm!%ay4hZdo^dD_)Iai_#ju)eT~VpkF3j8?QI(D7GISLl^$37*iE(@=pVSg zJm<}0x+DxElRKS`Tlu`^FWa`icvbc0R{~c!O^sXlE6&gKZMPKzU)k*=eiGZ+p0K zFs*vy797TN^-Ns4l_#$S2WG;VG#Dr!IZ@x2k= z4@VYebw1@Z%8i@CW?YbDnW7Kg7$Rv1q9+J_8e7Ks2Y(2PM0l4~jEw;;*{ZpiciXnG z)E7MUu2>$QGo=#cszTjt!g8mSCQJB8f@_6**WnJjoBv3@?>6qaQR8XU?6(MuFI$tj9N5Y z7(FL&73!5lg>$y4M?iv*S_fm;RnIH}OHE~d^U07~&Efl{qv4*ds7179XM2}mQ7y0Q z)66>%gkuUt3mVJPSSo%zBF;w^3@J`W!wSmLq48+k0bqyIZSl?%bO4soezkp%vU(vk zigE;xfEQRrsFKTtUoKgUQSJeB977xVyb}$^*1!!nR@T9)`(JJGMe15$uNdxXYOjZr zA8aC(IAW9QYs9-k9T~FwF9sR;s#O1#a)gtD78@KL}v8Lmz^fx zfzh333 + file.outputStream().use { input.copyTo(it) } + } + return file + } + + @Test + fun wavPackDsdDecodesOriginalBitsAndChannelOrderForNativeAndDop() { + val file = fixture() + try { + for ((encoding, slot) in listOf("dsd_be" to 4, "dsd_le" to 4, "dop" to 3, "dop" to 4)) { + requireNotNull(DsdSource.open(file.path)).use { source -> + assertEquals(2822400, source.rate) + assertEquals(2, source.channels) + val group = if (encoding == "dop") 2 else 4 + var cursor = 0 + while (true) { + val data = source.readFrames(encoding, slot, 37) ?: break + for (frame in 0 until data.size / (slot * 2)) { + for (channel in 0..1) { + val start = (frame * 2 + channel) * slot + if (encoding == "dop" && slot == 4) assertEquals(0, data[start].toInt()) + if (encoding == "dop") assertEquals(5, data[start + slot - 1].toInt()) + for (i in 0 until group) { + val sampleIndex = if (encoding == "dsd_be") i else group - 1 - i + val position = cursor + frame * group + sampleIndex + val expected = if (position < 16384) (position * 17 + channel * 29) and 255 else 0x69 + val offset = if (encoding == "dop" && slot == 4) 1 else 0 + assertEquals(expected, data[start + offset + i].toInt() and 255) + } + } + } + cursor += data.size / (slot * 2) * group + } + assertEquals(16384, cursor) + } + } + } finally { file.delete() } + } + + @Test + fun wavPackSeekRetainsBitAlignmentAndSurvivesClosedSafLease() { + val file = fixture() + try { + val source = android.os.ParcelFileDescriptor.open(file, android.os.ParcelFileDescriptor.MODE_READ_ONLY).use { fd -> + requireNotNull(DsdSource.open("/proc/self/fd/${fd.fd}")) + } + source.use { + for (time in listOf(20000L, 0L, 40000L)) { + val actual = it.seek(time, "dsd_be") + val cursor = (actual * it.rate + 7_999_999) / 8_000_000 + val data = requireNotNull(it.readFrames("dsd_be", 4, 1)) + for (channel in 0..1) for (index in 0..3) { + assertEquals(((cursor + index) * 17 + channel * 29).toInt() and 255, + data[channel * 4 + index].toInt() and 255) + } + } + } + } finally { file.delete() } + } + + @Test + fun dsdCannotBeSentToAAudioAsPcm() { + val file = fixture() + val context = InstrumentationRegistry.getInstrumentation().targetContext + val playback = HiResPlayback(context) {} + try { + val done = CountDownLatch(1) + var reply: Any? = null + var error: String? = null + playback.command(MethodCall("prepare", mapOf("path" to file.path, "token" to 1))) { value, failure -> + reply = value; error = failure; done.countDown() + } + assertTrue(done.await(10, TimeUnit.SECONDS)) + assertNull(error) + assertEquals("dsd_unsupported", (reply as Map<*, *>)["reason"]) + assertEquals(true, (reply as Map<*, *>)["fatal"]) + } finally { playback.dispose(); file.delete() } + } + + @Test + fun exclusiveOutputEitherVerifiesExactFormatOrRejectsSharedFallback() { + assertThrows(IllegalStateException::class.java) { NativeAudio.openOboe(1, 2, 24, 0) } + val handle = try { NativeAudio.openOboe(48000, 2, 16, 0) } + catch (error: IllegalStateException) { + assertTrue(error.message.orEmpty().contains("exclusive output unavailable")) + return + } + try { + val bits = NativeAudio.bitsOboe(handle) + assertTrue(bits in listOf(16, 24, 32)) + val silence = ByteBuffer.allocateDirect(4800 * 2 * (bits / 8)) + assertEquals(silence.capacity(), NativeAudio.writeOboe(handle, silence, 0, silence.capacity())) + NativeAudio.startOboe(handle) + val deadline = android.os.SystemClock.elapsedRealtime() + 3000 + while (NativeAudio.framesOboe(handle) < 4800 && android.os.SystemClock.elapsedRealtime() < deadline) Thread.sleep(10) + assertEquals(4800L, NativeAudio.framesOboe(handle)) + NativeAudio.flushOboe(handle) + assertEquals(0L, NativeAudio.framesOboe(handle)) + } finally { NativeAudio.closeOboe(handle) } + NativeAudio.closeOboe(handle) + assertThrows(IllegalStateException::class.java) { NativeAudio.framesOboe(handle) } + } +} diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..78a408c7 --- /dev/null +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.22.1) +project(spotiflac_audio LANGUAGES C CXX) + +include(FetchContent) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +set(BUILD_TESTING OFF CACHE BOOL "" FORCE) +set(OBOE_DISABLE_CONVERSION ON CACHE BOOL "" FORCE) +set(WAVPACK_BUILD_PROGRAMS OFF CACHE BOOL "" FORCE) +set(WAVPACK_ENABLE_DSD ON CACHE BOOL "" FORCE) +set(WAVPACK_ENABLE_THREADS OFF CACHE BOOL "" FORCE) +set(WAVPACK_ENABLE_ASM OFF CACHE BOOL "" FORCE) + +# Immutable releases plus checksums keep local and CI builds identical. +FetchContent_Declare(oboe + URL https://github.com/google/oboe/archive/refs/tags/1.11.0.tar.gz + URL_HASH SHA256=0ccf2110640a2b4489f1c93a404f2978656b434d8c5dc9cb6258f9cc95c79b15) +FetchContent_Declare(wavpack + URL https://github.com/dbry/WavPack/archive/refs/tags/5.9.0.tar.gz + URL_HASH SHA256=2a53e50aefd8c9f04a0828a0e1ef77b6f4c17b0ef6964ad234ab295f313b7d6d) +FetchContent_MakeAvailable(oboe wavpack) + +add_library(spotiflac_audio SHARED native_audio.cpp) +target_compile_features(spotiflac_audio PRIVATE cxx_std_17) +target_compile_options(spotiflac_audio PRIVATE -Wall -Wextra -Werror -fvisibility=hidden) +target_link_libraries(spotiflac_audio PRIVATE oboe WavPack::WavPack log) +target_link_options(spotiflac_audio PRIVATE "-Wl,-z,max-page-size=16384") diff --git a/android/app/src/main/cpp/native_audio.cpp b/android/app/src/main/cpp/native_audio.cpp new file mode 100644 index 00000000..2022982f --- /dev/null +++ b/android/app/src/main/cpp/native_audio.cpp @@ -0,0 +1,281 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +void require(bool condition, const char *message) { + if (!condition) throw std::runtime_error(message); +} +void report(JNIEnv *env, const std::exception &error) { + env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), error.what()); +} + +// Handles never expose raw pointers. A stale/double-close cannot dereference a +// freed stream; each JNI invocation retains ownership for its entire duration. +template class Handles { + std::mutex mutex; + std::unordered_map> values; + jlong next = 1; +public: + jlong add(std::shared_ptr value) { + std::lock_guard lock(mutex); + const auto id = next++; + values.emplace(id, std::move(value)); + return id; + } + std::shared_ptr get(jlong id) { + std::lock_guard lock(mutex); + auto it = values.find(id); + require(it != values.end(), "Native audio handle closed"); + return it->second; + } + void remove(jlong id) { + std::shared_ptr old; + { + std::lock_guard lock(mutex); + auto it = values.find(id); + if (it != values.end()) { old = std::move(it->second); values.erase(it); } + } + } +}; + +class ExclusiveOutput final : public oboe::AudioStreamDataCallback { + std::shared_ptr stream; + // Single producer (Kotlin worker), single consumer (audio callback). + std::vector ring; + std::atomic read{0}, written{0}, musicFrames{0}; + std::atomic lastMusicEnd{0}; + int64_t callbackFrames = 0; + int frameBytes = 0; + bool floatOutput = false; + int64_t frameBase = 0; +public: + int bits = 0; + int deviceId() const { return stream->getDeviceId(); } + ExclusiveOutput(int rate, int channels, int precision, int device) { + require(rate >= 8000 && rate <= 768000 && channels >= 1 && channels <= 2, + "Unsupported hi-res format"); + require(precision == 16 || precision == 24 || precision == 32, "Unsupported PCM precision"); + std::vector formats; + if (precision == 16) formats.push_back(oboe::AudioFormat::I16); + if (precision <= 24) formats.push_back(oboe::AudioFormat::I24); + formats.push_back(oboe::AudioFormat::I32); + if (precision <= 24) formats.push_back(oboe::AudioFormat::Float); + for (auto format : formats) { + oboe::AudioStreamBuilder builder; + builder.setDirection(oboe::Direction::Output) + ->setAudioApi(oboe::AudioApi::AAudio) + ->setSharingMode(oboe::SharingMode::Exclusive) + ->setPerformanceMode(oboe::PerformanceMode::LowLatency) + ->setUsage(oboe::Usage::Media) + ->setContentType(oboe::ContentType::Music) + ->setSampleRate(rate)->setChannelCount(channels)->setFormat(format) + ->setDeviceId(device)->setDataCallback(this) + ->setChannelConversionAllowed(false)->setFormatConversionAllowed(false) + ->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::None); + if (builder.openStream(stream) != oboe::Result::OK) continue; + // Exclusive is a request: Android can silently return Shared. Reject + // that stream instead of displaying a misleading exclusive badge. + if (stream->getAudioApi() == oboe::AudioApi::AAudio && + stream->getSharingMode() == oboe::SharingMode::Exclusive && + stream->getSampleRate() == rate && stream->getChannelCount() == channels && + stream->getFormat() == format && (device == 0 || stream->getDeviceId() == device)) { + floatOutput = format == oboe::AudioFormat::Float; + bits = format == oboe::AudioFormat::I16 ? 16 : format == oboe::AudioFormat::I24 ? 24 : 32; + frameBytes = channels * (bits / 8); + ring.resize(static_cast(rate / 4) * frameBytes); + return; + } + stream->close(); + stream.reset(); + } + throw std::runtime_error("Exact-rate AAudio exclusive output unavailable"); + } + ~ExclusiveOutput() override { if (stream) stream->close(); } + + oboe::DataCallbackResult onAudioReady(oboe::AudioStream *, void *audio, int32_t frames) override { + const auto r = read.load(std::memory_order_relaxed); + const auto w = written.load(std::memory_order_acquire); + const size_t requested = static_cast(frames) * frameBytes; + const size_t available = std::min(requested, w - r); + const size_t start = r % ring.size(); + const size_t first = std::min(available, ring.size() - start); + auto *out = static_cast(audio); + std::memcpy(out, ring.data() + start, first); + std::memcpy(out + first, ring.data(), available - first); + std::memset(out + available, 0, requested - available); + read.store(r + available, std::memory_order_release); + musicFrames.fetch_add(available / frameBytes, std::memory_order_relaxed); + if (available > 0) lastMusicEnd.store(frameBase + callbackFrames + available / frameBytes, std::memory_order_release); + callbackFrames += frames; + return oboe::DataCallbackResult::Continue; + } + void check() { + require(stream && stream->getState() != oboe::StreamState::Disconnected && + stream->getState() != oboe::StreamState::Closed, "AAudio output disconnected"); + } + int write(const uint8_t *data, size_t size) { + check(); + require(size % frameBytes == 0, "Unaligned PCM buffer"); + auto w = written.load(std::memory_order_relaxed); + auto r = read.load(std::memory_order_acquire); + size = std::min(size, ring.size() - (w - r)) / frameBytes * frameBytes; + for (size_t offset = 0; offset < size;) { + const size_t index = (w + offset) % ring.size(); + const size_t count = std::min(size - offset, ring.size() - index); + if (floatOutput) { + // Integer <=24-bit samples padded to I32 have an exact float + // representation. Never use this path for 32-bit source audio. + for (size_t sample = 0; sample < count; sample += 4) { + int32_t integer; + std::memcpy(&integer, data + offset + sample, 4); + const float value = static_cast(integer) * (1.0f / 2147483648.0f); + std::memcpy(ring.data() + index + sample, &value, 4); + } + } else std::memcpy(ring.data() + index, data + offset, count); + offset += count; + } + written.store(w + size, std::memory_order_release); + return static_cast(size); + } + void start() { check(); require(stream->requestStart() == oboe::Result::OK, "AAudio start failed"); } + void flush() { + check(); + // Wait for the callback to stop before resetting its ring/counters. + const auto state = stream->getState(); + if (state != oboe::StreamState::Open && state != oboe::StreamState::Flushed) { + require(stream->pause(2000000000LL) == oboe::Result::OK, "AAudio pause failed"); + require(stream->flush(2000000000LL) == oboe::Result::OK, "AAudio flush failed"); + } + read.store(0); written.store(0); musicFrames.store(0); + frameBase = stream->getFramesWritten(); + callbackFrames = 0; + lastMusicEnd.store(frameBase); + } + int64_t frames() { + check(); + int64_t presented = 0, nanos = 0; + auto music = static_cast(musicFrames.load(std::memory_order_relaxed)); + if (stream->getTimestamp(CLOCK_MONOTONIC, &presented, &nanos) == oboe::Result::OK) { + const auto pending = std::max(0, lastMusicEnd.load(std::memory_order_acquire) - presented); + return std::max(0, music - pending); + } + // A timestamp may not exist until the first hardware burst completes. + return 0; + } +}; + +class DsdDecoder { +public: + WavpackContext *context = nullptr; + int channels = 0; + int rate = 0; + int64_t samples = 0; + explicit DsdDecoder(const char *path) { + char error[80]{}; + context = WavpackOpenFileInput(path, error, OPEN_DSD_NATIVE | OPEN_ALT_TYPES, 0); + require(context != nullptr, "Cannot open WavPack file"); + channels = WavpackGetNumChannels(context); + rate = static_cast(WavpackGetNativeSampleRate(context)); + samples = WavpackGetNumSamples64(context); + } + ~DsdDecoder() { if (context) WavpackCloseFile(context); } + bool isDsd() const { return (WavpackGetQualifyMode(context) & QMODE_DSD_AUDIO) != 0; } +}; +Handles outputs; +Handles decoders; +} + +#define JNI_AUDIO(name) Java_com_zarz_spotiflac_NativeAudio_##name +extern "C" { +JNIEXPORT jlong JNICALL JNI_AUDIO(openOboe)(JNIEnv *env, jobject, jint rate, jint channels, jint bits, jint device) { + try { return outputs.add(std::make_shared(rate, channels, bits, device)); } + catch (const std::exception &e) { report(env, e); return 0; } +} +JNIEXPORT jint JNICALL JNI_AUDIO(bitsOboe)(JNIEnv *env, jobject, jlong handle) { + try { return outputs.get(handle)->bits; } catch (const std::exception &e) { report(env, e); return 0; } +} +JNIEXPORT jint JNICALL JNI_AUDIO(deviceOboe)(JNIEnv *env, jobject, jlong handle) { + try { return outputs.get(handle)->deviceId(); } catch (const std::exception &e) { report(env, e); return 0; } +} +JNIEXPORT jint JNICALL JNI_AUDIO(writeOboe)(JNIEnv *env, jobject, jlong handle, jobject buffer, jint offset, jint count) { + try { + auto *data = static_cast(env->GetDirectBufferAddress(buffer)); + auto size = env->GetDirectBufferCapacity(buffer); + require(data && offset >= 0 && count >= 0 && count <= 262144 && offset <= size && count <= size - offset, "Invalid PCM buffer"); + return outputs.get(handle)->write(data + offset, count); + } catch (const std::exception &e) { report(env, e); return 0; } +} +JNIEXPORT void JNICALL JNI_AUDIO(startOboe)(JNIEnv *env, jobject, jlong handle) { + try { outputs.get(handle)->start(); } catch (const std::exception &e) { report(env, e); } +} +JNIEXPORT void JNICALL JNI_AUDIO(flushOboe)(JNIEnv *env, jobject, jlong handle) { + try { outputs.get(handle)->flush(); } catch (const std::exception &e) { report(env, e); } +} +JNIEXPORT jlong JNICALL JNI_AUDIO(framesOboe)(JNIEnv *env, jobject, jlong handle) { + try { return outputs.get(handle)->frames(); } catch (const std::exception &e) { report(env, e); return 0; } +} +JNIEXPORT void JNICALL JNI_AUDIO(closeOboe)(JNIEnv *, jobject, jlong handle) { outputs.remove(handle); } +JNIEXPORT jlong JNICALL JNI_AUDIO(openWavPack)(JNIEnv *env, jobject, jstring path) { + const char *text = env->GetStringUTFChars(path, nullptr); + if (!text) return 0; + try { + auto decoder = std::make_shared(text); + env->ReleaseStringUTFChars(path, text); + text = nullptr; + if (!decoder->isDsd()) return 0; // PCM WavPack uses ordinary playback. + require(decoder->channels >= 1 && decoder->channels <= 2 && decoder->samples > 0 && + decoder->samples <= std::numeric_limits::max() / 8000000 && + (decoder->rate == 2822400 || decoder->rate == 5644800 || decoder->rate == 11289600 || decoder->rate == 22579200), + "Unsupported WavPack DSD format"); + return decoders.add(std::move(decoder)); + } catch (const std::exception &e) { + if (text) env->ReleaseStringUTFChars(path, text); + report(env, e); return 0; + } +} +JNIEXPORT jlongArray JNICALL JNI_AUDIO(infoWavPack)(JNIEnv *env, jobject, jlong handle) { + try { + auto d = decoders.get(handle); + jlong data[]{d->rate, d->channels, d->samples}; + auto result = env->NewLongArray(3); + if (result) env->SetLongArrayRegion(result, 0, 3, data); + return result; + } catch (const std::exception &e) { report(env, e); return nullptr; } +} +JNIEXPORT jbyteArray JNICALL JNI_AUDIO(readWavPack)(JNIEnv *env, jobject, jlong handle, jint frames) { + try { + require(frames > 0 && frames <= 16384, "Invalid DSD read size"); + auto d = decoders.get(handle); + std::vector data(static_cast(frames) * d->channels); + auto count = WavpackUnpackSamples(d->context, data.data(), frames); + require(WavpackGetNumErrors(d->context) == 0, "Corrupt WavPack DSD data"); + require(count > 0 || WavpackGetSampleIndex64(d->context) >= d->samples, "Truncated WavPack DSD data"); + std::vector bytes(static_cast(count) * d->channels); + // OPEN_DSD_NATIVE returns interleaved, MSB-first DSD bytes regardless + // of the wrapper's original DSF bit-order/blocking flags. + for (size_t i = 0; i < bytes.size(); ++i) bytes[i] = static_cast(data[i]); + auto result = env->NewByteArray(static_cast(bytes.size())); + if (result) env->SetByteArrayRegion(result, 0, static_cast(bytes.size()), bytes.data()); + return result; + } catch (const std::exception &e) { report(env, e); return nullptr; } +} +JNIEXPORT void JNICALL JNI_AUDIO(seekWavPack)(JNIEnv *env, jobject, jlong handle, jlong sample) { + try { + auto d = decoders.get(handle); + require(sample >= 0 && sample < d->samples && WavpackSeekSample64(d->context, sample), "WavPack DSD seek failed"); + } catch (const std::exception &e) { report(env, e); } +} +JNIEXPORT void JNICALL JNI_AUDIO(closeWavPack)(JNIEnv *, jobject, jlong handle) { decoders.remove(handle); } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DsdFile.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DsdFile.kt index 2c7434c3..a0114d6f 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DsdFile.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DsdFile.kt @@ -1,15 +1,14 @@ package com.zarz.spotiflac -import java.io.Closeable import java.io.RandomAccessFile import java.nio.ByteBuffer import java.nio.ByteOrder /** Bounded DSF/uncompressed DSDIFF reader. Samples are normalized to MSB first. */ -internal class DsdFile private constructor(private val file: RandomAccessFile) : Closeable { - var rate = 0 +internal class DsdFile private constructor(private val file: RandomAccessFile) : DsdSource { + override var rate = 0 private set - var channels = 0 + override var channels = 0 private set private var dataOffset = 0L private var bytesPerChannel = 0L @@ -18,7 +17,7 @@ internal class DsdFile private constructor(private val file: RandomAccessFile) : private var cursor = 0L private var cachedBlock = -1L private var block = ByteArray(0) - val durationUs: Long get() = bytesPerChannel * 8_000_000 / rate + override val durationUs: Long get() = bytesPerChannel * 8_000_000 / rate companion object { fun open(path: String): DsdFile? { @@ -125,14 +124,15 @@ internal class DsdFile private constructor(private val file: RandomAccessFile) : } } - fun seek(positionUs: Long, encoding: String): Long { + override fun seek(positionUs: Long, encoding: String): Long { val group = if (encoding == "dop") 2 else 4 cursor = (positionUs.coerceIn(0, durationUs) * rate / 8_000_000 / group * group) .coerceAtMost(bytesPerChannel) return cursor * 8_000_000 / rate } - fun readFrames(encoding: String, subslot: Int, maxFrames: Int = 4096): ByteArray? { + override fun readFrames(encoding: String, subslot: Int, maxFrames: Int): ByteArray? { + require(maxFrames in 1..4096) if (cursor >= bytesPerChannel) return null require(encoding in listOf("dop", "dsd_be", "dsd_le")) require(subslot in 3..4) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DsdSource.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DsdSource.kt new file mode 100644 index 00000000..83d731ca --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DsdSource.kt @@ -0,0 +1,89 @@ +package com.zarz.spotiflac + +import java.io.Closeable +import java.io.FileInputStream + +internal interface DsdSource : Closeable { + val rate: Int + val channels: Int + val durationUs: Long + fun seek(positionUs: Long, encoding: String): Long + fun readFrames(encoding: String, subslot: Int, maxFrames: Int = 4096): ByteArray? + + companion object { + fun isDsd(path: String): Boolean { + val header = ByteArray(32) + val count = FileInputStream(path).use { it.read(header) } + if (count < 4) return false + return when (String(header, 0, 4, Charsets.US_ASCII)) { + "DSD ", "FRM8" -> true + "wvpk" -> count >= 28 && header[27].toInt() and 0x80 != 0 + else -> false + } + } + fun open(path: String): DsdSource? { + val magic = ByteArray(4) + FileInputStream(path).use { if (it.read(magic) != 4) return null } + return if (magic.contentEquals("wvpk".toByteArray())) WavPackDsdSource.open(path) + else DsdFile.open(path) + } + } +} + +internal class WavPackDsdSource private constructor(private var handle: Long) : DsdSource { + private val info = NativeAudio.infoWavPack(handle) + override val rate = info[0].toInt() + override val channels = info[1].toInt() + private val bytesPerChannel = info[2] + override val durationUs = bytesPerChannel * 8_000_000 / rate + private var cursor = 0L + + companion object { + fun open(path: String): WavPackDsdSource? { + val handle = NativeAudio.openWavPack(path) + if (handle == 0L) return null + try { return WavPackDsdSource(handle) } + catch (error: Exception) { NativeAudio.closeWavPack(handle); throw error } + } + } + + override fun seek(positionUs: Long, encoding: String): Long { + val group = if (encoding == "dop") 2 else 4 + cursor = (positionUs.coerceIn(0, durationUs) * rate / 8_000_000 / group * group).coerceAtMost(bytesPerChannel) + if (cursor < bytesPerChannel) NativeAudio.seekWavPack(handle, cursor) + return cursor * 8_000_000 / rate + } + + override fun readFrames(encoding: String, subslot: Int, maxFrames: Int): ByteArray? { + if (cursor >= bytesPerChannel) return null + require(maxFrames in 1..4096 && encoding in listOf("dop", "dsd_be", "dsd_le")) + require(if (encoding == "dop") subslot in 3..4 else subslot == 4) + val group = if (encoding == "dop") 2 else 4 + val count = minOf(bytesPerChannel - cursor, maxFrames.toLong() * group).toInt() + val data = NativeAudio.readWavPack(handle, count) + require(data.size == count * channels) { "Truncated WavPack DSD" } + val frames = (count + group - 1) / group + val output = ByteArray(frames * channels * subslot) + var out = 0 + for (frame in 0 until frames) { + for (channel in 0 until channels) { + fun sample(index: Int): Byte { + val pos = frame * group + index + return if (pos < count) data[pos * channels + channel] else 0x69.toByte() + } + if (encoding == "dop") { + if (subslot == 4) output[out++] = 0 + output[out++] = sample(1) + output[out++] = sample(0) + output[out++] = 5 + } else for (index in 0 until 4) output[out++] = sample(if (encoding == "dsd_le") 3 - index else index) + } + } + cursor += count + return output + } + + override fun close() { + if (handle != 0L) { NativeAudio.closeWavPack(handle); handle = 0 } + } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/HiResPlayback.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/HiResPlayback.kt new file mode 100644 index 00000000..d8eda6ec --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/HiResPlayback.kt @@ -0,0 +1,186 @@ +package com.zarz.spotiflac + +import android.content.Context +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import android.os.PowerManager +import io.flutter.plugin.common.MethodCall +import java.nio.ByteBuffer + +/** Exact-rate PCM via Oboe's AAudio backend. DSD never enters a PCM stream. */ +internal class HiResPlayback(context: Context, private val emit: (Map) -> Unit) { + private val manager = context.getSystemService(AudioManager::class.java) + private val thread = HandlerThread("SpotiFLAC-AAudio", android.os.Process.THREAD_PRIORITY_AUDIO).apply { start() } + private val worker = Handler(thread.looper) + private val wakeLock = context.getSystemService(PowerManager::class.java) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SpotiFLAC:AAudio") + @Volatile private var revision = 0 + private var source: UsbPcmSource? = null + private var output = 0L + private var device = 0 + private var bits = 0 + private var token = 0 + private var pending: ByteBuffer? = null + private var playing = false + private var failed = false + private var needsSeek = false + private var baseUs = 0L + private var pausedUs = 0L + private var writtenFrames = 0L + private val route = object : AudioDeviceCallback() { + override fun onAudioDevicesRemoved(removed: Array) { + if (output != 0L && removed.any { it.id == device }) fail("AAudio device disconnected") + } + } + init { manager.registerAudioDeviceCallback(route, worker) } + + fun command(call: MethodCall, done: (Any?, String?) -> Unit) { + if (call.method == "prepare" || call.method == "stop") revision++ + val expected = revision + worker.post { + try { + val result: Any? = when (call.method) { + "prepare" -> prepare(call, expected) + "resume" -> { resume(); null } + "pause" -> { pause(); null } + "seek" -> { + val wasPlaying = playing + pause() + pausedUs = ((call.argument("position")?.toLong() ?: 0) * 1000) + .coerceIn(0, source?.durationUs ?: 0) + if (wasPlaying) resume() + null + } + "position" -> positionUs() / 1000 + "stop" -> { close(); null } + else -> error("Unknown hi-res command") + } + done(result, null) + } catch (error: Exception) { done(null, error.message ?: "AAudio output failed") } + } + } + + private fun prepare(call: MethodCall, expected: Int): Map { + close() + if (expected != revision) return mapOf("reason" to "cancelled") + if (Build.VERSION.SDK_INT < 27) return mapOf("reason" to "exclusive_unavailable") + token = call.argument("token") ?: 0 + var isDsd = call.argument("requiresDsd") == true + try { + val path = requireNotNull(call.argument("path")) + isDsd = isDsd || DsdSource.isDsd(path) + if (!isDsd) DsdSource.open(path)?.use { isDsd = true } + if (isDsd) return mapOf("reason" to "dsd_unsupported", "fatal" to true) + val pcm = UsbPcmSource(path).also { source = it } + val devices = manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + val wired = devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES || it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET } + output = NativeAudio.openOboe(pcm.rate, pcm.channels, pcm.bits, wired?.id ?: 0) + bits = NativeAudio.bitsOboe(output) + device = NativeAudio.deviceOboe(output) + val deadline = android.os.SystemClock.elapsedRealtime() + 5000 + while (pending == null && !pcm.ended) { + require(expected == revision) { "AAudio prepare cancelled" } + require(android.os.SystemClock.elapsedRealtime() < deadline) { "Decoder timed out" } + pending = pcm.read(bits) + if (pending == null) Thread.sleep(2) + } + if (expected != revision) { close(); return mapOf("reason" to "cancelled") } + return mapOf("ready" to true, "driver" to "aaudio_exclusive", "transport" to "pcm", + "sampleRate" to pcm.rate, "bitDepth" to pcm.bits, "duration" to pcm.durationUs / 1000, + "device" to (devices.firstOrNull { it.id == device }?.productName?.toString() ?: "AAudio")) + } catch (error: Exception) { + android.util.Log.i("HiResPlayback", "Exclusive output unavailable: ${error.message}") + close() + return mapOf("reason" to if (isDsd) "dsd_unsupported" else "exclusive_unavailable", "fatal" to isDsd) + } + } + + private fun fill() { + val pcm = requireNotNull(source) + repeat(8) { + if (pending == null) pending = pcm.read(bits) + val data = pending ?: return + val frame = pcm.channels * (bits / 8) + val remaining = minOf(data.remaining(), 65536) / frame * frame + val count = NativeAudio.writeOboe(output, data, data.position(), remaining) + data.position(data.position() + count) + writtenFrames += count / (pcm.channels * (bits / 8)) + if (data.hasRemaining()) return + pending = null + } + } + private fun resume() { + check(!failed && output != 0L) { "AAudio route changed; reopen track" } + if (needsSeek) { + source?.seek(pausedUs) + baseUs = pausedUs + pending = null + writtenFrames = 0 + needsSeek = false + } + fill() + NativeAudio.startOboe(output) + playing = true + if (!wakeLock.isHeld) wakeLock.acquire() + emit(mapOf("token" to token, "event" to "active")) + worker.removeCallbacks(pump) + worker.post(pump) + } + private fun positionUs(): Long { + if (needsSeek || output == 0L) return pausedUs + val pcm = source ?: return pausedUs + pausedUs = (baseUs + NativeAudio.framesOboe(output) * 1000000 / pcm.rate).coerceAtMost(pcm.durationUs) + return pausedUs + } + private fun pause() { + if (!needsSeek) pausedUs = runCatching { positionUs() }.getOrDefault(pausedUs) + playing = false + worker.removeCallbacks(pump) + if (wakeLock.isHeld) wakeLock.release() + if (output != 0L && !needsSeek) NativeAudio.flushOboe(output) + needsSeek = true + } + private val pump = object : Runnable { + override fun run() { + if (!playing) return + try { + fill() + val pcm = requireNotNull(source) + if (pcm.ended && pending == null && NativeAudio.framesOboe(output) >= writtenFrames) { + pause() + emit(mapOf("token" to token, "event" to "complete")) + } else worker.postDelayed(this, 5) + } catch (error: Exception) { fail(error.message ?: "AAudio error") } + } + } + private fun fail(reason: String) { + android.util.Log.w("HiResPlayback", reason) + runCatching { pause() } + failed = true + if (output != 0L) NativeAudio.closeOboe(output) + output = 0 + emit(mapOf("token" to token, "event" to "paused", "reason" to "route_changed")) + } + private fun close() { + runCatching { pause() } + if (output != 0L) NativeAudio.closeOboe(output) + output = 0 + source?.close() + source = null + pending = null + failed = false + needsSeek = false + baseUs = 0 + pausedUs = 0 + writtenFrames = 0 + } + fun dispose() { + revision++ + manager.unregisterAudioDeviceCallback(route) + worker.post { close(); thread.quitSafely() } + } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt index 1c2e0f29..de9aec80 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt @@ -295,8 +295,8 @@ internal fun MainActivity.readAudioMetadataFromUri( uri, displayNameHint, fallbackExt, acceptDirect = { !it.optBoolean("metadataFromFilename", false) }, ) { path, name -> - if (name.endsWith(".dsf", true) || name.endsWith(".dff", true)) { - DsdFile.open(path)?.use { source -> + if (name.endsWith(".dsf", true) || name.endsWith(".dff", true) || name.endsWith(".wv", true)) { + DsdSource.open(path)?.use { source -> return@readMetadataFromUri JSONObject().apply { put("trackName", name.substringBeforeLast('.')) put("artistName", "Unknown Artist") diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeAudio.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeAudio.kt new file mode 100644 index 00000000..fa8ab5d3 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeAudio.kt @@ -0,0 +1,21 @@ +package com.zarz.spotiflac + +import java.nio.ByteBuffer + +/** All calls are serialized on the owning playback worker. */ +internal object NativeAudio { + init { System.loadLibrary("spotiflac_audio") } + external fun openOboe(rate: Int, channels: Int, bits: Int, device: Int): Long + external fun writeOboe(handle: Long, data: ByteBuffer, offset: Int, count: Int): Int + external fun bitsOboe(handle: Long): Int + external fun deviceOboe(handle: Long): Int + external fun startOboe(handle: Long) + external fun flushOboe(handle: Long) + external fun framesOboe(handle: Long): Long + external fun closeOboe(handle: Long) + external fun openWavPack(path: String): Long + external fun infoWavPack(handle: Long): LongArray + external fun readWavPack(handle: Long, frames: Int): ByteArray + external fun seekWavPack(handle: Long, sample: Long) + external fun closeWavPack(handle: Long) +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/UsbBitPerfectPlugin.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/UsbBitPerfectPlugin.kt index aa5775e0..d67dce84 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/UsbBitPerfectPlugin.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/UsbBitPerfectPlugin.kt @@ -31,6 +31,8 @@ class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Even private var engine: UsbPcmPlayback? = null private var direct: UsbDirectPlayback? = null private var useDirect = false + private var hiRes: HiResPlayback? = null + private var useHiRes = false private lateinit var appContext: Context private var sink: EventChannel.EventSink? = null private val main = Handler(Looper.getMainLooper()) @@ -50,15 +52,29 @@ class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Even override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { if (call.method == "prepare") { - if (useDirect) direct?.command(MethodCall("stop", null)) { _, _ -> } + if (useHiRes) hiRes?.command(MethodCall("stop", null)) { _, _ -> } + else if (useDirect) direct?.command(MethodCall("stop", null)) { _, _ -> } else if (Build.VERSION.SDK_INT >= 34) engine?.command(MethodCall("stop", null)) { _, _ -> } useDirect = call.argument("direct") == true + useHiRes = !useDirect && call.argument("dapExclusive") == true + if (useHiRes && hiRes == null) { + hiRes = HiResPlayback(appContext) { event -> main.post { sink?.success(event) } } + } if (useDirect && direct == null) { direct = UsbDirectPlayback(appContext) { event -> main.post { sink?.success(event) } } } } + if (useHiRes) { + hiRes?.command(call) { value, error -> + main.post { + if (error == null) result.success(value) + else result.error("aaudio_exclusive", error, null) + } + } + return + } if (useDirect) { direct?.command(call) { value, error -> main.post { @@ -90,6 +106,8 @@ class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Even sink = null direct?.dispose() direct = null + hiRes?.dispose() + hiRes = null if (Build.VERSION.SDK_INT >= 34) engine?.dispose() engine = null } diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/UsbDirectPlayback.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/UsbDirectPlayback.kt index 109ae6e5..d982eb11 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/UsbDirectPlayback.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/UsbDirectPlayback.kt @@ -15,6 +15,7 @@ import android.os.Looper import android.os.PowerManager import com.spotiflac.backend.UsbDirectOutput import com.spotiflac.backend.UsbOutputFormat +import com.spotiflac.backend.UsbHardwareVolume import io.flutter.plugin.common.MethodCall import java.nio.ByteBuffer @@ -35,7 +36,7 @@ internal class UsbDirectPlayback(private val context: Context, private val emit: @Volatile private var deviceId: Int? = null private var format: UsbOutputFormat? = null private var pcm: UsbPcmSource? = null - private var dsd: DsdFile? = null + private var dsd: DsdSource? = null private var pending: ByteBuffer? = null private var playing = false private var ended = false @@ -46,13 +47,18 @@ internal class UsbDirectPlayback(private val context: Context, private val emit: private var writtenFrames = 0L private var pausedPositionUs = 0L private var needsSeek = false + private var allowFixedVolume = false + private val deviceVolumes = mutableMapOf() private val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @Suppress("DEPRECATION") val device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) ?: return - if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED && device.deviceId == deviceId) { - worker.post { fail("USB disconnected") } + if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED) { + worker.post { + deviceVolumes.remove(device.deviceId) + if (device.deviceId == deviceId) fail("USB disconnected") + } } else if (intent.action == permissionAction) { val request = permission ?: return if (request.device.deviceId != device.deviceId) return @@ -109,6 +115,14 @@ internal class UsbDirectPlayback(private val context: Context, private val emit: "pause" -> { pause(); null } "seek" -> { seek((call.argument("position")?.toLong() ?: 0) * 1000); null } "position" -> positionUs() / 1000 + "volume" -> { + require(call.argument("token") == token) { "Stale volume request" } + val audio = requireNotNull(output) + val db = call.argument("db")?.toDouble() + val value = if (db == null) audio.volume() else audio.setVolume(db) + if (db != null && value.available) deviceId?.let { deviceVolumes[it] = value.currentDb } + volumeMap(value) + } "stop" -> { closeSource(); null } else -> error("Unknown USB command") } @@ -124,9 +138,12 @@ internal class UsbDirectPlayback(private val context: Context, private val emit: if (request.revision != revision) { request.done(mapOf("reason" to "cancelled"), null); return@post } closeSource() token = request.call.argument("token") ?: 0 + allowFixedVolume = request.call.argument("allowFixedVolume") == true val path = requireNotNull(request.call.argument("path")) - dsd = DsdFile.open(path) - isDsd = dsd != null + isDsd = DsdSource.isDsd(path) + dsd = DsdSource.open(path) + isDsd = isDsd || dsd != null + if (isDsd) requireNotNull(dsd) { "DSD decoder unavailable" } if (!isDsd) pcm = UsbPcmSource(path) val rate = dsd?.rate ?: requireNotNull(pcm).rate val channels = dsd?.channels ?: requireNotNull(pcm).channels @@ -138,6 +155,11 @@ internal class UsbDirectPlayback(private val context: Context, private val emit: val audio = UsbDirectOutput.open(usb.fileDescriptor, usb.rawDescriptors, rate.toUInt(), channels.toUByte(), bits.toUByte(), isDsd, request.call.argument("allowDop") == true) output = audio format = audio.format() + if (audio.volume().available) { + deviceVolumes[request.device.deviceId]?.let { + audio.setVolume(minOf(it, audio.volume().restoreLimitDb)) + } + } if (request.revision != revision) { closeSource(); request.done(mapOf("reason" to "cancelled"), null); return@post } val deadline = android.os.SystemClock.elapsedRealtime() + 5000 while (pending == null && !ended) { @@ -149,7 +171,9 @@ internal class UsbDirectPlayback(private val context: Context, private val emit: val f = requireNotNull(format) request.done(mapOf("ready" to true, "device" to (request.device.productName ?: "USB audio"), "sampleRate" to rate, "bitDepth" to bits, "duration" to durationUs / 1000, - "transport" to f.encoding, "carrierRate" to f.sampleRate.toLong(), "driver" to "usb_direct"), null) + "transport" to f.encoding, "carrierRate" to f.sampleRate.toLong(), "driver" to "usb_direct", + "volume" to volumeMap(audio.volume()), + "volumeBlocked" to (!audio.volume().available && !allowFixedVolume)), null) } catch (error: Exception) { android.util.Log.i("UsbDirect", "Direct output unavailable: ${error.message}") closeSource() @@ -174,6 +198,7 @@ internal class UsbDirectPlayback(private val context: Context, private val emit: private fun resume() { check(!failed) { "USB route changed; reopen track" } val audio = requireNotNull(output) + check(audio.volume().available || allowFixedVolume) { "volume_unavailable" } if (needsSeek) resetSource(pausedPositionUs) fill() audio.start() @@ -184,6 +209,11 @@ internal class UsbDirectPlayback(private val context: Context, private val emit: worker.post(pump) } + private fun volumeMap(volume: UsbHardwareVolume): Map = mapOf( + "available" to volume.available, "minDb" to volume.minDb, + "maxDb" to volume.maxDb, "currentDb" to volume.currentDb, "token" to token, + ) + private fun fill() { val f = requireNotNull(format) val frameBytes = f.subslot.toInt() * f.channels.toInt() diff --git a/assets/licenses/oboe.txt b/assets/licenses/oboe.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/assets/licenses/oboe.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/assets/licenses/wavpack.txt b/assets/licenses/wavpack.txt new file mode 100644 index 00000000..a0136963 --- /dev/null +++ b/assets/licenses/wavpack.txt @@ -0,0 +1,25 @@ + Copyright (c) 1998 - 2025 David Bryant + All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index b7133e5d..cf07dea2 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,10 +1,21 @@ { - "usbBitPerfect": "USB bit-perfect audio", - "usbBitPerfectDescription": "Preserve the original audio on compatible USB DACs. Android output requires Android 14 or later; the direct USB driver also works on older Android versions. Applies from the next track, bypassing AutoMix and normalization. Control volume on your DAC; system volume may not apply.", + "usbBitPerfect": "Hi-res and bit-perfect audio", + "usbBitPerfectDescription": "Preserve source precision on compatible outputs. Choose direct USB or AAudio exclusive below; otherwise Android USB output requires Android 14 or later. Applies from the next track, without AutoMix or normalization. System volume may not control direct USB.", + "usbHardwareVolume": "DAC hardware volume", + "usbVolumeWarningTitle": "Check your listening volume", + "usbVolumeWarning": "Direct USB audio can be much louder than normal playback, and phone volume buttons may not control it. Lower your DAC or amplifier volume before starting. When supported, SpotiFLAC sets the DAC to at most โˆ’40 dB on connection; this does not guarantee a safe listening level on every amplifier or headphone.", + "usbVolumeAccept": "I understand", + "usbVolumeUnavailable": "Playback is paused because this DAC has no verified hardware volume control. Lower the DAC or amplifier volume, then enable fixed-volume output if needed and select the track again.", + "usbFixedVolume": "Allow fixed-volume USB output", + "usbFixedVolumeDescription": "For DACs without a volume control accessible to the app. Keep off for USB headsets without their own volume control. Applies from the next track.", + "usbFixedVolumeWarning": "The app cannot lower the volume on this output. Audio may start at full level, and the phone volume slider will not help. Only continue after lowering the volume on your DAC or external amplifier. Do not enable this for a headset without its own volume control.", + "dapExclusive": "DAP hi-res (AAudio exclusive)", + "dapExclusiveDescription": "Use Oboe/AAudio at the track's original rate and precision for FLAC/WAV PCM. Requires Android 8.1 or later and compatible hardware. Exclusive access is checked; unavailable formats use normal playback. DSD requires the direct USB driver. Applies from the next track.", + "dapExclusiveUnavailable": "This device cannot open an exact-rate AAudio exclusive stream for this track. Using normal playback.", "usbDirect": "Direct USB driver", - "usbDirectDescription": "Use exclusive USB audio for FLAC/WAV and DSF/DFF, with Android USB permission. Native DSD requires a recognized compatible DAC. Other PCM formats use normal playback. Applies from the next track.", + "usbDirectDescription": "Use exclusive USB audio for FLAC/WAV, DSF/DFF and WavPack DSD, with Android USB permission. Native DSD requires a recognized compatible DAC. Other PCM formats use normal playback. Applies from the next track.", "usbDsdOverPcm": "DSD over PCM (DoP)", - "usbDsdOverPcmDescription": "Enable only if your DAC explicitly supports DoP. Sends DSF/DFF as DSD inside a PCM carrier when native DSD is unavailable. An incompatible device may play noise; leave off for ordinary USB headsets.", + "usbDsdOverPcmDescription": "Enable only if your DAC explicitly supports DoP. Sends DSF/DFF and WavPack DSD inside a PCM carrier when native DSD is unavailable. An incompatible device may play noise; leave off for ordinary USB headsets.", "usbDsdUnsupported": "This DSD format or connected DAC is not supported. Playback stopped.", "usbPermissionDenied": "USB permission was not granted. Using normal playback.", "usbBitPerfectPending": "Waiting for the next track and a compatible USB output.", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 4cd1a96b..c0942209 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1,12 +1,23 @@ { - "usbBitPerfect": "Audio USB bit-perfect", + "usbBitPerfect": "Audio hi-res dan bit-perfect", + "usbHardwareVolume": "Volume hardware DAC", + "usbVolumeWarningTitle": "Periksa volume sebelum memutar", + "usbVolumeWarning": "Audio USB langsung bisa jauh lebih keras daripada pemutaran biasa, dan tombol volume HP mungkin tidak berpengaruh. Turunkan volume DAC atau amplifier sebelum memutar. Jika didukung, SpotiFLAC mengatur volume DAC paling tinggi โˆ’40 dB saat tersambung; angka ini tidak menjamin tingkat suara yang aman untuk semua amplifier atau headphone.", + "usbVolumeAccept": "Saya mengerti", + "usbVolumeUnavailable": "Pemutaran dijeda karena DAC ini tidak memiliki kontrol volume hardware yang terverifikasi. Turunkan volume DAC atau amplifier, lalu aktifkan keluaran volume tetap jika diperlukan dan pilih lagunya kembali.", + "usbFixedVolume": "Izinkan keluaran USB volume tetap", + "usbFixedVolumeDescription": "Untuk DAC yang kontrol volumenya tidak bisa diakses aplikasi. Biarkan mati pada headset USB tanpa kontrol volume sendiri. Berlaku mulai lagu berikutnya.", + "usbFixedVolumeWarning": "Aplikasi tidak bisa menurunkan volume keluaran ini. Suara bisa mulai pada tingkat penuh dan slider volume HP tidak akan membantu. Lanjutkan hanya setelah menurunkan volume pada DAC atau amplifier eksternal. Jangan aktifkan untuk headset tanpa kontrol volume sendiri.", + "dapExclusive": "Hi-res DAP (AAudio eksklusif)", + "dapExclusiveDescription": "Gunakan Oboe/AAudio pada laju sampel dan presisi asli lagu PCM FLAC/WAV. Memerlukan Android 8.1 atau lebih baru dan perangkat kompatibel. Akses eksklusif diperiksa; format yang tidak didukung memakai pemutaran biasa. DSD memerlukan driver USB langsung. Berlaku mulai lagu berikutnya.", + "dapExclusiveUnavailable": "Perangkat ini tidak bisa membuka AAudio eksklusif pada laju sampel asli lagu. Memakai pemutaran biasa.", "usbDirect": "Driver USB langsung", - "usbDirectDescription": "Gunakan audio USB eksklusif untuk FLAC/WAV dan DSF/DFF, dengan izin USB Android. Native DSD memerlukan DAC kompatibel yang dikenali. Format PCM lainnya memakai pemutaran biasa. Berlaku mulai lagu berikutnya.", + "usbDirectDescription": "Gunakan audio USB eksklusif untuk FLAC/WAV, DSF/DFF dan WavPack DSD, dengan izin USB Android. Native DSD memerlukan DAC kompatibel yang dikenali. Format PCM lainnya memakai pemutaran biasa. Berlaku mulai lagu berikutnya.", "usbDsdOverPcm": "DSD melalui PCM (DoP)", - "usbDsdOverPcmDescription": "Aktifkan hanya jika DAC Anda mendukung DoP. Mengirim DSF/DFF sebagai DSD di dalam pembawa PCM saat native DSD tidak tersedia. Perangkat yang tidak kompatibel dapat menghasilkan suara bising; biarkan mati untuk headset USB biasa.", + "usbDsdOverPcmDescription": "Aktifkan hanya jika DAC Anda mendukung DoP. Mengirim DSF/DFF dan WavPack DSD di dalam pembawa PCM saat native DSD tidak tersedia. Perangkat yang tidak kompatibel dapat menghasilkan suara bising; biarkan mati untuk headset USB biasa.", "usbDsdUnsupported": "Format DSD atau DAC yang tersambung tidak didukung. Pemutaran dihentikan.", "usbPermissionDenied": "Izin USB tidak diberikan. Memakai pemutaran biasa.", - "usbBitPerfectDescription": "Pertahankan audio asli pada DAC USB yang kompatibel. Keluaran Android memerlukan Android 14 atau lebih baru; driver USB langsung juga tersedia di Android versi lama. Berlaku mulai lagu berikutnya, tanpa AutoMix dan normalisasi. Atur volume melalui DAC; volume sistem mungkin tidak berlaku.", + "usbBitPerfectDescription": "Pertahankan presisi audio asli pada keluaran kompatibel. Pilih USB langsung atau AAudio eksklusif di bawah; keluaran USB Android biasa memerlukan Android 14 atau lebih baru. Berlaku mulai lagu berikutnya, tanpa AutoMix atau normalisasi. Volume sistem mungkin tidak mengatur USB langsung.", "usbBitPerfectPending": "Menunggu lagu berikutnya dan keluaran USB yang kompatibel.", "usbBitPerfectNoDevice": "DAC USB tidak terhubung. Menggunakan keluaran audio biasa.", "usbBitPerfectFallback": "Perangkat atau format audio ini tidak mendukung keluaran bit-perfect. Menggunakan pemutaran biasa.", diff --git a/lib/main.dart b/lib/main.dart index 0aeb11e7..2ccf3f4a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -53,6 +53,12 @@ void main() { 'libusb', 'libusb1-sys', ], await rootBundle.loadString('assets/licenses/usb.txt')); + yield LicenseEntryWithLineBreaks(const [ + 'Oboe', + ], await rootBundle.loadString('assets/licenses/oboe.txt')); + yield LicenseEntryWithLineBreaks(const [ + 'WavPack', + ], await rootBundle.loadString('assets/licenses/wavpack.txt')); } }); _startupBenchmark?.observeFirstFrame(); diff --git a/lib/models/settings.dart b/lib/models/settings.dart index e5035f2b..b5635a9e 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -35,6 +35,8 @@ class AppSettings { final bool usbBitPerfect; final bool usbDirect; final bool usbDsdOverPcm; + final bool usbAllowFixedVolume; + final bool dapExclusive; final bool autoplay; final bool isFirstLaunch; final bool checkForUpdates; @@ -151,6 +153,8 @@ class AppSettings { this.usbBitPerfect = false, this.usbDirect = false, this.usbDsdOverPcm = false, + this.usbAllowFixedVolume = false, + this.dapExclusive = false, this.autoplay = false, this.isFirstLaunch = true, this.checkForUpdates = true, @@ -242,6 +246,8 @@ class AppSettings { bool? usbBitPerfect, bool? usbDirect, bool? usbDsdOverPcm, + bool? usbAllowFixedVolume, + bool? dapExclusive, bool? autoplay, bool? isFirstLaunch, bool? checkForUpdates, @@ -329,6 +335,8 @@ class AppSettings { usbBitPerfect: usbBitPerfect ?? this.usbBitPerfect, usbDirect: usbDirect ?? this.usbDirect, usbDsdOverPcm: usbDsdOverPcm ?? this.usbDsdOverPcm, + usbAllowFixedVolume: usbAllowFixedVolume ?? this.usbAllowFixedVolume, + dapExclusive: dapExclusive ?? this.dapExclusive, autoplay: autoplay ?? this.autoplay, isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, checkForUpdates: checkForUpdates ?? this.checkForUpdates, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 1b278178..d514ea95 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -26,6 +26,8 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( usbBitPerfect: json['usbBitPerfect'] as bool? ?? false, usbDirect: json['usbDirect'] as bool? ?? false, usbDsdOverPcm: json['usbDsdOverPcm'] as bool? ?? false, + usbAllowFixedVolume: json['usbAllowFixedVolume'] as bool? ?? false, + dapExclusive: json['dapExclusive'] as bool? ?? false, autoplay: json['autoplay'] as bool? ?? false, isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, checkForUpdates: json['checkForUpdates'] as bool? ?? true, @@ -129,6 +131,8 @@ Map _$AppSettingsToJson( 'usbBitPerfect': instance.usbBitPerfect, 'usbDirect': instance.usbDirect, 'usbDsdOverPcm': instance.usbDsdOverPcm, + 'usbAllowFixedVolume': instance.usbAllowFixedVolume, + 'dapExclusive': instance.dapExclusive, 'autoplay': instance.autoplay, 'isFirstLaunch': instance.isFirstLaunch, 'checkForUpdates': instance.checkForUpdates, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 7e5c53ba..eafee5bd 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -567,7 +567,10 @@ class SettingsNotifier extends Notifier { } void setUsbDirect(bool enabled) { - state = state.copyWith(usbDirect: enabled); + state = state.copyWith( + usbDirect: enabled, + dapExclusive: enabled ? false : null, + ); _saveSettings(); } @@ -576,6 +579,19 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setUsbAllowFixedVolume(bool enabled) { + state = state.copyWith(usbAllowFixedVolume: enabled); + _saveSettings(); + } + + void setDapExclusive(bool enabled) { + state = state.copyWith( + dapExclusive: enabled, + usbDirect: enabled ? false : null, + ); + _saveSettings(); + } + void setEmbedMetadata(bool enabled) { state = state.copyWith(embedMetadata: enabled); _saveSettings(); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 8f0b43af..ce933c8b 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -26,6 +26,7 @@ import 'package:spotiflac_android/screens/settings/settings_tab.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; +import 'package:spotiflac_android/services/music_playback_deck.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/app_remote_config_service.dart'; @@ -63,6 +64,7 @@ class _MainShellState extends ConsumerState bool _hasCheckedAppAnnouncement = false; bool _initialSafRepairComplete = false; bool _safRepairDialogVisible = false; + bool _usbVolumeWarningShown = false; StreamSubscription? _shareSubscription; DateTime? _lastBackPress; final GlobalKey _homeTabNavigatorKey = @@ -96,6 +98,8 @@ class _MainShellState extends ConsumerState setUsbOutputOptions( direct: ref.read(settingsProvider).usbDirect, allowDop: ref.read(settingsProvider).usbDsdOverPcm, + allowFixedVolume: ref.read(settingsProvider).usbAllowFixedVolume, + dapExclusive: ref.read(settingsProvider).dapExclusive, ); setAutoplayEnabled( ref.read(settingsProvider).autoplay, @@ -114,6 +118,7 @@ class _MainShellState extends ConsumerState void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + usbAudioStatus.addListener(_onUsbAudioStatusChanged); _homePreviewStopObserver = _PreviewStopNavigatorObserver( () => ref.read(previewPlayerProvider.notifier).stop(), ); @@ -158,6 +163,28 @@ class _MainShellState extends ConsumerState }); } + void _onUsbAudioStatusChanged() { + if (usbAudioStatus.value.reason == 'active') { + _usbVolumeWarningShown = false; + } + if (usbAudioStatus.value.reason != 'volume_unavailable' || + _usbVolumeWarningShown) { + return; + } + _usbVolumeWarningShown = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || usbAudioStatus.value.reason != 'volume_unavailable') { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.usbVolumeUnavailable), + duration: const Duration(seconds: 8), + ), + ); + }); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed && _initialSafRepairComplete) { @@ -509,6 +536,7 @@ class _MainShellState extends ConsumerState @override void dispose() { WidgetsBinding.instance.removeObserver(this); + usbAudioStatus.removeListener(_onUsbAudioStatusChanged); ShellNavigationService.unregisterTabSelectionHandler(this); _shareSubscription?.cancel(); _pageController.dispose(); @@ -751,8 +779,20 @@ class _MainShellState extends ConsumerState setUsbBitPerfectEnabled(enabled); }); ref.listen( - settingsProvider.select((s) => (s.usbDirect, s.usbDsdOverPcm)), - (_, value) => setUsbOutputOptions(direct: value.$1, allowDop: value.$2), + settingsProvider.select( + (s) => ( + s.usbDirect, + s.usbDsdOverPcm, + s.usbAllowFixedVolume, + s.dapExclusive, + ), + ), + (_, value) => setUsbOutputOptions( + direct: value.$1, + allowDop: value.$2, + allowFixedVolume: value.$3, + dapExclusive: value.$4, + ), ); ref.listen( settingsProvider.select((s) => (s.autoplay, s.localLibraryEnabled)), diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 681cca1e..36bf6952 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -19,6 +19,7 @@ import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:spotiflac_android/widgets/ios_library_folder_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; class LibrarySettingsPage extends ConsumerStatefulWidget { const LibrarySettingsPage({super.key}); @@ -205,6 +206,27 @@ class _LibrarySettingsPageState extends ConsumerState { await ref.read(localLibraryProvider.notifier).cancelScan(); } + Future _confirmAudioVolume(String message) async { + return await showAppDialog( + context: context, + builder: (context) => AppAlertDialog( + title: Text(context.l10n.usbVolumeWarningTitle), + content: Text(message), + actions: [ + AppDialogAction( + onPressed: () => Navigator.pop(context, false), + child: Text(context.l10n.dialogCancel), + ), + AppDialogAction( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.usbVolumeAccept), + ), + ], + ), + ) ?? + false; + } + Future _clearLibrary() async { final confirmed = await showAppDialog( context: context, @@ -913,6 +935,10 @@ class _LibrarySettingsPageState extends ConsumerState { ? 'DSD (DoP)' : 'DSD (Native)'} ยท ${(status.sampleRate! / 1000).toStringAsFixed(1)} kHz', 'dsd_unsupported' => context.l10n.usbDsdUnsupported, + 'volume_unavailable' => + context.l10n.usbVolumeUnavailable, + 'exclusive_unavailable' => + context.l10n.dapExclusiveUnavailable, 'permission_denied' => context.l10n.usbPermissionDenied, 'no_usb' => context.l10n.usbBitPerfectNoDevice, @@ -925,9 +951,19 @@ class _LibrarySettingsPageState extends ConsumerState { }, ].join('\n\n'), value: settings.usbBitPerfect, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setUsbBitPerfect(value), + onChanged: (value) async { + if (value && + !await _confirmAudioVolume( + context.l10n.usbVolumeWarning, + )) { + return; + } + if (mounted) { + ref + .read(settingsProvider.notifier) + .setUsbBitPerfect(value); + } + }, showDivider: settings.usbBitPerfect, ), ), @@ -937,9 +973,18 @@ class _LibrarySettingsPageState extends ConsumerState { title: context.l10n.usbDirect, subtitle: context.l10n.usbDirectDescription, value: settings.usbDirect, - onChanged: (value) => - ref.read(settingsProvider.notifier).setUsbDirect(value), - showDivider: settings.usbDirect, + onChanged: (value) async { + if (value && + !await _confirmAudioVolume( + context.l10n.usbVolumeWarning, + )) { + return; + } + if (mounted) { + ref.read(settingsProvider.notifier).setUsbDirect(value); + } + }, + showDivider: true, ), if (settings.usbDirect) SettingsSwitchItem( @@ -950,8 +995,70 @@ class _LibrarySettingsPageState extends ConsumerState { onChanged: (value) => ref .read(settingsProvider.notifier) .setUsbDsdOverPcm(value), - showDivider: false, + showDivider: true, ), + if (settings.usbDirect) ...[ + SettingsSwitchItem( + icon: Icons.volume_up_outlined, + title: context.l10n.usbFixedVolume, + subtitle: context.l10n.usbFixedVolumeDescription, + value: settings.usbAllowFixedVolume, + onChanged: (value) async { + if (value && + !await _confirmAudioVolume( + context.l10n.usbFixedVolumeWarning, + )) { + return; + } + if (mounted) { + ref + .read(settingsProvider.notifier) + .setUsbAllowFixedVolume(value); + } + }, + ), + ValueListenableBuilder( + valueListenable: usbHardwareVolume, + builder: (context, volume, _) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.usbHardwareVolume, + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + volume == null + ? context.l10n.usbBitPerfectPending + : volume.available + ? '${volume.currentDb.toStringAsFixed(1)} dB' + : context.l10n.usbVolumeUnavailable, + ), + if (volume?.available == true) + MornyeVolumeControl( + foreground: Theme.of( + context, + ).colorScheme.onSurface, + ), + ], + ), + ), + ), + ], + SettingsSwitchItem( + icon: Icons.headphones, + title: context.l10n.dapExclusive, + subtitle: context.l10n.dapExclusiveDescription, + value: settings.dapExclusive, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setDapExclusive(value), + showDivider: false, + ), ], ], ), diff --git a/lib/services/music_playback_deck.dart b/lib/services/music_playback_deck.dart index 4f532bcc..fa37182a 100644 --- a/lib/services/music_playback_deck.dart +++ b/lib/services/music_playback_deck.dart @@ -8,10 +8,45 @@ import 'package:flutter/services.dart'; /// Only verified native playback reports active. File metadata alone never does. final usbAudioStatus = ValueNotifier(const UsbAudioStatus()); -class UsbDsdUnavailable extends StateError { +class UsbOutputUnavailable extends StateError { + UsbOutputUnavailable(super.message); +} + +class UsbDsdUnavailable extends UsbOutputUnavailable { UsbDsdUnavailable() : super('DSD requires a compatible direct USB output'); } +/// Null outside direct USB; an unavailable control must not change phone volume. +final usbHardwareVolume = ValueNotifier(null); + +class UsbVolumeState { + UsbVolumeState(Map data) + : available = data['available'] == true, + minDb = (data['minDb'] as num?)?.toDouble() ?? -90, + maxDb = (data['maxDb'] as num?)?.toDouble() ?? 0, + currentDb = (data['currentDb'] as num?)?.toDouble() ?? 0, + token = (data['token'] as num?)?.toInt() ?? 0; + final bool available; + final double minDb, maxDb, currentDb; + final int token; + double get fraction => available && maxDb > minDb + ? ((currentDb - minDb) / (maxDb - minDb)).clamp(0, 1) + : 0; +} + +Future setUsbHardwareVolume(double fraction) async { + final state = usbHardwareVolume.value; + if (state == null || !state.available) return; + final result = await MusicPlaybackDeck._channel + .invokeMapMethod('volume', { + 'token': state.token, + 'db': state.minDb + fraction.clamp(0, 1) * (state.maxDb - state.minDb), + }); + if (result != null && usbHardwareVolume.value?.token == state.token) { + usbHardwareVolume.value = UsbVolumeState(result); + } +} + class UsbAudioStatus { const UsbAudioStatus({ this.reason = 'idle', @@ -72,6 +107,7 @@ class MusicPlaybackDeck { DeviceFileSource? _source; bool _routeLost = false; bool _nativeStarted = false; + bool _volumeBlocked = false; PlayerState _directState = PlayerState.stopped; UsbAudioStatus _preparedStatus = const UsbAudioStatus(); @@ -113,6 +149,8 @@ class MusicPlaybackDeck { bool directUsb = false, bool allowDop = false, bool requiresDsd = false, + bool allowFixedVolume = false, + bool dapExclusive = false, }) async { await stop(); _source = source; @@ -142,9 +180,17 @@ class MusicPlaybackDeck { 'token': _token, 'direct': directUsb, 'allowDop': allowDop, + 'allowFixedVolume': allowFixedVolume, + 'dapExclusive': dapExclusive, + 'requiresDsd': requiresDsd, }); if (_disposed || preparingToken != _token) return; if (response?['ready'] == true) { + _volumeBlocked = response?['volumeBlocked'] == true; + final volume = response?['volume']; + usbHardwareVolume.value = volume is Map + ? UsbVolumeState(volume) + : null; _direct = true; _directState = PlayerState.stopped; _duration = Duration( @@ -158,7 +204,9 @@ class MusicPlaybackDeck { transport: response?['transport'] as String? ?? 'pcm', driver: response?['driver'] as String? ?? 'android', ); - usbAudioStatus.value = const UsbAudioStatus(reason: 'ready'); + usbAudioStatus.value = UsbAudioStatus( + reason: _volumeBlocked ? 'volume_unavailable' : 'ready', + ); _durations.add(_duration!); return; } @@ -211,6 +259,12 @@ class MusicPlaybackDeck { Future resume() async { if (!_direct) return _ordinary.resume(); + if (_volumeBlocked) { + usbAudioStatus.value = const UsbAudioStatus(reason: 'volume_unavailable'); + throw UsbOutputUnavailable( + 'DAC hardware volume unavailable; check audio output settings', + ); + } final token = _token; // Before first playback the SAF lease is still open. After playback starts, // reconnect through the handler to obtain a fresh lease for the same song. @@ -219,6 +273,14 @@ class MusicPlaybackDeck { await _channel.invokeMethod('resume'); } on PlatformException { if (token != _token || !_direct || _disposed) return; + if (_preparedStatus.driver == 'usb_direct') { + _routeLost = true; + _setState(PlayerState.paused); + usbAudioStatus.value = const UsbAudioStatus(reason: 'route_changed'); + throw UsbOutputUnavailable( + 'Direct USB output changed; reconnect before playing', + ); + } if (_nativeStarted) { _routeLost = true; _setState(PlayerState.paused); @@ -297,6 +359,8 @@ class MusicPlaybackDeck { await _channel.invokeMethod('stop'); _setState(PlayerState.stopped); _direct = false; + _volumeBlocked = false; + usbHardwareVolume.value = null; usbAudioStatus.value = const UsbAudioStatus(); } await _ordinary.stop(); diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index b5d9be98..26474e29 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -46,6 +46,8 @@ bool _autoMixEnabled = false; bool _usbBitPerfectEnabled = false; bool _usbDirectEnabled = false; bool _usbDopEnabled = false; +bool _usbAllowFixedVolume = false; +bool _dapExclusiveEnabled = false; MusicPlayerHandler? _activeMusicPlayerHandler; PlaybackNotification _notificationPresentation = const PlaybackNotification(); Future Function(MediaItem)? _toggleNotificationFavorite; @@ -111,9 +113,16 @@ void setUsbBitPerfectEnabled(bool enabled) { // Apply at the next source boundary; never raise a playing track's volume. } -void setUsbOutputOptions({required bool direct, required bool allowDop}) { +void setUsbOutputOptions({ + required bool direct, + required bool allowDop, + bool allowFixedVolume = false, + bool dapExclusive = false, +}) { _usbDirectEnabled = direct; _usbDopEnabled = allowDop; + _usbAllowFixedVolume = allowFixedVolume; + _dapExclusiveEnabled = dapExclusive; } /// Refreshes gain tags after a successful file update, including SAF copies. @@ -1377,8 +1386,11 @@ class MusicPlayerHandler extends BaseAudioHandler DeviceFileSource(path), preferBitPerfect: _usbBitPerfectEnabled, directUsb: _usbDirectEnabled, + allowFixedVolume: _usbAllowFixedVolume, + dapExclusive: _dapExclusiveEnabled, allowDop: _usbDopEnabled, requiresDsd: + media.bitDepth == 1 || const ['dsf', 'dff'].contains(media.format?.toLowerCase()) || media.source.toLowerCase().endsWith('.dsf') || media.source.toLowerCase().endsWith('.dff'), @@ -1489,7 +1501,7 @@ class MusicPlayerHandler extends BaseAudioHandler playbackLease = null; } } catch (directError) { - if (directError is UsbDsdUnavailable || + if (directError is UsbOutputUnavailable || playbackLease == null || !_isCurrentPlayRequest(generation, media)) { rethrow; diff --git a/lib/widgets/mornye_volume_control.dart b/lib/widgets/mornye_volume_control.dart index 9d3af410..34fca02d 100644 --- a/lib/widgets/mornye_volume_control.dart +++ b/lib/widgets/mornye_volume_control.dart @@ -7,6 +7,19 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/widgets/mornye_player_slider.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:volume_controller/volume_controller.dart'; +import 'package:spotiflac_android/services/music_playback_deck.dart'; + +final usbVolumeProvider = StreamProvider.autoDispose((ref) { + final events = StreamController(); + void changed() => events.add(usbHardwareVolume.value); + usbHardwareVolume.addListener(changed); + changed(); + ref.onDispose(() { + usbHardwareVolume.removeListener(changed); + unawaited(events.close()); + }); + return events.stream; +}); /// A single shared subscription for the visible player, including hardware /// volume-button changes. This controls system volume, not ReplayGain gain. @@ -29,6 +42,7 @@ final systemVolumeWriterProvider = Provider Function(double)>(( ref, ) { return (value) { + if (usbHardwareVolume.value != null) return setUsbHardwareVolume(value); final volume = VolumeController.instance; volume.showSystemUI = false; return volume.setVolume(value.clamp(0, 1)); @@ -60,10 +74,15 @@ class _MornyeVolumeControlState extends ConsumerState { Future _flushVolume() async { _writing = true; final write = ref.read(systemVolumeWriterProvider); + final usbToken = usbHardwareVolume.value?.token; try { // Send changes during the gesture. If the platform is still processing // a write, keep only the newest value instead of queuing stale positions. while (mounted && _pendingVolume != null) { + if (usbHardwareVolume.value?.token != usbToken) { + _pendingVolume = null; + break; + } final value = _pendingVolume!; _pendingVolume = null; try { @@ -80,11 +99,18 @@ class _MornyeVolumeControlState extends ConsumerState { @override Widget build(BuildContext context) { - final volume = ref.watch(systemVolumeProvider).value; + final usb = ref.watch(usbVolumeProvider).value ?? usbHardwareVolume.value; + final volume = usb == null + ? ref.watch(systemVolumeProvider).value + : usb.available + ? usb.fraction + : null; final value = (_preview ?? volume ?? 0).clamp(0.0, 1.0); String percentage(double volume) => '${(volume * 100).round()}%'; return Semantics( - label: context.l10n.nowPlayingVolume, + label: usb == null + ? context.l10n.nowPlayingVolume + : context.l10n.usbHardwareVolume, slider: true, enabled: volume != null, excludeSemantics: true, diff --git a/pubspec.yaml b/pubspec.yaml index a6e16215..280eda5c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -112,6 +112,8 @@ flutter: - assets/images/ - assets/fonts/Inter-LICENSE.txt - assets/licenses/usb.txt + - assets/licenses/oboe.txt + - assets/licenses/wavpack.txt fonts: - family: Inter diff --git a/rust_backend/crates/mobile/src/usb_audio/README.md b/rust_backend/crates/mobile/src/usb_audio/README.md index 031593d3..2a83e95c 100644 --- a/rust_backend/crates/mobile/src/usb_audio/README.md +++ b/rust_backend/crates/mobile/src/usb_audio/README.md @@ -1,8 +1,9 @@ # Direct USB audio -Android USB host transport for local FLAC/WAV PCM and uncompressed DSF/DSDIFF. +Android USB host transport for local FLAC/WAV PCM, uncompressed DSF/DSDIFF and +lossless WavPack DSD. The existing Android 14 preferred-mixer path remains available. Direct USB is -opt-in under Settings > Library > Playback > USB bit-perfect audio. +opt-in under Settings > Library > Playback > Hi-res and bit-perfect audio. ## Ownership and playback @@ -23,13 +24,37 @@ opt-in under Settings > Library > Playback > USB bit-perfect audio. completed frame. Disconnect pauses playback; it never redirects active USB playback to the speaker. Pending permission requests can be cancelled by Next. - ReplayGain, AutoMix, software volume and playback-rate processing are bypassed - while USB mode is selected. Volume is controlled at the DAC. + while USB mode is selected. Volume is controlled at the DAC's USB Feature Unit. + +## Hardware volume + +- Follow the selected playback terminal to a UAC1/UAC2 Feature Unit. Do not + modify capture controls, guess a mixer/selector route, or advertise volume + when only some channels are writable. +- Read GET_MIN/MAX/RES (UAC1) or RANGE (UAC2), then SET_CUR and read back CUR. + Master volume is preferred; otherwise all playback channels must be writable. + Values are signed 1/256 dB. Gaps/steps round down; positive gain is not offered. +- Before the first audio transfer, lower each channel to at most -40 dB, keeping + quieter or already silent channels. Reuse a user-selected volume between songs + on the same attached device, respecting a hardware knob lowered externally. + Remove remembered levels on detach. This is attenuation, not a guarantee of + safe acoustic output for every amplifier/headphone combination. +- The Mornye player slider and Library playback settings control verified DAC + volume. They never modify DSD/DoP samples or apply software gain. Phone volume + buttons may not affect direct USB output. +- Warn before enabling the mode. A DAC without verified hardware volume stays + paused unless the user explicitly enables fixed-volume output after a second + warning. A failure after direct-USB preparation never redirects to the speaker. ## DSD `DsdFile.kt` normalizes DSF channel blocks/LSB ordering and DFF byte-interleaved MSB data without loading the file into memory. Native U32 uses the device's byte ordering. DoP uses 24-bit words, left-aligned when carried in 32-bit slots. +WavPack 5.9.0 decodes compressed DSD to the same MSB-first bitstream through a +small JNI bridge (`OPEN_DSD_NATIVE`, checksums enabled). It retains its own file +descriptor across SAF lease closure and never converts DSD into PCM. Reads and +seeks are bounded; only mono/stereo DSD64 through DSD512 are accepted. The transport assigns alternating 05/FA markers across all frames, including inserted DSD silence. Seeking retains channel/frame alignment. @@ -46,9 +71,9 @@ settings; no product-name match or assumption that PCM support implies DSD: DoP is a separate opt-in for a DAC explicitly known to support it; USB audio descriptors do not announce DoP support. Keep it disabled for ordinary USB headsets. Unsupported DSD is stopped rather than interpreted as PCM audio. -WavPack DSD, DST compression, DSD-to-PCM conversion and vendor DAP outputs are not -implemented. SAF scanning reads basic DSD format/duration and uses the filename -for metadata; embedded DSD artwork/tags are not yet imported. +DSD DST compression, DSD-to-PCM conversion and vendor-specific internal DAP DSD +commands are not implemented. SAF scanning reads basic DSD format/duration and +uses the filename for metadata; embedded DSD artwork/tags are not yet imported. ## Verification @@ -57,13 +82,35 @@ for metadata; embedded DSD artwork/tags are not yet imported. - JVM tests: DSF padding/bit order, DFF channel order, native LE/BE, DoP layout, seeking, truncated containers and compressed-DST rejection. - Android instrumentation: absent USB fallback, real native FFI error cleanup, - and integer WAV decode/seek after the original SAF descriptor is closed. + integer WAV decode/seek after the original SAF descriptor is closed, synthetic + WavPack DSD byte equality through native/DoP packing, seek and EOF behavior. +- Hardware volume host tests: UAC1/UAC2 ranges and read-back, failed verification, + silence preservation, independent channel attenuation and playback topology. - Flutter tests: opt-in settings, transport options, cancellation, stale replies, DSD failure without PCM fallback, existing playback/queue/DSP behavior. -Physical PCM/DoP/native-DSD output, DAC lock indication, hardware volume and long -playback stability still require real USB hardware. Host tests, emulator tests -and successful ARM32/ARM64 builds do not establish DAC compatibility. +The user reported working physical USB DSP playback with the previous direct +USB build (device model unspecified). New hardware volume, native DSD/DoP and +long playback stability still require device testing. Host tests, emulator +tests and successful ARM32/ARM64 builds do not establish DAC compatibility. + +## DAP PCM hi-res + +The separate **DAP hi-res (AAudio exclusive)** option uses Oboe 1.11.0 on Android +8.1+. It opens at the source rate/channels and sufficient precision, with Oboe +conversion disabled. Integer PCM is padded losslessly when necessary; float +output is allowed only for <=24-bit sources with exact representation. A bounded +single-producer/single-consumer queue feeds the audio callback without allocation, +locking or file I/O. Hardware timestamps exclude inserted silence from position. + +Android may grant Shared when Exclusive was requested. Verify the actual API, +sharing mode, rate, channels and format before reporting active output. If no +matching exclusive stream is available, close it and use ordinary playback. +DSD is rejected before reaching this PCM engine. Disconnect after starting +pauses playback. This does not claim to bypass undocumented vendor DSP. + +The current emulator only grants Shared; instrumentation verifies rejection. +Actual exclusive DAP playback and route-specific hardware behavior need a DAP. ## Reference and dependencies @@ -71,4 +118,7 @@ USB identity/format facts were checked against [Linux USB audio quirks](https://github.com/torvalds/linux/blob/master/sound/usb/quirks.c). The Android-only `libusb1-sys` dependency builds its unmodified bundled libusb; license texts and rebuild information are in `assets/licenses/usb.txt` and in -the app's license registry. No additional audio SDK or decoder library is added. +the app's license registry. Oboe (Apache-2.0) and WavPack (BSD-3-Clause) are built +statically into `libspotiflac_audio.so` for ARM32/ARM64. Their pinned release URLs +and SHA-256 checksums are in `android/app/src/main/cpp/CMakeLists.txt`; licenses +are included in `assets/licenses/oboe.txt` and `assets/licenses/wavpack.txt`. diff --git a/rust_backend/crates/mobile/src/usb_audio/mod.rs b/rust_backend/crates/mobile/src/usb_audio/mod.rs index 9a661e90..b850c127 100644 --- a/rust_backend/crates/mobile/src/usb_audio/mod.rs +++ b/rust_backend/crates/mobile/src/usb_audio/mod.rs @@ -8,6 +8,8 @@ mod framing; #[cfg(target_os = "android")] #[allow(unsafe_code)] // libusb ownership is confined to this worker module. mod transport; +#[cfg(any(test, target_os = "android"))] +mod volume; use std::sync::Arc; @@ -34,6 +36,17 @@ pub struct UsbOutputFormat { pub encoding: String, } +#[derive(uniffi::Record, Clone, Default)] +pub struct UsbHardwareVolume { + pub available: bool, + pub min_db: f64, + pub max_db: f64, + pub current_db: f64, + /// Quietest channel before startup attenuation; restoring a previous UI + /// setting must respect a DAC volume knob that was lowered externally. + pub restore_limit_db: f64, +} + #[derive(uniffi::Object)] pub struct UsbDirectOutput { #[cfg(target_os = "android")] @@ -118,4 +131,21 @@ impl UsbDirectOutput { #[cfg(target_os = "android")] self.output.close(); } + + pub fn volume(&self) -> UsbHardwareVolume { + #[cfg(target_os = "android")] + return self.output.volume(); + #[cfg(not(target_os = "android"))] + unreachable!("Android-only constructor") + } + + pub fn set_volume(&self, db: f64) -> Result { + #[cfg(target_os = "android")] + return self.output.set_volume(db).map_err(Into::into); + #[cfg(not(target_os = "android"))] + { + let _ = db; + unreachable!("Android-only constructor") + } + } } diff --git a/rust_backend/crates/mobile/src/usb_audio/transport.rs b/rust_backend/crates/mobile/src/usb_audio/transport.rs index f03fea22..68997c2a 100644 --- a/rust_backend/crates/mobile/src/usb_audio/transport.rs +++ b/rust_backend/crates/mobile/src/usb_audio/transport.rs @@ -1,8 +1,9 @@ //! All raw libusb pointers belong to one thread. Transfer buffers remain at //! stable addresses until callbacks complete, including cancellation on drop. use super::{ - UsbOutputFormat, + UsbHardwareVolume, UsbOutputFormat, descriptors::{self, Alternate, Endpoint, PacketClock}, + volume::{self, Volume}, }; use libusb1_sys as usb; use std::{ @@ -22,6 +23,13 @@ struct State { flush: u64, flushed: u64, error: Option, + volume: UsbHardwareVolume, + volume_requests: VecDeque, +} +struct VolumeRequest { + db: f64, + deadline: Instant, + reply: mpsc::SyncSender>, } type Shared = Arc<(Mutex, Condvar)>; @@ -61,9 +69,14 @@ impl Output { } // SAF/device descriptors are opened by Android and stay owned by // Kotlin until this worker has joined. No device discovery/root. - let result = Session::open(fd, candidates); + let result = Session::open(fd, candidates, &raw); match result { Ok(mut session) => { + state.0.lock().unwrap().volume = session + .volume + .as_ref() + .map(Volume::snapshot) + .unwrap_or_default(); let _ = tx.send(Ok(session.format.clone())); if let Err(error) = session.run(&state) { let mut s = state.0.lock().unwrap(); @@ -157,6 +170,30 @@ impl Output { let _ = t.join(); } } + pub fn volume(&self) -> UsbHardwareVolume { + self.shared.0.lock().unwrap().volume.clone() + } + pub fn set_volume(&self, db: f64) -> Result { + let (reply, result) = mpsc::sync_channel(1); + { + let mut s = self.shared.0.lock().unwrap(); + if s.closed || s.error.is_some() || !s.volume.available { + return Err("USB hardware volume unavailable".into()); + } + if s.volume_requests.len() >= 8 { + return Err("USB volume requests busy".into()); + } + s.volume_requests.push_back(VolumeRequest { + db, + deadline: Instant::now() + Duration::from_secs(2), + reply, + }); + self.shared.1.notify_all(); + } + result + .recv_timeout(Duration::from_secs(4)) + .map_err(|_| "USB volume request timed out".to_string())? + } } impl Drop for Output { fn drop(&mut self) { @@ -270,9 +307,26 @@ struct Session { slots: Vec>, feedback_slot: Option>, format: UsbOutputFormat, + volume: Option, +} +impl volume::Control for Session { + fn transfer( + &self, + input: bool, + request: u8, + value: u16, + index: u16, + data: &mut [u8], + ) -> Result<(), String> { + self.control(input, request, value, index, data, false) + } } impl Session { - fn open(fd: i32, candidates: Vec<(Alternate, UsbOutputFormat)>) -> Result { + fn open( + fd: i32, + candidates: Vec<(Alternate, UsbOutputFormat)>, + raw: &[u8], + ) -> Result { let mut s = Self { context: ptr::null_mut(), handle: ptr::null_mut(), @@ -285,6 +339,7 @@ impl Session { slots: vec![], feedback_slot: None, format: candidates[0].1.clone(), + volume: None, }; unsafe { // NO_DEVICE_DISCOVERY is required on Android; the USB permission @@ -313,6 +368,13 @@ impl Session { if let Some(ep) = &s.feedback { s.feedback_slot = Some(Slot::new(1, ep.max_packet)?); } + s.volume = volume::feature( + raw, + s.alternate.control, + s.alternate.terminal, + s.format.channels, + ) + .and_then(|feature| Volume::open(&s, feature).ok()); return Ok(s); } Err(failure) @@ -472,6 +534,28 @@ impl Session { if s.closed { break; } + if let Some(request) = s.volume_requests.pop_front() { + drop(s); + if Instant::now() > request.deadline { + let _ = request.reply.send(Err("USB volume request expired".into())); + } else { + let mut volume = self + .volume + .take() + .ok_or("USB hardware volume unavailable")?; + let result = volume.set(self, request.db); + self.volume = Some(volume); + if let Ok(snapshot) = &result { + shared.0.lock().unwrap().volume = snapshot.clone(); + } + let error = result.as_ref().err().cloned(); + let _ = request.reply.send(result); + if let Some(error) = error { + return Err(error); + } + } + s = shared.0.lock().unwrap(); + } if s.flush != s.flushed { drop(s); self.drain(); diff --git a/rust_backend/crates/mobile/src/usb_audio/volume.rs b/rust_backend/crates/mobile/src/usb_audio/volume.rs new file mode 100644 index 00000000..1e2127e2 --- /dev/null +++ b/rust_backend/crates/mobile/src/usb_audio/volume.rs @@ -0,0 +1,460 @@ +//! USB Audio Feature Unit volume. Values are signed 1/256 dB, not PCM gain. +use super::UsbHardwareVolume; +use std::collections::HashMap; + +pub trait Control { + fn transfer( + &self, + input: bool, + request: u8, + value: u16, + index: u16, + data: &mut [u8], + ) -> Result<(), String>; +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Feature { + pub unit: u8, + pub interface: u8, + pub uac2: bool, + pub channels: Vec, +} + +/// Follow only an unambiguous playback terminal chain. Never change a capture +/// Feature Unit or guess which input of a mixer/selector is currently routed. +pub fn feature(raw: &[u8], interface: u8, terminal: u8, channels: u8) -> Option { + let mut active = false; + let mut uac2 = false; + let mut sources = HashMap::new(); + let mut features = HashMap::new(); + let mut outputs = Vec::new(); + let mut offset = 0; + while offset + 2 <= raw.len() { + let len = raw[offset] as usize; + if len < 2 || len > raw.len() - offset { + return None; + } + let d = &raw[offset..offset + len]; + offset += len; + if d[1] == 4 && len >= 9 { + active = d[2] == interface && d[5] == 1 && d[6] == 1; + uac2 = d[7] == 0x20; + } else if active && d[1] == 0x24 && len >= 4 { + match d[2] { + 3 if len >= 9 => { + sources.insert(d[3], d[7]); + outputs.push(d[3]); + } + 6 if len >= 7 => { + sources.insert(d[3], d[4]); + let size = if uac2 { 4 } else { d[5] as usize }; + let start = if uac2 { 5 } else { 6 }; + if size == 0 || size > 4 || len < start + size + 1 { + continue; + } + let writable = |channel: usize| { + let pos = start + channel * size; + if pos + size >= len { + return false; + } + if uac2 { + (d[pos] >> 2) & 3 == 3 + } else { + d[pos] & 2 != 0 + } + }; + let controlled = if writable(0) { + vec![0] + } else if (1..=channels as usize).all(writable) { + (1..=channels).collect() + } else { + continue; + }; + features.insert( + d[3], + Feature { + unit: d[3], + interface, + uac2, + channels: controlled, + }, + ); + } + _ => {} + } + } + } + let mut matches = Vec::new(); + for mut id in outputs { + let mut seen = Vec::new(); + let mut candidate = None; + while id != terminal && !seen.contains(&id) { + seen.push(id); + if candidate.is_none() { + candidate = features.get(&id).cloned(); + } + id = match sources.get(&id) { + Some(source) => *source, + None => break, + }; + } + if id == terminal + && let Some(f) = candidate + { + matches.push(f); + } + } + matches.dedup(); + if matches.len() == 1 { + matches.pop() + } else { + None + } +} + +#[derive(Clone, Copy, Debug)] +struct Range { + min: i16, + max: i16, + step: i16, +} + +impl Range { + fn parse(bytes: &[u8]) -> Result { + let r = Self { + min: i16::from_le_bytes([bytes[0], bytes[1]]), + max: i16::from_le_bytes([bytes[2], bytes[3]]), + step: i16::from_le_bytes([bytes[4], bytes[5]]), + }; + if r.min == i16::MIN || r.min > r.max || r.step < 0 { + return Err("Invalid USB volume range".into()); + } + Ok(r) + } + fn floor(self, value: i32) -> i16 { + let value = value.clamp(self.min as i32, self.max as i32); + let step = i32::from(self.step).max(1); + (self.min as i32 + (value - self.min as i32) / step * step) as i16 + } +} + +pub struct Volume { + feature: Feature, + ranges: Vec>, + snapshot: UsbHardwareVolume, +} + +impl Volume { + pub fn open(control: &impl Control, feature: Feature) -> Result { + let index = (feature.unit as u16) << 8 | feature.interface as u16; + let mut ranges = Vec::new(); + let mut current = i16::MIN; + let mut startup = Vec::new(); + let mut restore_limit = i16::MAX; + for channel in &feature.channels { + let selector = 0x200 | *channel as u16; + let mut data = [0; 2]; + control.transfer( + true, + if feature.uac2 { 1 } else { 0x81 }, + selector, + index, + &mut data, + )?; + current = current.max(i16::from_le_bytes(data)); + restore_limit = restore_limit.min(i16::from_le_bytes(data)); + startup.push(i32::from(i16::from_le_bytes(data).min(-40 * 256))); + let r = if feature.uac2 { + control.transfer(true, 2, selector, index, &mut data)?; + let count = u16::from_le_bytes(data) as usize; + if !(1..=16).contains(&count) { + return Err("Unsupported USB volume ranges".into()); + } + let mut data = vec![0; 2 + 6 * count]; + control.transfer(true, 2, selector, index, &mut data)?; + if u16::from_le_bytes([data[0], data[1]]) as usize != count { + return Err("USB volume range changed".into()); + } + data[2..] + .as_chunks::<6>() + .0 + .iter() + .map(|bytes| Range::parse(bytes)) + .collect::, _>>()? + } else { + let mut data = [0; 6]; + for (request, bytes) in (0x82..=0x84).zip(data.as_chunks_mut::<2>().0) { + control.transfer(true, request, selector, index, bytes)?; + } + vec![Range::parse(&data)?] + }; + ranges.push(r); + } + let min = ranges + .iter() + .map(|r| r.iter().map(|r| r.min).min().unwrap()) + .max() + .unwrap(); + let max = ranges + .iter() + .map(|r| r.iter().map(|r| r.max).max().unwrap()) + .min() + .unwrap() + .min(0); + if min >= max || min > -40 * 256 { + return Err("USB volume cannot provide startup attenuation".into()); + } + let mut volume = Self { + feature, + ranges, + snapshot: UsbHardwareVolume { + available: true, + min_db: min as f64 / 256.0, + max_db: max as f64 / 256.0, + current_db: current as f64 / 256.0, + restore_limit_db: restore_limit as f64 / 256.0, + }, + }; + // Never raise the device's current volume on connection. Read-back is + // mandatory before any audio transfer can start. + volume.set_targets(control, &startup)?; + Ok(volume) + } + pub fn snapshot(&self) -> UsbHardwareVolume { + self.snapshot.clone() + } + + pub fn set(&mut self, control: &impl Control, db: f64) -> Result { + if !db.is_finite() || db > 0.0 || db < -128.0 { + return Err("Invalid USB volume".into()); + } + let target = (db.min(self.snapshot.max_db) * 256.0).floor() as i32; + self.set_targets(control, &vec![target; self.feature.channels.len()]) + } + + fn set_targets( + &mut self, + control: &impl Control, + targets: &[i32], + ) -> Result { + let index = (self.feature.unit as u16) << 8 | self.feature.interface as u16; + let mut loudest = i16::MIN; + for ((channel, ranges), target) in self + .feature + .channels + .iter() + .zip(&self.ranges) + .zip(targets.iter().copied()) + { + // -32768 is USB's negative-infinity value. Preserve a channel that + // was already silent; startup must never raise a quieter channel. + let value = if target == i16::MIN as i32 { + i16::MIN + } else { + ranges + .iter() + .filter(|r| r.min as i32 <= target) + .map(|r| r.floor(target)) + .max() + .ok_or("USB volume below supported range")? + }; + let selector = 0x200 | *channel as u16; + control.transfer(false, 1, selector, index, &mut value.to_le_bytes())?; + let mut readback = [0; 2]; + control.transfer( + true, + if self.feature.uac2 { 1 } else { 0x81 }, + selector, + index, + &mut readback, + )?; + let actual = i16::from_le_bytes(readback); + if actual != value { + return Err("USB volume read-back mismatch".into()); + } + loudest = loudest.max(actual); + } + self.snapshot.current_db = loudest as f64 / 256.0; + Ok(self.snapshot()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + struct Fake { + db: Cell, + mismatch: bool, + } + impl Control for Fake { + fn transfer( + &self, + input: bool, + request: u8, + _: u16, + _: u16, + data: &mut [u8], + ) -> Result<(), String> { + if !input { + if !self.mismatch { + self.db.set(i16::from_le_bytes([data[0], data[1]])); + } + } else if request == 2 { + let range = [1, 0, 0, 0xa0, 0, 0, 0, 1]; // -96..0 dB, 1 dB steps. + data.copy_from_slice(&range[..data.len()]); + } else { + data.copy_from_slice(&self.db.get().to_le_bytes()); + } + Ok(()) + } + } + #[test] + fn startup_attenuates_without_raising_and_rounds_down() { + let io = Fake { + db: Cell::new(0), + mismatch: false, + }; + let f = Feature { + unit: 2, + interface: 0, + uac2: true, + channels: vec![0], + }; + let mut volume = Volume::open(&io, f.clone()).unwrap(); + assert_eq!(volume.snapshot().current_db, -40.0); + assert_eq!(volume.set(&io, -25.2).unwrap().current_db, -26.0); + io.db.set(-70 * 256); + assert_eq!(Volume::open(&io, f).unwrap().snapshot().current_db, -70.0); + } + #[test] + fn failed_readback_cannot_report_safe_volume() { + let io = Fake { + db: Cell::new(0), + mismatch: true, + }; + assert!( + Volume::open( + &io, + Feature { + unit: 2, + interface: 0, + uac2: true, + channels: vec![0] + } + ) + .is_err() + ); + } + #[test] + fn startup_preserves_silent_channels() { + let io = Fake { + db: Cell::new(i16::MIN), + mismatch: false, + }; + let v = Volume::open( + &io, + Feature { + unit: 2, + interface: 0, + uac2: true, + channels: vec![0], + }, + ) + .unwrap(); + assert_eq!(v.snapshot().current_db, -128.0); + assert_eq!(io.db.get(), i16::MIN); + } + #[test] + fn topology_selects_playback_not_capture() { + let raw = [ + 9, 4, 0, 0, 0, 1, 1, 0x20, 0, 18, 0x24, 6, 2, 1, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 12, 0x24, 3, 3, 1, 3, 0, 2, 1, 0, 0, 0, + ]; + assert_eq!(feature(&raw, 0, 1, 2).unwrap().unit, 2); + assert!(feature(&raw, 0, 9, 2).is_none()); + assert!(feature(&raw, 1, 1, 2).is_none()); + } + + #[test] + fn uac1_control_uses_min_max_resolution_and_verified_current() { + struct Uac1(Cell); + impl Control for Uac1 { + fn transfer( + &self, + input: bool, + request: u8, + value: u16, + index: u16, + data: &mut [u8], + ) -> Result<(), String> { + assert_eq!((value, index), (0x200, 0x200)); + if !input { + assert_eq!(request, 1); + self.0.set(i16::from_le_bytes([data[0], data[1]])); + } else { + let value: i16 = match request { + 0x81 => self.0.get(), + 0x82 => -80 * 256, + 0x83 => 0, + 0x84 => 128, + _ => panic!("Unexpected UAC1 request"), + }; + data.copy_from_slice(&value.to_le_bytes()); + } + Ok(()) + } + } + let raw = [ + 9, 4, 0, 0, 0, 1, 1, 0, 0, 10, 0x24, 6, 2, 1, 1, 3, 0, 0, 0, 9, 0x24, 3, 3, 1, 3, 0, 2, + 0, + ]; + let control = Uac1(Cell::new(-10 * 256)); + let mut volume = Volume::open(&control, feature(&raw, 0, 1, 2).unwrap()).unwrap(); + assert_eq!(volume.snapshot().current_db, -40.0); + assert_eq!(volume.set(&control, -23.1).unwrap().current_db, -23.5); + assert!(volume.set(&control, f64::NAN).is_err()); + assert!(volume.set(&control, 2.0).is_err()); + } + + #[test] + fn startup_keeps_independent_channels_at_or_below_their_old_levels() { + struct Stereo([Cell; 2]); + impl Control for Stereo { + fn transfer( + &self, + input: bool, + request: u8, + value: u16, + _: u16, + data: &mut [u8], + ) -> Result<(), String> { + let channel = (value & 255) as usize - 1; + if !input { + self.0[channel].set(i16::from_le_bytes([data[0], data[1]])); + } else if request == 2 { + data.copy_from_slice(&[1, 0, 0, 0xa0, 0, 0, 0, 1][..data.len()]); + } else { + data.copy_from_slice(&self.0[channel].get().to_le_bytes()); + } + Ok(()) + } + } + let io = Stereo([Cell::new(-70 * 256), Cell::new(-10 * 256)]); + let volume = Volume::open( + &io, + Feature { + unit: 2, + interface: 0, + uac2: true, + channels: vec![1, 2], + }, + ) + .unwrap(); + assert_eq!(io.0[0].get(), -70 * 256); + assert_eq!(io.0[1].get(), -40 * 256); + assert_eq!(volume.snapshot().restore_limit_db, -70.0); + } +} diff --git a/test/music_player_automix_test.dart b/test/music_player_automix_test.dart index 5f272b6a..bdcf3a62 100644 --- a/test/music_player_automix_test.dart +++ b/test/music_player_automix_test.dart @@ -180,10 +180,18 @@ void main() { expect(defaults.usbBitPerfect, isFalse); expect(defaults.usbDirect, isFalse); expect(defaults.usbDsdOverPcm, isFalse); + expect(defaults.usbAllowFixedVolume, isFalse); + expect(defaults.dapExclusive, isFalse); + final upgraded = AppSettings.fromJson({ + 'usbBitPerfect': true, + 'usbDirect': true, + }); + expect(upgraded.usbAllowFixedVolume, isFalse); final selected = defaults.copyWith( usbBitPerfect: true, usbDirect: true, usbDsdOverPcm: true, + usbAllowFixedVolume: true, autoMix: true, playbackNormalization: true, ); @@ -191,6 +199,13 @@ void main() { expect(restored.usbBitPerfect, isTrue); expect(restored.usbDirect, isTrue); expect(restored.usbDsdOverPcm, isTrue); + expect(restored.usbAllowFixedVolume, isTrue); + expect( + AppSettings.fromJson( + defaults.copyWith(dapExclusive: true).toJson(), + ).dapExclusive, + isTrue, + ); expect(restored.autoMix, isTrue); expect(restored.playbackNormalization, isTrue); }, @@ -218,6 +233,8 @@ void main() { late String? fallbackReason; late bool failResume; late bool fatal; + late bool volumeBlocked; + Map? hardwareVolume; late String transport; late Map prepareArguments; Completer? pauseReply; @@ -240,6 +257,8 @@ void main() { fallbackReason = null; failResume = false; fatal = false; + volumeBlocked = false; + hardwareVolume = null; transport = 'pcm'; prepareArguments = {}; pauseReply = null; @@ -263,6 +282,19 @@ void main() { 'sampleRate': 96000, 'bitDepth': 24, 'transport': transport, + 'driver': prepareArguments['direct'] == true + ? 'usb_direct' + : 'android', + 'volumeBlocked': volumeBlocked, + if (hardwareVolume != null) + 'volume': {...hardwareVolume!, 'token': token}, + }; + case 'volume': + expect((call.arguments as Map)['token'], token); + return { + ...hardwareVolume!, + 'token': token, + 'currentDb': (call.arguments as Map)['db'], }; case 'position': return position; @@ -293,6 +325,69 @@ void main() { expect(prepareArguments['allowDop'], isTrue); }); + test('hardware volume changes the DAC, never PCM gain', () async { + hardwareVolume = { + 'available': true, + 'minDb': -90.0, + 'maxDb': 0.0, + 'currentDb': -40.0, + }; + await deck.setSource( + DeviceFileSource('/one.flac'), + preferBitPerfect: true, + directUsb: true, + ); + expect(usbHardwareVolume.value?.currentDb, -40); + await setUsbHardwareVolume(0.5); + expect(usbHardwareVolume.value?.currentDb, -45); + expect(usbCalls, ['prepare', 'volume']); + expect(native.calls.where((call) => call.$2 == 'setVolume'), isEmpty); + await deck.stop(); + expect(usbHardwareVolume.value, isNull); + }); + + test( + 'uncontrolled DAC is blocked without ordinary full-volume fallback', + () async { + volumeBlocked = true; + hardwareVolume = {'available': false}; + await deck.setSource( + DeviceFileSource('/one.flac'), + preferBitPerfect: true, + directUsb: true, + ); + await expectLater(deck.resume(), throwsA(isA())); + expect(usbCalls, ['prepare']); + expect(usbAudioStatus.value.reason, 'volume_unavailable'); + expect(native.sources['usb-test'], isNull); + await setUsbHardwareVolume(1); + expect(usbCalls, ['prepare']); + }, + ); + + test( + 'AAudio exclusive and fixed volume require explicit options', + () async { + await deck.setSource( + DeviceFileSource('/one.wav'), + preferBitPerfect: true, + dapExclusive: true, + ); + expect(prepareArguments['dapExclusive'], isTrue); + expect(prepareArguments['allowFixedVolume'], isFalse); + expect(prepareArguments['direct'], isFalse); + fallbackReason = 'exclusive_unavailable'; + await deck.setSource( + DeviceFileSource('/one.wav'), + preferBitPerfect: true, + dapExclusive: true, + ); + expect(usbAudioStatus.value.reason, 'exclusive_unavailable'); + expect(deck.isDirect, isFalse); + expect(native.sources['usb-test'], '/one.wav'); + }, + ); + test( 'changing tracks cancels a pending USB permission without fallback', () async { @@ -337,7 +432,7 @@ void main() { directUsb: true, ); failResume = true; - await expectLater(deck.resume(), throwsA(isA())); + await expectLater(deck.resume(), throwsA(isA())); expect(native.sources['usb-test'], isNull); expect(deck.needsSourceReload, isTrue); }); @@ -437,6 +532,22 @@ void main() { expect(usbAudioStatus.value.reason, 'unsupported'); }); + test( + 'direct USB failure before first sound never redirects to speaker', + () async { + failResume = true; + await deck.setSource( + DeviceFileSource('/one.flac'), + preferBitPerfect: true, + directUsb: true, + ); + await expectLater(deck.resume(), throwsA(isA())); + expect(native.sources['usb-test'], isNull); + expect(native.playing, isEmpty); + expect(usbAudioStatus.value.reason, 'route_changed'); + }, + ); + test( 'route loss pauses and never starts the speaker automatically', () async {