mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-31 14:06:56 -06:00
Compare commits
576
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfb3815eb3 | ||
|
|
bf9fa3cc60 | ||
|
|
64669e9a7c | ||
|
|
6b84a4f046 | ||
|
|
67865b23e8 | ||
|
|
fe4bb3fe0c | ||
|
|
a4d3d8541f | ||
|
|
e8abd44712 | ||
|
|
ab1849cbf8 | ||
|
|
265645a8fc | ||
|
|
9b68982738 | ||
|
|
ef8a63f8c3 | ||
|
|
f0d5acb800 | ||
|
|
d47abe484d | ||
|
|
f431791c26 | ||
|
|
53a18ec037 | ||
|
|
44067c8779 | ||
|
|
52471b37e9 | ||
|
|
44663d9601 | ||
|
|
a36228f0ec | ||
|
|
e85a4801e9 | ||
|
|
239c78e3a0 | ||
|
|
2cfc56d515 | ||
|
|
06db0bd373 | ||
|
|
6c596fca13 | ||
|
|
5cdc72a0ed | ||
|
|
589608a5d3 | ||
|
|
55d9e8f86d | ||
|
|
84495f1c9b | ||
|
|
095b483ce1 | ||
|
|
816a6efb77 | ||
|
|
1b3e87c9c0 | ||
|
|
2a5d308c55 | ||
|
|
b1466e451c | ||
|
|
24543e1486 | ||
|
|
9b8283b908 | ||
|
|
30ef8306f8 | ||
|
|
7587ae2690 | ||
|
|
b74fca0fb0 | ||
|
|
01c8c85ebc | ||
|
|
4ef6458e4e | ||
|
|
152c2c45e6 | ||
|
|
96130d22c7 | ||
|
|
988d678eec | ||
|
|
7b339fe921 | ||
|
|
5703db0b80 | ||
|
|
1816f2dc92 | ||
|
|
e8940766d7 | ||
|
|
9ac264184e | ||
|
|
25d52c646a | ||
|
|
9d53e4d97a | ||
|
|
3776e3338f | ||
|
|
bec2d06ac6 | ||
|
|
01622f15bd | ||
|
|
6d268d5623 | ||
|
|
60f4342697 | ||
|
|
fdb2ea6504 | ||
|
|
93d3cbfecc | ||
|
|
dea681cd65 | ||
|
|
42ceeaf02f | ||
|
|
5b9f6e5028 | ||
|
|
b537e82bd9 | ||
|
|
4c9a036f95 | ||
|
|
90a2c386dd | ||
|
|
d8000e9756 | ||
|
|
9d3d02dbe7 | ||
|
|
8ae491b6d2 | ||
|
|
412af03dbf | ||
|
|
81e4c5c1ec | ||
|
|
a8fa1e53d3 | ||
|
|
798ee7d88f | ||
|
|
3a57a0edc8 | ||
|
|
12c224aa66 | ||
|
|
9c8afc1cf0 | ||
|
|
e9cda9000d | ||
|
|
d81a6c2d49 | ||
|
|
95ce47dbc6 | ||
|
|
16be837ebc | ||
|
|
a17dc98023 | ||
|
|
c62d2533b7 | ||
|
|
8a06cee05d | ||
|
|
8e196cfe2e | ||
|
|
7694771a5e | ||
|
|
582bfb2f7e | ||
|
|
b042a73dbf | ||
|
|
99d50d9811 | ||
|
|
979efff8a9 | ||
|
|
efbb5d9168 | ||
|
|
ef00e8322c | ||
|
|
ef4a4a0a9a | ||
|
|
603d62761a | ||
|
|
f3cc778673 | ||
|
|
e5e0d860f3 | ||
|
|
afb8e10ff2 | ||
|
|
b337801c66 | ||
|
|
00315b6fb4 | ||
|
|
a593ca0130 | ||
|
|
5e94c0503f | ||
|
|
f32b07a646 | ||
|
|
461520dca6 | ||
|
|
6258b15d8a | ||
|
|
18409ed52b | ||
|
|
d67961a402 | ||
|
|
7b8b69f5d3 | ||
|
|
8385c22616 | ||
|
|
bfd9bfbb89 | ||
|
|
14967a61bf | ||
|
|
1d8e879c96 | ||
|
|
06a303954e | ||
|
|
4e932785fe | ||
|
|
b96379e443 | ||
|
|
9d474a5388 | ||
|
|
47e1b0e523 | ||
|
|
ef106bff24 | ||
|
|
6bb5fbc978 | ||
|
|
3b3ccbd563 | ||
|
|
2adae474b7 | ||
|
|
5de30f96a1 | ||
|
|
d624f7ddb8 | ||
|
|
f53051a35f | ||
|
|
ed5c7ba19b | ||
|
|
437459f6eb | ||
|
|
168b54e0fb | ||
|
|
fdb8acd531 | ||
|
|
d92c8601a7 | ||
|
|
07665e676a | ||
|
|
4b1ba55bfa | ||
|
|
79e5bb6efb | ||
|
|
2e9ad2f421 | ||
|
|
172dc4edc6 | ||
|
|
31f0389397 | ||
|
|
c1bf044a85 | ||
|
|
2578ea69a3 | ||
|
|
3a1167ffd5 | ||
|
|
52bd33bb06 | ||
|
|
516ef23703 | ||
|
|
c6037f3f7d | ||
|
|
4a4e82daba | ||
|
|
0a9d29f227 | ||
|
|
055ddbee7a | ||
|
|
89d113a52a | ||
|
|
1e036eaa17 | ||
|
|
d65b04571c | ||
|
|
2a232036c2 | ||
|
|
bde132e0bf | ||
|
|
86b31e71d3 | ||
|
|
4b1c025427 | ||
|
|
783ce8f44b | ||
|
|
ef65ceafb8 | ||
|
|
da404551b1 | ||
|
|
f2ae345f20 | ||
|
|
18dd68eb1d | ||
|
|
f3a19480d8 | ||
|
|
d484a716ad | ||
|
|
6594badf37 | ||
|
|
69caa17ff7 | ||
|
|
ba467b2b72 | ||
|
|
9477e7cb08 | ||
|
|
29c014deef | ||
|
|
0dfb074820 | ||
|
|
eb4e2d21ba | ||
|
|
f887489603 | ||
|
|
705c2b9077 | ||
|
|
8840fbc87a | ||
|
|
0a6e08a9c3 | ||
|
|
fff096876d | ||
|
|
0224ca1e24 | ||
|
|
039b9f4f03 | ||
|
|
c3d37f367d | ||
|
|
253707f65f | ||
|
|
8a5da53870 | ||
|
|
6125f49ab1 | ||
|
|
6b4a7da010 | ||
|
|
3a28a65769 | ||
|
|
01fd2d0cb4 | ||
|
|
750db81371 | ||
|
|
79adfc0b57 | ||
|
|
90318fd6d6 | ||
|
|
95b14c8df0 | ||
|
|
08b0704b0c | ||
|
|
52dc4f9822 | ||
|
|
33d59b26a6 | ||
|
|
9afb266fce | ||
|
|
df0e8928d7 | ||
|
|
75eb152f03 | ||
|
|
2a43710e52 | ||
|
|
6c84416c3c | ||
|
|
807230167d | ||
|
|
7bcb4e4802 | ||
|
|
9209909ff7 | ||
|
|
edd2b0ef73 | ||
|
|
8ac95607b7 | ||
|
|
7b22753330 | ||
|
|
d8ab50b5fc | ||
|
|
8d252c8b2e | ||
|
|
686f8c9692 | ||
|
|
7f2ff83c44 | ||
|
|
0c19777c8a | ||
|
|
bf3cd7ac09 | ||
|
|
1bebc52bf4 | ||
|
|
194cf8c83d | ||
|
|
606ddac169 | ||
|
|
e10b245652 | ||
|
|
c0cb993408 | ||
|
|
cf755b8cd2 | ||
|
|
9ad9eb88e4 | ||
|
|
fca728501f | ||
|
|
55eff096ac | ||
|
|
c2788c50d4 | ||
|
|
fe573bb31f | ||
|
|
87db8f4048 | ||
|
|
ddb874e399 | ||
|
|
f4ed9919e6 | ||
|
|
f8370f90f5 | ||
|
|
b32d7bd926 | ||
|
|
14862a5139 | ||
|
|
6d4f9a41a5 | ||
|
|
a96cf50a3a | ||
|
|
49abc39806 | ||
|
|
8e360b336b | ||
|
|
afa850d697 | ||
|
|
7c9a18878b | ||
|
|
844830bf6f | ||
|
|
acbc3411e5 | ||
|
|
13316cf60e | ||
|
|
9176296e96 | ||
|
|
18dcb7b870 | ||
|
|
58a0482712 | ||
|
|
751a5755cb | ||
|
|
430c6a9e4e | ||
|
|
cc1bf5a62d | ||
|
|
123ae65e2b | ||
|
|
5b824e46c8 | ||
|
|
d438c753e1 | ||
|
|
69aea8fad3 | ||
|
|
768629a8ce | ||
|
|
a8594684a1 | ||
|
|
bbafeb85f6 | ||
|
|
695811ad13 | ||
|
|
82a83ae6f4 | ||
|
|
376e9f03ff | ||
|
|
2a675b7ae4 | ||
|
|
65829dbb1c | ||
|
|
e65a66c539 | ||
|
|
baea184bd1 | ||
|
|
1dcd6aa064 | ||
|
|
94390f49fa | ||
|
|
20927d7684 | ||
|
|
35ba903358 | ||
|
|
8b78e0306b | ||
|
|
f29b4ef325 | ||
|
|
bd5b5f17fa | ||
|
|
03815fbf4d | ||
|
|
19b1f33eb5 | ||
|
|
9b362a7244 | ||
|
|
0dc7b1ebec | ||
|
|
0c30ecffcc | ||
|
|
7a6df4cbbd | ||
|
|
d434cf6b15 | ||
|
|
c2f052715d | ||
|
|
edd1787353 | ||
|
|
ec8d1ed6a8 | ||
|
|
bec0cca48d | ||
|
|
b2e4dee0dd | ||
|
|
00faedcbd0 | ||
|
|
b0daf3213e | ||
|
|
5323c8ef25 | ||
|
|
67c51ef198 | ||
|
|
2895fa7dd0 | ||
|
|
5686d61f45 | ||
|
|
bee60a375c | ||
|
|
221661a46a | ||
|
|
7ec362962c | ||
|
|
dd57e2a9e6 | ||
|
|
82eff0f561 | ||
|
|
a16bb01915 | ||
|
|
6409a8cbbd | ||
|
|
6583ec6032 | ||
|
|
776a7af06c | ||
|
|
46bef836a3 | ||
|
|
1d562bb009 | ||
|
|
2094c7fbd2 | ||
|
|
e0deb3f4e5 | ||
|
|
bae80849dd | ||
|
|
4fcc387571 | ||
|
|
bca2b4af07 | ||
|
|
41f6670889 | ||
|
|
338411b99a | ||
|
|
fd1348f1b3 | ||
|
|
3b7a1f2b33 | ||
|
|
6d910609e1 | ||
|
|
ea531b723f | ||
|
|
0e6f5accb4 | ||
|
|
d8b2961651 | ||
|
|
0a2492b610 | ||
|
|
fbee545a5e | ||
|
|
7eb643fc5e | ||
|
|
927eeb58cf | ||
|
|
857882c740 | ||
|
|
92ed201515 | ||
|
|
67f486d4c4 | ||
|
|
7fb734dab8 | ||
|
|
ca1a6d443e | ||
|
|
cbfaff70e7 | ||
|
|
056bc288c4 | ||
|
|
468125aaf4 | ||
|
|
262392cd23 | ||
|
|
e927e295bb | ||
|
|
4ebdc35b04 | ||
|
|
70c9bcf7dd | ||
|
|
3f3f64746f | ||
|
|
dec75a8086 | ||
|
|
bb3a6bdd9a | ||
|
|
cd981d723e | ||
|
|
2bba5c334d | ||
|
|
dd3ef4b11a | ||
|
|
1c2128ec37 | ||
|
|
5051b4a45f | ||
|
|
edcdf770b2 | ||
|
|
e32c16b82e | ||
|
|
9c994f3f7b | ||
|
|
c4573d7fc8 | ||
|
|
446a059faf | ||
|
|
c46d6efd4c | ||
|
|
a47135b228 | ||
|
|
5d98ddfa69 | ||
|
|
574b3117e7 | ||
|
|
3498db9e51 | ||
|
|
5cd155bb64 | ||
|
|
456cb859b3 | ||
|
|
b4f7c4af6d | ||
|
|
0eee82a49c | ||
|
|
d4b7300a1e | ||
|
|
ae7155d95f | ||
|
|
93181762ee | ||
|
|
3cc0dc77cb | ||
|
|
46399aa729 | ||
|
|
8d5b38ab3a | ||
|
|
391a0aa66c | ||
|
|
3d1a30b61d | ||
|
|
5495378d7a | ||
|
|
9c7e5018b3 | ||
|
|
849d1a6b07 | ||
|
|
663ccd0004 | ||
|
|
061c238e68 | ||
|
|
50b0f68363 | ||
|
|
6c836b4389 | ||
|
|
ed42ac49f9 | ||
|
|
1831b4173a | ||
|
|
116ccfac5b | ||
|
|
0ef67b795b | ||
|
|
b764e299bc | ||
|
|
3d5b4dec8d | ||
|
|
b81bd5e064 | ||
|
|
007e6ab78f | ||
|
|
f08581b545 | ||
|
|
e5ca24cc3e | ||
|
|
7676de2749 | ||
|
|
7347e98e79 | ||
|
|
33a6c3dfbc | ||
|
|
74b3278a19 | ||
|
|
41c8561d51 | ||
|
|
c7a4c544c0 | ||
|
|
16335b8259 | ||
|
|
bdff15defb | ||
|
|
aa91e47073 | ||
|
|
3b7415db90 | ||
|
|
f4460e1ec7 | ||
|
|
285d9fecd5 | ||
|
|
8222048c10 | ||
|
|
7c061de737 | ||
|
|
f4233dc8f7 | ||
|
|
ba4c234344 | ||
|
|
0f6bd8a573 | ||
|
|
52294bd4a3 | ||
|
|
12d9f4dde6 | ||
|
|
6dfa00590b | ||
|
|
6bab8365e6 | ||
|
|
cd1ddff0c0 | ||
|
|
ec67064398 | ||
|
|
e59f079a02 | ||
|
|
b0339a418f | ||
|
|
d9b959ed6c | ||
|
|
75e019dde3 | ||
|
|
e5ca13d07f | ||
|
|
a307f767ba | ||
|
|
f868c2678f | ||
|
|
dac86131ee | ||
|
|
34117efc24 | ||
|
|
651b9049a8 | ||
|
|
a89c0c4db5 | ||
|
|
54254639cc | ||
|
|
fe8270e6dc | ||
|
|
2d1f25b1bb | ||
|
|
8eb1d1aa77 | ||
|
|
d85889aafa | ||
|
|
63717328c5 | ||
|
|
18579f546c | ||
|
|
3c53d25413 | ||
|
|
3ef50746c8 | ||
|
|
704ebc3c76 | ||
|
|
db9ea775fc | ||
|
|
8c33cefe1c | ||
|
|
5bb3e7b3b6 | ||
|
|
f58da8fe8a | ||
|
|
99dbd4d628 | ||
|
|
741010d50c | ||
|
|
3726c57d78 | ||
|
|
e5787ab7ac | ||
|
|
f146bc359f | ||
|
|
12224df8f5 | ||
|
|
baf102e13f | ||
|
|
bd7e8f271f | ||
|
|
b8e206273c | ||
|
|
5c1f9d7dc8 | ||
|
|
d14edcd51c | ||
|
|
06a4b21663 | ||
|
|
5183bc4dba | ||
|
|
ad8beac9b4 | ||
|
|
165fa98a90 | ||
|
|
bee999584f | ||
|
|
40c5e9b42a | ||
|
|
f188b66209 | ||
|
|
eada1c4cfe | ||
|
|
c82b3887f0 | ||
|
|
7be845abd1 | ||
|
|
b9e4873ffa | ||
|
|
8f8884ec05 | ||
|
|
d19d44cd1b | ||
|
|
52e19a871a | ||
|
|
5755795663 | ||
|
|
7f9b629964 | ||
|
|
1cd0233f49 | ||
|
|
e44c49cada | ||
|
|
30122bcea1 | ||
|
|
62cb92c4df | ||
|
|
0b9ead430f | ||
|
|
ddb9c9543d | ||
|
|
334cf40506 | ||
|
|
98d543f0d8 | ||
|
|
5fff2e8c78 | ||
|
|
b5ea465274 | ||
|
|
1a49fc70fb | ||
|
|
6fd17d7c0e | ||
|
|
3432b52373 | ||
|
|
81ec430341 | ||
|
|
1953f2ecc6 | ||
|
|
950f3e7085 | ||
|
|
6f8f1e7692 | ||
|
|
0b078d2f22 | ||
|
|
d26d6fec77 | ||
|
|
766dbc4845 | ||
|
|
72288fc77e | ||
|
|
7a62ab3be0 | ||
|
|
d6ea8cf001 | ||
|
|
c39773b62d | ||
|
|
34d8488284 | ||
|
|
8716dc9025 | ||
|
|
a47ce1cee5 | ||
|
|
921a36aa45 | ||
|
|
3617011342 | ||
|
|
bebca3aa85 | ||
|
|
c8021d4c90 | ||
|
|
c9b694907f | ||
|
|
312315acb9 | ||
|
|
5ec8c21b19 | ||
|
|
79e30c39d8 | ||
|
|
4ac47b3a7f | ||
|
|
d5e6fa32ef | ||
|
|
db23f873fd | ||
|
|
d9922c47a6 | ||
|
|
57682b03ef | ||
|
|
3189a1a512 | ||
|
|
4c38ef59c1 | ||
|
|
6e361262e1 | ||
|
|
2e5fc17f69 | ||
|
|
e53b772aea | ||
|
|
66caedd923 | ||
|
|
6d03eab566 | ||
|
|
6b3c73b7ff | ||
|
|
328760fd70 | ||
|
|
fe1d7e4c58 | ||
|
|
1b8baa10af | ||
|
|
7ca978fdb9 | ||
|
|
a8a36540d6 | ||
|
|
d81d5756d4 | ||
|
|
7f4ab5c76b | ||
|
|
1617733a98 | ||
|
|
de5f2e0fb3 | ||
|
|
777c08ef1a | ||
|
|
a4213cf90f | ||
|
|
d64d5f491e | ||
|
|
37f240ed54 | ||
|
|
c40078f32a | ||
|
|
0a5fc6ccba | ||
|
|
acaa91008c | ||
|
|
5e7309537e | ||
|
|
dab2bb956c | ||
|
|
edf4fc59f6 | ||
|
|
7ea64c1190 | ||
|
|
992d715493 | ||
|
|
9e84e0e351 | ||
|
|
fe3d759b53 | ||
|
|
2e3389178e | ||
|
|
5026b0655d | ||
|
|
3a702d9c6f | ||
|
|
ced57be723 | ||
|
|
f55010d244 | ||
|
|
b2f6361265 | ||
|
|
218cbb6670 | ||
|
|
557553cb06 | ||
|
|
6d626adf9f | ||
|
|
ec41f804ff | ||
|
|
a1e9660e60 | ||
|
|
35609b756b | ||
|
|
8c73d9c8b3 | ||
|
|
434c60047d | ||
|
|
23b7e023b6 | ||
|
|
89c7c9a0b0 | ||
|
|
9f4335388e | ||
|
|
b1cb6cb368 | ||
|
|
c9e53ee704 | ||
|
|
2d42cf278c | ||
|
|
56ffce6dec | ||
|
|
4c21ff8fe4 | ||
|
|
793432bbfc | ||
|
|
719fd909ee | ||
|
|
a9fa027528 | ||
|
|
57baab86d4 | ||
|
|
29497296a1 | ||
|
|
4bacfb13d5 | ||
|
|
d5f069417e | ||
|
|
4b98064e46 | ||
|
|
1f7a4ab3e4 | ||
|
|
4235def686 | ||
|
|
7d8c185d13 | ||
|
|
3765a32b9a | ||
|
|
5e7ca6de22 | ||
|
|
dabcb4840a | ||
|
|
eb5bf699c7 | ||
|
|
57edb43856 | ||
|
|
5a156c2468 | ||
|
|
7a58e49775 | ||
|
|
4e14c980a4 | ||
|
|
cf9cced90d | ||
|
|
e5d5996f7d | ||
|
|
93ddd419f5 | ||
|
|
c85952ecc3 | ||
|
|
5f4cc96578 | ||
|
|
fe919e5d57 | ||
|
|
96ea49c7f6 | ||
|
|
ae46052cac | ||
|
|
35e9d63bee | ||
|
|
9144570b6e | ||
|
|
c73a8e2272 | ||
|
|
f792eae580 | ||
|
|
0d18612a87 | ||
|
|
bff9ca1390 | ||
|
|
89ff513b78 | ||
|
|
7d173d3a25 | ||
|
|
4c4414d006 | ||
|
|
67158020f0 | ||
|
|
30372886cf | ||
|
|
217bea462d | ||
|
|
5e1c247eda | ||
|
|
faf2a13468 | ||
|
|
9f1a62470d | ||
|
|
e1e15cf3ab | ||
|
|
cfce845f70 | ||
|
|
87b348fecc | ||
|
|
acd9e7d54e | ||
|
|
33ad8264c8 | ||
|
|
75fae5535f | ||
|
|
092b3cc890 | ||
|
|
e26a5f5f42 | ||
|
|
4fd015f836 |
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,38 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
# craft-ui is on egui 0.36 until storytold/craft-libs#199 lands.
|
||||
ignore:
|
||||
- dependency-name: "egui"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
- dependency-name: "eframe"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
- dependency-name: "egui_kittest"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -0,0 +1,27 @@
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
|
||||
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
libgl1-mesa-dev libasound2-dev
|
||||
@@ -3,20 +3,19 @@
|
||||
# player falls back to its silent clock when the VM has no sound device.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
|
||||
# or dependency change: a drifted dependency still shows up within a day, and always before a
|
||||
# release ships. PRs that change the FreeBSD packaging itself still run it.
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow or packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "47 3 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- "crates/playback/**"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "47 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
|
||||
# Source replacement, patches and path overrides in cargo config never show up
|
||||
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
|
||||
# Cargo still reads the legacy .cargo/config.
|
||||
python3 - <<'PY'
|
||||
import os, sys, tomllib
|
||||
for path in (".cargo/config", ".cargo/config.toml"):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
config = tomllib.load(open(path, "rb"))
|
||||
replaced = [name for name, source in config.get("source", {}).items()
|
||||
if isinstance(source, dict) and "replace-with" in source]
|
||||
if replaced or "patch" in config or "paths" in config:
|
||||
print(f"{path} replaces, patches or overrides a source")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Fails when Cargo.lock is out of date with the manifests.
|
||||
cargo metadata --locked --format-version 1 > /dev/null
|
||||
python3 - <<'PY'
|
||||
import sys, tomllib
|
||||
lock = tomllib.load(open("Cargo.lock", "rb"))
|
||||
bad = sorted({p["source"] for p in lock.get("package", [])
|
||||
if p.get("source", "").startswith("git+")
|
||||
and not p["source"].startswith("git+https://github.com/storytold/")})
|
||||
if bad:
|
||||
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
@@ -10,9 +10,6 @@ on:
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -32,6 +32,7 @@ People trust SoundCraft with their recordings; a crash loses takes. **This outra
|
||||
- The audio callback never blocks, allocates in steady state, or panics.
|
||||
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
- `Engine::execute` catches escaped panics and restores the document. Every crash fix lands with a regression test.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
|
||||
|
||||
## Architecture (layers enforced by `cargo xtask layers`)
|
||||
|
||||
Generated
+394
-299
File diff suppressed because it is too large
Load Diff
+14
-3
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.1"
|
||||
version = "0.12.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -32,18 +32,27 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
proptest = "1"
|
||||
|
||||
# audio
|
||||
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
|
||||
symphonia = { version = "0.6", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
|
||||
rustfft = "6"
|
||||
# Session bundles (one file holding a session and its audio, for the browser): stored entries only.
|
||||
zip = { version = "9", default-features = false }
|
||||
cpal = "0.16"
|
||||
cpal = "0.18"
|
||||
|
||||
# Which file paths stored in a session may be opened.
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
|
||||
# Video containers and bitstreams, shared with the other craft apps.
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4" }
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# egui 0.37 needs craft-libs#199 (craft-ui) first; pin 0.36 until it lands.
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
@@ -61,9 +70,11 @@ type_complexity = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.soundcraft.png" width="128" height="128" alt="SoundCraft icon: a singing bird on a blue tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">SoundCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Recording, editing and mixing audio; an open-source, clean-room reimplementation of Avid Pro Tools, rebuilt in pure Rust.</b>
|
||||
<b>Recording, editing and mixing audio; an open-source, modern digital audio workstation (DAW), built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -25,6 +29,9 @@
|
||||
<img alt="Platforms" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-0b7385?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue?style=flat-square">
|
||||
<img alt="Status: pre-alpha" src="https://img.shields.io/badge/status-pre--alpha-orange?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_soundcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/soundcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -204,6 +211,9 @@ soundcraft-cli app --port 7801 ui.screenshot '{"path":"shot.png"}'
|
||||
|
||||
# Give Claude a DAW.
|
||||
claude mcp add soundcraft -- soundcraft-cli mcp --demo
|
||||
|
||||
# Or keep the agent to some folders (reads and writes are separate roots).
|
||||
soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR
|
||||
```
|
||||
|
||||
See [`docs/control-protocol.md`](docs/control-protocol.md) and [`docs/mcp.md`](docs/mcp.md).
|
||||
@@ -252,7 +262,7 @@ very welcome, in the issues or on Discord.
|
||||
|
||||
## Downloads
|
||||
|
||||
**Download SoundCraft** from GitHub: the [latest release](https://github.com/storytold/soundcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/soundcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
**Download SoundCraft** from GitHub: the [latest release](https://github.com/storytold/soundcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/soundcraft/releases) have earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
### Windows
|
||||
|
||||
@@ -372,10 +382,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and SoundCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Avid and Pro Tools are trademarks or registered trademarks of Avid Technology, Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Avid Technology, Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+31
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (readiness by audience with hours to ~95 %) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
|
||||
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
|
||||
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
|
||||
@@ -122,6 +122,24 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change.
|
||||
- **2026-10-16:** Master fader volume automation uses each render block's actual last sample, matching track faders. Breakpoints in the next block no longer pull the preceding block's level toward them, and ramps reach their last-sample value. Regression tests cover nonzero starts, one-sample callbacks and several block sizes. No readiness percentage change.
|
||||
- **2026-10-16:** Aux, folder and master latency calculations exclude inserts already taken out for producing NaN or infinity, matching audio tracks. Subsequent blocks no longer compensate for an insert that is bypassed after failure. Regression tests check impulse timing, healthy insert latency and reset/retry at 64- and 512-sample blocks. No readiness percentage change.
|
||||
- **2026-10-14:** MCP `batch` rejects more than 1000 calls before executing any command, instead of silently running only the first 1000 (#460). No readiness percentage change.
|
||||
- **2026-10-13:** WAV: an empty data chunk followed by metadata (LIST, bext) reads as no audio; a recording that was never finalised (sizes left at 0) still gives what was written (#466)
|
||||
- **2026-10-13:** Sampler: a ping-pong loop keeps its phase and direction when a step is wider than the loop (a narrow loop with high transposition), instead of reflecting once and dropping the rest of the travel (#463)
|
||||
- **2026-10-13:** `mix.instrument` takes the built-in instrument's parameters in `params` (validated, clamped) and refuses any other field, so a synth setting written beside `plugin` is an error instead of being accepted and ignored (#265)
|
||||
- **2026-10-13:** A bounce of a bus that an aux returns onto lines up with the timeline: the aux insert's lookahead is compensated like the main mix's (it left the bus late and cut the end off), and tracks routed to that bus wait for it too (#246)
|
||||
- **2026-10-13:** Delay compensation covers the inserts of the master faders: a bounce of the main mix with a Maximizer on the master lines up with the same mix without it (it came out 71 samples late) (#351)
|
||||
- **2026-10-12 (audio device settings):** Setup › Playback Engine chooses the audio system, output and input device, sample rate and buffer size, kept per machine with the preferences; 16/32-bit integer devices are converted; ASIO is available behind the opt-in `asio` build feature (verified with an SSL 2+ at 128 samples). Channel routing, one duplex clock and latency compensation remain (G-01). No readiness percentage change.
|
||||
- **2026-10-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change.
|
||||
- **2026-10-12 (plugin editors on Windows):** VST3 editors and embedded CLAP editors open in a SoundCraft host window on Windows (owned by the app window, resized on request, closed from its title bar); verified with a JUCE VST3 compressor and a CLAP effect. No readiness percentage change.
|
||||
- **2026-10-12:** `soundcraft-cli convert` rejects an unparsable or zero `--rate` and an unknown `--bit-depth` (exit 1, nothing written) instead of converting with a default (#256)
|
||||
- **2026-10-12:** Timecode labels with huge fields no longer overflow: frame counts are computed in 128 bits and clamp, so `setup.current_timecode` and `session.format_time` saturate instead of failing (#248)
|
||||
- **2026-10-12:** `session.format_time` with the smallest bar number no longer fails with an internal error: the bar offset saturates (#249)
|
||||
- **2026-10-12 (plugin editors on Windows):** VST3 editors and embedded CLAP editors open in a SoundCraft host window on Windows (owned by the app window, resized on request, closed from its title bar); verified with a JUCE VST3 compressor and a CLAP effect. No readiness percentage change.
|
||||
- **2026-10-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change.
|
||||
- **2026-10-12 (audio device settings):** Setup › Playback Engine chooses the audio system, output and input device, sample rate and buffer size, kept per machine with the preferences; 16/32-bit integer devices are converted; ASIO is available behind the opt-in `asio` build feature (verified with an SSL 2+ at 128 samples). Channel routing, one duplex clock and latency compensation remain (G-01). No readiness percentage change.
|
||||
- **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change.
|
||||
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard
|
||||
progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
|
||||
@@ -143,6 +161,18 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged |
|
||||
| 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged |
|
||||
| 2026-10-16 | trivial | Failed inserts no longer contribute aux, folder or master latency after containment; readiness estimates unchanged |
|
||||
| 2026-10-14 | trivial | Progress log: oversized MCP batches rejected before execution (#460) |
|
||||
| 2026-10-13 | trivial | Progress log: empty WAV data chunk followed by metadata (#466) |
|
||||
| 2026-10-13 | trivial | Progress log: sampler ping-pong phase for wide steps (#463) |
|
||||
| 2026-10-13 | trivial | Progress log: mix.instrument params, unknown fields refused (#265) |
|
||||
| 2026-10-13 | trivial | Progress log: bus bounce compensates aux returns (#246) |
|
||||
| 2026-10-13 | trivial | Progress log: master insert latency compensated (#351) |
|
||||
| 2026-10-12 | trivial | Progress log: convert option validation (#256) |
|
||||
| 2026-10-12 | trivial | Progress log: timecode overflow (#248) |
|
||||
| 2026-10-12 | trivial | Progress log: bar offset saturates (#249) |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours: full 950–1,450 h, mainstream 380–600 h, essentials 110–200 h |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner ~36 % and essentials user ~57 % to the headline numbers |
|
||||
| 2026-10-10 | minor | Stage alpha → pre-alpha: the core-workflow gate fails on interface recording, MIDI recording and session interchange; alpha distance added |
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
|
||||
//! flag must not silently remove a root and leave the server unconfined.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AutomationArgs {
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
|
||||
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
|
||||
if value.starts_with('-') {
|
||||
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
|
||||
}
|
||||
value
|
||||
}
|
||||
};
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_are_taken_once_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
let mut args = ["a folder".to_owned(), "song.scraft".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--demo", &mut args).unwrap());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(args.next().as_deref(), Some("song.scraft"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_operands_leave_roots_unchanged() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
}
|
||||
}
|
||||
+103
-13
@@ -8,15 +8,23 @@
|
||||
//! soundcraft-cli commands [FILTER] list commands
|
||||
//! soundcraft-cli describe ID one command's details
|
||||
//! soundcraft-cli app [--port P] METHOD [JSON] call a running app's control channel
|
||||
//! soundcraft-cli mcp [--connect PORT] [--demo] MCP server on stdio
|
||||
//! soundcraft-cli mcp [--connect PORT] [--demo] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
//! MCP server on stdio. The two roots confine every file the
|
||||
//! agent's commands read (open, import, presets) and write
|
||||
//! (save, bounce, export) to those folders, links followed;
|
||||
//! a root left out grants none of its access. They run the
|
||||
//! in-process server, so they conflict with --connect.
|
||||
//! soundcraft-cli parity [--write PATH] parity report (markdown)
|
||||
//! soundcraft-cli plugins list built-in plugins
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod automation_args;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_automation::{Backend, Headless, Remote, mcp::Server};
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_engine::file_access::{self, AutomationRoots};
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
|
||||
@@ -102,7 +110,7 @@ fn info(args: &[String]) -> ExitCode {
|
||||
Ok(b) => b,
|
||||
Err(e) => return fail(format!("{path}: {e}")),
|
||||
};
|
||||
let ext = std::path::Path::new(path).extension().and_then(|x| x.to_str());
|
||||
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str);
|
||||
match soundcraft_audio_io::probe(&bytes, ext) {
|
||||
Ok(i) => {
|
||||
outln!("{}", serde_json::to_string_pretty(&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf})).unwrap_or_default());
|
||||
@@ -114,31 +122,34 @@ fn info(args: &[String]) -> ExitCode {
|
||||
|
||||
fn convert(args: &[String]) -> ExitCode {
|
||||
let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return fail("convert IN OUT") };
|
||||
let rate = match parse_rate(arg_value(args, "--rate").as_deref()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return fail(e),
|
||||
};
|
||||
let bit_depth = match parse_bit_depth(arg_value(args, "--bit-depth").as_deref()) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return fail(e),
|
||||
};
|
||||
let bytes = match std::fs::read(input) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return fail(format!("{input}: {e}")),
|
||||
};
|
||||
let ext = std::path::Path::new(input).extension().and_then(|x| x.to_str());
|
||||
let ext = std::path::Path::new(input).extension().and_then(std::ffi::OsStr::to_str);
|
||||
let (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
|
||||
Ok(x) => x,
|
||||
Err(e) => return fail(format!("{input}: {e}")),
|
||||
};
|
||||
if let Some(rate) = arg_value(args, "--rate").and_then(|r| r.parse::<u32>().ok()) {
|
||||
if let Some(rate) = rate {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
|
||||
buf.sample_rate = rate;
|
||||
}
|
||||
if args.iter().any(|a| a == "--normalize") {
|
||||
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
|
||||
}
|
||||
let out_ext = std::path::Path::new(output).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase();
|
||||
let out_ext = std::path::Path::new(output).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
|
||||
let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else {
|
||||
return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS));
|
||||
};
|
||||
let bit_depth = match arg_value(args, "--bit-depth").as_deref() {
|
||||
Some("16") => soundcraft_audio_io::BitDepth::Int16,
|
||||
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
|
||||
_ => soundcraft_audio_io::BitDepth::Int24,
|
||||
};
|
||||
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None };
|
||||
match soundcraft_audio_io::encode(&buf, &opts).map_err(|e| e.to_string()).and_then(|b| std::fs::write(output, b).map_err(|e| e.to_string())) {
|
||||
Ok(()) => {
|
||||
@@ -149,6 +160,25 @@ fn convert(args: &[String]) -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_rate(v: Option<&str>) -> Result<Option<u32>, String> {
|
||||
match v {
|
||||
None => Ok(None),
|
||||
Some(r) => match r.parse::<u32>() {
|
||||
Ok(hz) if hz > 0 => Ok(Some(hz)),
|
||||
_ => Err(format!("--rate: expected a sample rate in Hz, got `{r}`")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bit_depth(v: Option<&str>) -> Result<soundcraft_audio_io::BitDepth, String> {
|
||||
match v {
|
||||
None | Some("24") => Ok(soundcraft_audio_io::BitDepth::Int24),
|
||||
Some("16") => Ok(soundcraft_audio_io::BitDepth::Int16),
|
||||
Some("32") | Some("32f") => Ok(soundcraft_audio_io::BitDepth::Float32),
|
||||
Some(b) => Err(format!("--bit-depth: expected 16, 24 or 32f, got `{b}`")),
|
||||
}
|
||||
}
|
||||
|
||||
/// One stderr line per media file a session refers to but could not load (the exit code is unchanged).
|
||||
fn missing_warnings(missing: &[String]) -> Vec<String> {
|
||||
missing.iter().map(|m| format!("warning: missing media: {m}")).collect()
|
||||
@@ -356,15 +386,62 @@ fn app(args: &[String]) -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
/// What `mcp` was asked for.
|
||||
struct McpArgs {
|
||||
connect: Option<String>,
|
||||
demo: bool,
|
||||
roots: Option<std::sync::Arc<AutomationRoots>>,
|
||||
}
|
||||
|
||||
/// Parse the `mcp` options up front: a malformed or misspelled root flag must not leave the
|
||||
/// server running unconfined.
|
||||
fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> {
|
||||
let mut connect = None;
|
||||
let mut demo = false;
|
||||
let mut roots_args = automation_args::AutomationArgs::new(None, None);
|
||||
let mut it = args.iter().cloned();
|
||||
while let Some(a) = it.next() {
|
||||
if roots_args.consume(&a, &mut it)? {
|
||||
continue;
|
||||
}
|
||||
match a.as_str() {
|
||||
"--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?),
|
||||
"--demo" => demo = true,
|
||||
other if other.starts_with("--automation-") => return Err(format!("unknown mcp option `{other}`")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
|
||||
if roots.is_some() && connect.is_some() {
|
||||
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
|
||||
so drop --connect (or use --connect without the roots)"
|
||||
.into());
|
||||
}
|
||||
Ok(McpArgs { connect, demo, roots })
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> ExitCode {
|
||||
let McpArgs { connect, demo, roots } = match parse_mcp_args(args) {
|
||||
Ok(a) => a,
|
||||
Err(err) => return fail(err),
|
||||
};
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
let r = if let Some(port) = arg_value(args, "--connect") {
|
||||
let r = if let Some(port) = connect {
|
||||
let mut s = Server::new(Remote::new(&port));
|
||||
s.serve(stdin.lock(), stdout.lock())
|
||||
} else {
|
||||
let e = if args.iter().any(|a| a == "--demo") { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
|
||||
let mut s = Server::new(Headless::new(e));
|
||||
// `--demo` is the operator's own choice, made before the roots come into force.
|
||||
let e = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
|
||||
let mut h = Headless::new(e);
|
||||
if let Some(roots) = roots {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
file_access::confine_process(roots.clone());
|
||||
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
eprintln!("soundcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
h = h.with_automation_roots(Some(roots));
|
||||
}
|
||||
let mut s = Server::new(h);
|
||||
s.serve(stdin.lock(), stdout.lock())
|
||||
};
|
||||
match r {
|
||||
@@ -422,6 +499,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rejects_invalid_rate_and_bit_depth() {
|
||||
assert_eq!(parse_rate(None), Ok(None));
|
||||
assert_eq!(parse_rate(Some("44100")), Ok(Some(44_100)));
|
||||
assert!(parse_rate(Some("4410x")).is_err());
|
||||
assert!(parse_rate(Some("0")).is_err());
|
||||
assert!(matches!(parse_bit_depth(None), Ok(soundcraft_audio_io::BitDepth::Int24)));
|
||||
assert!(matches!(parse_bit_depth(Some("16")), Ok(soundcraft_audio_io::BitDepth::Int16)));
|
||||
assert!(matches!(parse_bit_depth(Some("32f")), Ok(soundcraft_audio_io::BitDepth::Float32)));
|
||||
assert!(parse_bit_depth(Some("8")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_media_gets_one_warning_each() {
|
||||
assert!(missing_warnings(&[]).is_empty());
|
||||
@@ -446,6 +535,7 @@ mod tests {
|
||||
let help = help_text();
|
||||
assert!(help.starts_with("soundcraft-cli info FILE"));
|
||||
assert!(help.ends_with("soundcraft-cli plugins list built-in plugins"));
|
||||
assert!(help.contains("--automation-read-root DIR") && help.contains("--automation-write-root DIR"));
|
||||
assert!(!help.contains("```"));
|
||||
assert!(!help.contains("#![") && !help.contains("use serde_json"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! `soundcraft-cli mcp` with the automation root flags, run as the real binary.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
fn bin() -> Command {
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
|
||||
cmd.env("SOUNDCRAFT_NO_PREFS", "1");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> Output {
|
||||
bin().args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("sc-cli-roots-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Send `calls` (tool name, arguments) to `soundcraft-cli mcp <args>`; returns whether it exited
|
||||
/// cleanly, its stderr, and the stdout lines after `initialize`, one per call.
|
||||
fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
|
||||
let mut child = bin().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
||||
for (i, (name, arguments)) in calls.iter().enumerate() {
|
||||
let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}});
|
||||
writeln!(stdin, "{call}").unwrap();
|
||||
}
|
||||
}
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
(out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies)
|
||||
}
|
||||
|
||||
fn text(reply: &Value) -> &str {
|
||||
reply["result"]["content"][0]["text"].as_str().unwrap()
|
||||
}
|
||||
|
||||
/// A short WAV made by the binary itself.
|
||||
fn make_wav(path: &std::path::Path) {
|
||||
let o = run(&["run", "--demo", "--bounce", path.to_str().unwrap(), "--start", "0", "--end", "0.1"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_automation_roots_confine_the_server() {
|
||||
let base = tmpdir("roots");
|
||||
let (work, elsewhere) = (base.join("work"), base.join("elsewhere"));
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
std::fs::create_dir_all(&elsewhere).unwrap();
|
||||
make_wav(&work.join("in.wav"));
|
||||
make_wav(&elsewhere.join("secret.wav"));
|
||||
let work_s = work.to_str().unwrap();
|
||||
let secret = elsewhere.join("secret.wav").to_str().unwrap().to_string();
|
||||
|
||||
// Both spellings of the flags; inside works, outside is refused with a readable message.
|
||||
let read_flag = format!("--automation-read-root={work_s}");
|
||||
let calls = [
|
||||
("import_audio", serde_json::json!({"path": work.join("in.wav").to_str().unwrap()})),
|
||||
("import_audio", serde_json::json!({"path": secret})),
|
||||
("save_session", serde_json::json!({"path": work.join("saved.scraft").to_str().unwrap()})),
|
||||
("save_session", serde_json::json!({"path": elsewhere.join("saved.scraft").to_str().unwrap()})),
|
||||
("bounce_mix", serde_json::json!({"path": elsewhere.join("mix.wav").to_str().unwrap()})),
|
||||
];
|
||||
let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls);
|
||||
assert!(success, "{stderr}");
|
||||
assert!(stderr.contains("files confined"), "{stderr}");
|
||||
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
|
||||
assert_eq!(replies[1]["result"]["isError"], true);
|
||||
assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1]));
|
||||
assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]);
|
||||
assert!(work.join("saved.scraft").exists());
|
||||
assert_eq!(replies[3]["result"]["isError"], true);
|
||||
assert!(text(&replies[3]).contains("outside the write root"), "{}", text(&replies[3]));
|
||||
assert_eq!(replies[4]["result"]["isError"], true);
|
||||
assert!(text(&replies[4]).contains("outside the write root"), "{}", text(&replies[4]));
|
||||
assert!(!elsewhere.join("saved.scraft").exists() && !elsewhere.join("mix.wav").exists());
|
||||
|
||||
// A root left out grants none of its access.
|
||||
let (_, _, replies) = mcp_session(&[&read_flag], &calls[2..3]);
|
||||
assert_eq!(replies[0]["result"]["isError"], true);
|
||||
assert!(text(&replies[0]).contains("write authority is absent"), "{}", text(&replies[0]));
|
||||
let (_, _, replies) = mcp_session(&["--automation-write-root", work_s], &calls[0..1]);
|
||||
assert_eq!(replies[0]["result"]["isError"], true);
|
||||
assert!(text(&replies[0]).contains("read authority is absent"), "{}", text(&replies[0]));
|
||||
|
||||
// No flags: nothing is confined.
|
||||
let (success, _, replies) = mcp_session(&[], &calls[1..2]);
|
||||
assert!(success);
|
||||
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
let base = tmpdir("roots-args");
|
||||
let dir = base.to_str().unwrap();
|
||||
let missing = base.join("missing");
|
||||
let fails = |args: &[&str], needle: &str| {
|
||||
let mut full = vec!["mcp"];
|
||||
full.extend_from_slice(args);
|
||||
let o = run(&full);
|
||||
assert!(!o.status.success(), "{args:?}");
|
||||
let err = String::from_utf8_lossy(&o.stderr).into_owned();
|
||||
assert!(err.contains(needle), "{args:?}: {err}");
|
||||
};
|
||||
// The flags confine the in-process server, so they cannot be combined with --connect.
|
||||
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
|
||||
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
|
||||
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
|
||||
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
|
||||
fails(&["--automation-read-root"], "needs a folder");
|
||||
fails(&["--automation-write-root", "--demo"], "needs a folder");
|
||||
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
fails(&["--automation-write-root="], "needs a non-empty folder");
|
||||
// A misspelled root flag must not start an unconfined server.
|
||||
fails(&["--automation-read-roots", dir], "unknown mcp option");
|
||||
// A file is not a folder.
|
||||
std::fs::write(base.join("f.txt"), "x").unwrap();
|
||||
fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_lists_the_root_flags() {
|
||||
let o = run(&["--help"]);
|
||||
let help = String::from_utf8_lossy(&o.stdout).into_owned();
|
||||
assert!(help.contains("--automation-read-root DIR") && help.contains("--automation-write-root DIR"), "{help}");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod cli;
|
||||
mod closed_stdout;
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SoundCraft</title>
|
||||
<link data-trunk rel="icon" type="image/png" href="../../assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" />
|
||||
<link data-trunk rel="rust" data-bin="soundcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! The browser shell: the eframe web runner around [`SoundApp`].
|
||||
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::{Engine, io};
|
||||
use soundcraft_ui_egui::dialogs::Dialog;
|
||||
use soundcraft_ui_egui::sampler_window::SampleRead;
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
@@ -41,7 +41,7 @@ pub fn start() {
|
||||
let engine = if query().contains("empty") { Engine::default() } else { soundcraft_engine::demo::demo_engine() };
|
||||
let player = soundcraft_playback::Player::new(Arc::new(engine.session().clone()));
|
||||
let app = SoundApp::new(engine, Some(player), Services::default());
|
||||
Ok(Box::new(WebShell { app, inbox: Rc::default() }))
|
||||
Ok(Box::new(WebShell { app, inbox: Rc::default(), samples: Rc::default() }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -67,7 +67,8 @@ enum Purpose {
|
||||
}
|
||||
|
||||
const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION];
|
||||
const AUDIO_EXTS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"];
|
||||
const AUDIO_EXTS: &[&str] =
|
||||
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"];
|
||||
const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"];
|
||||
|
||||
impl Purpose {
|
||||
@@ -106,9 +107,14 @@ impl Purpose {
|
||||
/// handled on the next frame. The browser has no paths: files travel as bytes.
|
||||
type Inbox = Rc<RefCell<Vec<(Purpose, String, Vec<u8>)>>>;
|
||||
|
||||
/// Audio dropped on an instrument track or a Sampler window, its bytes read, waiting for the
|
||||
/// next frame to load into that track's sampler.
|
||||
type SampleInbox = Rc<RefCell<Vec<(SampleRead, Result<Vec<u8>, String>)>>>;
|
||||
|
||||
struct WebShell {
|
||||
app: SoundApp,
|
||||
inbox: Inbox,
|
||||
samples: SampleInbox,
|
||||
}
|
||||
|
||||
impl WebShell {
|
||||
@@ -133,7 +139,17 @@ impl WebShell {
|
||||
|
||||
fn take_drops(&mut self, ctx: &egui::Context) {
|
||||
// In the browser a dropped file's `path()` is only its name: read its bytes instead.
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
// Audio dropped on an instrument track or a Sampler window goes to its sampler instead.
|
||||
let dropped = self.app.take_dropped_files(ctx);
|
||||
ctx.input_mut(|i| i.raw.dropped_files.clear());
|
||||
for read in self.app.take_sample_reads() {
|
||||
let (samples, ctx) = (self.samples.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let bytes = read.file.bytes_async().await;
|
||||
samples.borrow_mut().push((read, bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
for f in dropped {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "Audio.wav".into());
|
||||
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
|
||||
@@ -149,27 +165,23 @@ impl WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) {
|
||||
fn handle(&mut self, purpose: Purpose, name: String, bytes: Vec<u8>) {
|
||||
let e = &mut self.app.engine;
|
||||
let at = e.session().edit.selection.start.max(0);
|
||||
let result = match purpose {
|
||||
Purpose::Session => {
|
||||
let _ = self.app.run("transport.stop", json!({}));
|
||||
io::open_session_bytes(&mut self.app.engine, name, bytes).map(|missing| {
|
||||
if missing.is_empty() { format!("Opened {name}") } else { format!("Opened {name} ({} missing media)", missing.len()) }
|
||||
})
|
||||
}
|
||||
// Asks first when the open session has changes: the bytes wait for the answer.
|
||||
Purpose::Session => return soundcraft_ui_egui::dialogs::open_session_bytes(&mut self.app, name, bytes),
|
||||
// Imports are edits: one undo step each, like the import commands.
|
||||
Purpose::Audio => {
|
||||
let before = e.session_arc();
|
||||
io::import_audio_bytes(e, name, bytes, None, None, at).map(|_| {
|
||||
io::import_audio_bytes(e, &name, &bytes, None, None, at).map(|_| {
|
||||
e.push_undo("Import Audio", before);
|
||||
format!("Imported {name}")
|
||||
})
|
||||
}
|
||||
Purpose::Midi => {
|
||||
let before = e.session_arc();
|
||||
io::import_midi_bytes(e, bytes, at, true).map(|_| {
|
||||
io::import_midi_bytes(e, &bytes, at, true).map(|_| {
|
||||
e.push_undo("Import MIDI", before);
|
||||
format!("Imported {name}")
|
||||
})
|
||||
@@ -185,7 +197,11 @@ impl eframe::App for WebShell {
|
||||
self.take_path_prompt(ctx);
|
||||
let pending = std::mem::take(&mut *self.inbox.borrow_mut());
|
||||
for (purpose, name, bytes) in pending {
|
||||
self.handle(purpose, &name, &bytes);
|
||||
self.handle(purpose, name, bytes);
|
||||
}
|
||||
let samples = std::mem::take(&mut *self.samples.borrow_mut());
|
||||
for (read, bytes) in samples {
|
||||
self.app.load_sample_bytes(&read, bytes);
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
// A prompt opened during this frame becomes a picker on the next one.
|
||||
|
||||
@@ -10,6 +10,10 @@ default-run = "soundcraft"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# ASIO audio devices on Windows (see soundcraft-playback's `asio` feature).
|
||||
asio = ["soundcraft-playback/asio"]
|
||||
|
||||
[dependencies]
|
||||
soundcraft-engine.workspace = true
|
||||
soundcraft-ui-egui.workspace = true
|
||||
@@ -18,6 +22,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
|
||||
egui.workspace = true
|
||||
serde_json.workspace = true
|
||||
log.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd.workspace = true
|
||||
@@ -27,6 +32,6 @@ rfd.workspace = true
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.19.2", default-features = false }
|
||||
muda = { version = "0.21.2", default-features = false }
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] }
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
//! Per-user settings and automation profile directories.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Portable mode (#268): this file beside the executable keeps settings, autosaves, presets and
|
||||
/// logs in [`PORTABLE_DATA_DIR`] next to it instead of the user profile.
|
||||
const PORTABLE_MARKER: &str = "portable.txt";
|
||||
const PORTABLE_DATA_DIR: &str = "SoundCraftData";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Platform {
|
||||
@@ -13,7 +18,18 @@ enum Platform {
|
||||
/// SoundCraft's settings folder. A nonempty `$SOUNDCRAFT_CONFIG_DIR` overrides the platform default.
|
||||
/// The override is the complete directory; no application-name suffix is appended. An explicitly
|
||||
/// empty value disables the settings directory instead of falling back to a personal profile.
|
||||
/// Otherwise `portable.txt` beside the executable selects `<exe dir>/SoundCraftData`, when that
|
||||
/// folder can be written (a read-only medium falls back to the platform default).
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
static DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let exe = std::env::current_exe().ok();
|
||||
resolve(exe.as_deref().and_then(Path::parent))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn resolve(exe_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
let platform = if cfg!(target_os = "macos") {
|
||||
Platform::Mac
|
||||
} else if cfg!(windows) {
|
||||
@@ -21,13 +37,20 @@ pub fn config_dir() -> Option<PathBuf> {
|
||||
} else {
|
||||
Platform::Other
|
||||
};
|
||||
config_dir_with(platform, |name| std::env::var_os(name))
|
||||
config_dir_with(platform, |name| std::env::var_os(name), exe_dir)
|
||||
}
|
||||
|
||||
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
if let Some(dir) = env("SOUNDCRAFT_CONFIG_DIR") {
|
||||
return (!dir.is_empty()).then(|| PathBuf::from(dir));
|
||||
}
|
||||
if let Some(exe_dir) = exe_dir.filter(|d| d.join(PORTABLE_MARKER).is_file()) {
|
||||
let data = exe_dir.join(PORTABLE_DATA_DIR);
|
||||
match writable(&data) {
|
||||
Ok(()) => return Some(data),
|
||||
Err(e) => log::warn!("portable mode: {} can't be written ({e}); using the user profile instead", data.display()),
|
||||
}
|
||||
}
|
||||
match platform {
|
||||
Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/SoundCraft")),
|
||||
Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("SoundCraft")),
|
||||
@@ -37,6 +60,14 @@ fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -
|
||||
}
|
||||
}
|
||||
|
||||
/// Create `dir` if needed and prove a file can be written in it.
|
||||
fn writable(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let probe = dir.join(format!(".write-test-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"")?;
|
||||
std::fs::remove_file(probe)
|
||||
}
|
||||
|
||||
/// Read bounded settings, recovering unreadable docking metadata independently of other settings.
|
||||
pub fn load_ui(path: &std::path::Path) -> Result<Option<soundcraft_ui_egui::UiState>, String> {
|
||||
read_ui(path, true)
|
||||
@@ -195,16 +226,20 @@ mod config_tests {
|
||||
}
|
||||
|
||||
fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option<PathBuf> {
|
||||
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
|
||||
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_override_wins_without_reading_user_profile() {
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
let actual = config_dir_with(platform, |name| {
|
||||
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
|
||||
Some(OsString::from("isolated/settings"))
|
||||
});
|
||||
let actual = config_dir_with(
|
||||
platform,
|
||||
|name| {
|
||||
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
|
||||
Some(OsString::from("isolated/settings"))
|
||||
},
|
||||
None,
|
||||
);
|
||||
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
|
||||
}
|
||||
}
|
||||
@@ -213,10 +248,14 @@ mod config_tests {
|
||||
fn config_empty_override_disables_profile_without_reading_defaults() {
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
assert_eq!(
|
||||
config_dir_with(platform, |name| {
|
||||
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
|
||||
Some(OsString::new())
|
||||
}),
|
||||
config_dir_with(
|
||||
platform,
|
||||
|name| {
|
||||
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
|
||||
Some(OsString::new())
|
||||
},
|
||||
None
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -234,11 +273,34 @@ mod config_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_txt_beside_the_exe_keeps_settings_next_to_it() {
|
||||
let exe = std::env::temp_dir().join(format!("soundcraft-portable-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
std::fs::create_dir_all(&exe).unwrap();
|
||||
let vars = [("APPDATA", "/roaming")];
|
||||
assert_eq!(resolve_with_exe(&vars, &exe), Some(PathBuf::from("/roaming/SoundCraft")), "no marker: the profile");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
assert_eq!(resolve_with_exe(&vars, &exe), Some(exe.join("SoundCraftData")));
|
||||
assert!(exe.join("SoundCraftData").is_dir());
|
||||
// The explicit override still wins.
|
||||
assert_eq!(resolve_with_exe(&[("SOUNDCRAFT_CONFIG_DIR", "/custom")], &exe), Some(PathBuf::from("/custom")));
|
||||
// A data folder that can't be made (a file is in the way) falls back to the profile.
|
||||
std::fs::remove_dir_all(exe.join("SoundCraftData")).unwrap();
|
||||
std::fs::write(exe.join("SoundCraftData"), b"").unwrap();
|
||||
assert_eq!(resolve_with_exe(&vars, &exe), Some(PathBuf::from("/roaming/SoundCraft")));
|
||||
std::fs::remove_dir_all(exe).unwrap();
|
||||
}
|
||||
|
||||
fn resolve_with_exe(vars: &[(&str, &str)], exe: &Path) -> Option<PathBuf> {
|
||||
config_dir_with(Platform::Windows, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), Some(exe))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn config_override_preserves_non_unicode_paths() {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
let value = OsString::from_vec(b"isolated/\xff".to_vec());
|
||||
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
|
||||
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone()), None), Some(PathBuf::from(value)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`soundcraft-cli mcp --connect PORT`) wraps.
|
||||
//!
|
||||
//! Lines are bounded ([`MAX_LINE`]): a longer one is refused and the connection closed, without
|
||||
//! buffering the rest. So is the number of connections served at once ([`MAX_CONNECTIONS`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_ui_egui::ControlRequest;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). The largest payload a request
|
||||
/// carries is a plugin state for `mix.insert_state`: base64 of at most `b64::MAX_DECODED_BYTES`
|
||||
/// (64 MiB, so about 85 MiB of text). Room for that and 1 MiB of JSON around it; replies
|
||||
/// (screenshots) aren't bounded by this.
|
||||
const MAX_LINE: usize = soundcraft_engine::model::b64::MAX_DECODED_BYTES.div_ceil(3) * 4 + (1 << 20);
|
||||
// With MAX_CONNECTIONS, at most 1.5 GiB of request lines are buffered at once.
|
||||
const _: () = assert!(MAX_LINE <= 96 << 20);
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
@@ -20,28 +35,85 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let actual = listener.local_addr().map(|a| a.port()).unwrap_or(port);
|
||||
log::info!("control server listening on 127.0.0.1:{actual}");
|
||||
println!("SOUNDCRAFT_CONTROL_PORT={actual}");
|
||||
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let _ = std::thread::Builder::new().name("soundcraft-control-conn".into()).spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || accept_loop(listener, tx, ctx));
|
||||
if let Err(e) = spawned {
|
||||
log::error!("control thread failed: {e}");
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
/// A connection's place among [`MAX_CONNECTIONS`], given back when the connection ends (also when
|
||||
/// its thread can't be started, or panics).
|
||||
struct Slot(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for Slot {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
continue;
|
||||
}
|
||||
let slot = Slot(Arc::clone(&open));
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let _ = std::thread::Builder::new().name("soundcraft-control-conn".into()).spawn(move || {
|
||||
let _slot = slot;
|
||||
serve(stream, tx, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => break,
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Bytes that aren't UTF-8 end the connection, as `BufRead::lines` did.
|
||||
let Ok(line) = std::str::from_utf8(&buf) else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
let reply = match serde_json::from_str::<Value>(line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
@@ -64,3 +136,80 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lines_are_read_up_to_the_limit() {
|
||||
let mut buf = Vec::new();
|
||||
let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc";
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, b"bb", "CRLF is stripped");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, b"cc", "a last line without a newline is still read");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(false), "then end of stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_line_is_refused_without_reading_the_rest() {
|
||||
let mut buf = Vec::new();
|
||||
let long = [b'x'; 100];
|
||||
let mut input: &[u8] = &long;
|
||||
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
|
||||
assert!(buf.len() <= 11, "at most limit + 1 bytes are buffered, got {}", buf.len());
|
||||
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_largest_plugin_state_fits_in_a_line() {
|
||||
let state = soundcraft_engine::model::b64::MAX_DECODED_BYTES.div_ceil(3) * 4;
|
||||
let request = json!({"id": 1, "method": "engine.execute", "params": {"command": "mix.insert_state", "params": {"track": "Audio 1", "slot": 0, "state": ""}}});
|
||||
assert!(state + request.to_string().len() <= MAX_LINE);
|
||||
}
|
||||
|
||||
/// The control server on a loopback port, with a stand-in UI that echoes each method.
|
||||
fn serve_echo() -> std::net::SocketAddr {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
let _ = req.reply.send(json!({"ok": true, "method": req.method}));
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_with_their_ids() {
|
||||
let addr = serve_echo();
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
for (id, method) in [(3, "ping"), (4, "pong")] {
|
||||
let mut line = String::new();
|
||||
r.read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!((v["id"].as_i64(), v["method"].as_str()), (Some(id), Some(method)), "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_connections_are_refused() {
|
||||
let addr = serve_echo();
|
||||
// Idle connections are accepted in order and held open.
|
||||
let held: Vec<TcpStream> = (0..MAX_CONNECTIONS).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let mut extra = TcpStream::connect(addr).unwrap();
|
||||
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(&mut extra).read_line(&mut line).unwrap();
|
||||
assert!(line.contains("too many control connections"), "{line}");
|
||||
drop(held);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,28 +112,36 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
|
||||
/// logging never panics and never silently resets to the epoch for a convertible instant.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
|
||||
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
|
||||
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
|
||||
let (y, t) = utc_parts(secs, since.subsec_nanos());
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
||||
/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
|
||||
/// so month, day and time of day come from chrono and only the year is carried, which keeps every
|
||||
/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
|
||||
fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -533,6 +541,20 @@ mod tests {
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
|
||||
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
|
||||
// SystemTime can't hold this instant, so it is only checked where it exists.
|
||||
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -44,14 +44,15 @@ impl eframe::App for App {
|
||||
if let Some(menu) = &mut self.2 {
|
||||
menu.process(&mut self.0);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
self.0.guarded(|app| app.logic(ctx));
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(menu) = &mut self.2 {
|
||||
menu.sync(&self.0);
|
||||
}
|
||||
// Files dropped on the window: sessions open, audio/MIDI import.
|
||||
// Files dropped on the window: audio on an instrument track or a Sampler window loads
|
||||
// the sampler; otherwise sessions open and audio/MIDI import.
|
||||
let dropped: Vec<String> =
|
||||
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect());
|
||||
self.0.take_dropped_files(ctx).iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect();
|
||||
for p in dropped {
|
||||
open_path(&mut self.0, &p);
|
||||
}
|
||||
@@ -67,6 +68,10 @@ impl eframe::App for App {
|
||||
self.0.ui.window_size = (r.size() * ctx.zoom_factor()).round().into();
|
||||
}
|
||||
self.0.ui.ui_scale = self.0.extra.ui_scale;
|
||||
// Saved now rather than at exit: the recent list matters most after a crash.
|
||||
if self.0.take_recent_changed() {
|
||||
save_prefs(&self.0.ui);
|
||||
}
|
||||
// With the system menu bar there is no egui bar showing the window and dirty state, so the
|
||||
// title carries them; elsewhere the egui menu bar already does.
|
||||
let title = if self.0.native_menu_bar {
|
||||
@@ -85,9 +90,10 @@ impl eframe::App for App {
|
||||
self.0.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
self.0.guarded(|app| app.ui(ui));
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
self.0.close_autosave();
|
||||
save_prefs(&self.0.ui);
|
||||
if let Some(logger) = self.1 {
|
||||
logger.report_audio_thread();
|
||||
@@ -162,15 +168,21 @@ fn services() -> Services {
|
||||
"file.import_midi" => d.add_filter("MIDI", &["mid", "midi", "smf"]),
|
||||
_ => d.add_filter(
|
||||
"Audio",
|
||||
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"],
|
||||
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"],
|
||||
),
|
||||
};
|
||||
d.pick_file().map(|p| p.to_string_lossy().into_owned())
|
||||
})),
|
||||
pick_save: Some(Box::new(|_purpose: &str, default: &str| {
|
||||
pick_save: Some(Box::new(|purpose: &str, default: &str| {
|
||||
let path = std::path::Path::new(default);
|
||||
let mut d = rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]);
|
||||
if let Some(n) = path.file_name().and_then(|n| n.to_str()) {
|
||||
let mut d = match purpose {
|
||||
"file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", &["wav", "aif", "aiff", "flac"]),
|
||||
_ => rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]),
|
||||
};
|
||||
if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) {
|
||||
d = d.set_directory(dir);
|
||||
}
|
||||
if let Some(n) = path.file_name().and_then(std::ffi::OsStr::to_str) {
|
||||
d = d.set_file_name(n);
|
||||
}
|
||||
d.save_file().map(|p| p.to_string_lossy().into_owned())
|
||||
@@ -255,9 +267,11 @@ fn main() -> eframe::Result {
|
||||
"SoundCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let player = if no_audio { None } else { Some(soundcraft_playback::Player::new(Arc::new(engine.session().clone()))) };
|
||||
let player =
|
||||
if no_audio { None } else { Some(soundcraft_playback::Player::with_config(Arc::new(engine.session().clone()), &prefs.audio)) };
|
||||
let mut app = SoundApp::new(engine, player, services());
|
||||
app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave")));
|
||||
app.leftovers = app.autosave_dir.as_deref().map(soundcraft_engine::autosave::leftovers).unwrap_or_default();
|
||||
app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets")));
|
||||
app.extra.ui_scale = prefs.ui_scale;
|
||||
app.ui = prefs;
|
||||
|
||||
@@ -40,3 +40,18 @@ sync_github_name = true
|
||||
real_name = "Vikram Bhaskaran"
|
||||
display_name = "Vikram Bhaskaran"
|
||||
sync_github_name = false
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Momentum96]
|
||||
sync_github_name = true
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
+270
-33
@@ -2,11 +2,14 @@
|
||||
//! thin safe wrapper over one Audio Unit instance.
|
||||
//!
|
||||
//! Every pointer handed to the Audio Unit points into memory this module owns for at least as
|
||||
//! long as the unit can use it: the input state the render callback reads is a leaked `Box`
|
||||
//! freed only after the unit is disposed, and output buffers are re-pointed before each render.
|
||||
//! long as the unit can use it: the input state the render callback reads and the host time the
|
||||
//! host callbacks read are leaked `Box`es freed only after the unit is disposed, and output buffers
|
||||
//! are re-pointed before each render.
|
||||
|
||||
use soundcraft_dsp::Transport;
|
||||
use std::ffi::{CStr, c_char, c_void};
|
||||
use std::ptr;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
|
||||
|
||||
type OSStatus = i32;
|
||||
type AudioComponent = *mut c_void;
|
||||
@@ -40,6 +43,7 @@ const PROP_MAX_FRAMES: u32 = 14;
|
||||
const PROP_PARAMETER_VALUE_STRINGS: u32 = 16;
|
||||
const PROP_TAIL_TIME: u32 = 20;
|
||||
const PROP_SET_RENDER_CALLBACK: u32 = 23;
|
||||
const PROP_HOST_CALLBACKS: u32 = 27;
|
||||
|
||||
const SCOPE_GLOBAL: u32 = 0;
|
||||
const SCOPE_INPUT: u32 = 1;
|
||||
@@ -140,6 +144,153 @@ struct AudioBufferListHeader {
|
||||
first: AudioBuffer,
|
||||
}
|
||||
|
||||
type BeatAndTempoProc = unsafe extern "C" fn(*mut c_void, *mut f64, *mut f64) -> OSStatus;
|
||||
type MusicalTimeProc = unsafe extern "C" fn(*mut c_void, *mut u32, *mut f32, *mut u32, *mut f64) -> OSStatus;
|
||||
type TransportStateProc = unsafe extern "C" fn(*mut c_void, *mut u8, *mut u8, *mut f64, *mut u8, *mut f64, *mut f64) -> OSStatus;
|
||||
type TransportState2Proc = unsafe extern "C" fn(*mut c_void, *mut u8, *mut u8, *mut u8, *mut f64, *mut u8, *mut f64, *mut f64) -> OSStatus;
|
||||
|
||||
#[repr(C)]
|
||||
struct HostCallbackInfo {
|
||||
host_user_data: *mut c_void,
|
||||
beat_and_tempo: Option<BeatAndTempoProc>,
|
||||
musical_time_location: Option<MusicalTimeProc>,
|
||||
transport_state: Option<TransportStateProc>,
|
||||
transport_state2: Option<TransportState2Proc>,
|
||||
}
|
||||
|
||||
/// What the host callbacks report: the transport of the block being rendered. Atomics, because a
|
||||
/// unit may also ask from another thread.
|
||||
struct HostTime {
|
||||
playing: AtomicBool,
|
||||
recording: AtomicBool,
|
||||
changed: AtomicBool,
|
||||
samples: AtomicI64,
|
||||
tempo: AtomicU64,
|
||||
quarters: AtomicU64,
|
||||
bar_start: AtomicU64,
|
||||
numerator: AtomicU32,
|
||||
denominator: AtomicU32,
|
||||
sample_rate: AtomicU64,
|
||||
}
|
||||
|
||||
impl HostTime {
|
||||
fn new() -> HostTime {
|
||||
HostTime {
|
||||
playing: AtomicBool::new(false),
|
||||
recording: AtomicBool::new(false),
|
||||
changed: AtomicBool::new(false),
|
||||
samples: AtomicI64::new(0),
|
||||
tempo: AtomicU64::new(120f64.to_bits()),
|
||||
quarters: AtomicU64::new(0),
|
||||
bar_start: AtomicU64::new(0),
|
||||
numerator: AtomicU32::new(4),
|
||||
denominator: AtomicU32::new(4),
|
||||
sample_rate: AtomicU64::new(48_000f64.to_bits()),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&self, t: &Transport) {
|
||||
if self.playing.swap(t.playing, Ordering::Relaxed) != t.playing {
|
||||
self.changed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
self.recording.store(t.recording, Ordering::Relaxed);
|
||||
self.samples.store(t.samples, Ordering::Relaxed);
|
||||
self.tempo.store(t.tempo.to_bits(), Ordering::Relaxed);
|
||||
self.quarters.store(t.quarters.to_bits(), Ordering::Relaxed);
|
||||
self.bar_start.store(t.bar_start.to_bits(), Ordering::Relaxed);
|
||||
self.numerator.store(t.numerator, Ordering::Relaxed);
|
||||
self.denominator.store(t.denominator, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_f64(a: &AtomicU64) -> f64 {
|
||||
f64::from_bits(a.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Writes through an out-pointer the unit may leave null.
|
||||
///
|
||||
/// # Safety
|
||||
/// `p` is null or valid for writing a `T`.
|
||||
unsafe fn put<T>(p: *mut T, v: T) {
|
||||
// SAFETY: per the contract above.
|
||||
if let Some(p) = unsafe { p.as_mut() } {
|
||||
*p = v;
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn beat_and_tempo(user: *mut c_void, beat: *mut f64, tempo: *mut f64) -> OSStatus {
|
||||
// SAFETY: `user` is the `HostTime` leaked by `Instance::new`, alive until after the unit is
|
||||
// disposed.
|
||||
let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 };
|
||||
// SAFETY: the unit's out-pointers (or null).
|
||||
unsafe {
|
||||
put(beat, load_f64(&t.quarters));
|
||||
put(tempo, load_f64(&t.tempo));
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
unsafe extern "C" fn musical_time(
|
||||
user: *mut c_void,
|
||||
to_next_beat: *mut u32,
|
||||
numerator: *mut f32,
|
||||
denominator: *mut u32,
|
||||
downbeat: *mut f64,
|
||||
) -> OSStatus {
|
||||
// SAFETY: as in `beat_and_tempo`.
|
||||
let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 };
|
||||
let q = load_f64(&t.quarters);
|
||||
let per_quarter = load_f64(&t.sample_rate) * 60.0 / load_f64(&t.tempo).max(1.0);
|
||||
let next = ((q.ceil() - q) * per_quarter).round();
|
||||
let next = if next.is_finite() { next.clamp(0.0, f64::from(u32::MAX)) as u32 } else { 0 };
|
||||
// SAFETY: the unit's out-pointers (or null).
|
||||
unsafe {
|
||||
put(to_next_beat, next);
|
||||
put(numerator, t.numerator.load(Ordering::Relaxed) as f32);
|
||||
put(denominator, t.denominator.load(Ordering::Relaxed));
|
||||
put(downbeat, load_f64(&t.bar_start));
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
unsafe extern "C" fn transport_state2(
|
||||
user: *mut c_void,
|
||||
playing: *mut u8,
|
||||
recording: *mut u8,
|
||||
changed: *mut u8,
|
||||
sample: *mut f64,
|
||||
cycling: *mut u8,
|
||||
cycle_start: *mut f64,
|
||||
cycle_end: *mut f64,
|
||||
) -> OSStatus {
|
||||
// SAFETY: as in `beat_and_tempo`.
|
||||
let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 };
|
||||
// SAFETY: the unit's out-pointers (or null).
|
||||
unsafe {
|
||||
put(playing, u8::from(t.playing.load(Ordering::Relaxed)));
|
||||
put(recording, u8::from(t.recording.load(Ordering::Relaxed)));
|
||||
put(changed, u8::from(t.changed.swap(false, Ordering::Relaxed)));
|
||||
put(sample, t.samples.load(Ordering::Relaxed) as f64);
|
||||
put(cycling, 0);
|
||||
put(cycle_start, 0.0);
|
||||
put(cycle_end, 0.0);
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
unsafe extern "C" fn transport_state(
|
||||
user: *mut c_void,
|
||||
playing: *mut u8,
|
||||
changed: *mut u8,
|
||||
sample: *mut f64,
|
||||
cycling: *mut u8,
|
||||
cycle_start: *mut f64,
|
||||
cycle_end: *mut f64,
|
||||
) -> OSStatus {
|
||||
// SAFETY: the same pointers as `transport_state2`, without the recording flag.
|
||||
unsafe { transport_state2(user, playing, ptr::null_mut(), changed, sample, cycling, cycle_start, cycle_end) }
|
||||
}
|
||||
|
||||
type AuRenderCallback = unsafe extern "C" fn(
|
||||
ref_con: *mut c_void,
|
||||
action_flags: *mut u32,
|
||||
@@ -361,6 +512,8 @@ pub struct Instance {
|
||||
unit: AudioUnit,
|
||||
/// Leaked `Box<InputState>` read by `input_callback`; freed in `Drop` after disposal.
|
||||
input: *mut InputState,
|
||||
/// Leaked `Box<HostTime>` read by the host callbacks; freed in `Drop` after disposal.
|
||||
time: *mut HostTime,
|
||||
outputs: Vec<Vec<f32>>,
|
||||
abl: AudioBufferList,
|
||||
has_input: bool,
|
||||
@@ -369,11 +522,13 @@ pub struct Instance {
|
||||
max_frames: usize,
|
||||
sample_rate: f64,
|
||||
sample_time: f64,
|
||||
link: std::sync::Arc<StateLink>,
|
||||
}
|
||||
|
||||
// SAFETY: an Audio Unit instance may be used from any thread as long as calls are not
|
||||
// concurrent; `Instance` is only reachable through `&mut`/`&` of its owner, which the `Plugin`
|
||||
// contract keeps on one thread at a time. The input state is owned exclusively by it.
|
||||
// contract keeps on one thread at a time. The input state and host time are owned exclusively by
|
||||
// it (the host time is all atomics).
|
||||
unsafe impl Send for Instance {}
|
||||
|
||||
impl Instance {
|
||||
@@ -398,19 +553,42 @@ impl Instance {
|
||||
return Err(format!("AudioComponentInstanceNew failed ({st})"));
|
||||
}
|
||||
let input = Box::into_raw(Box::new(InputState { bufs: Vec::new(), frames: 0 }));
|
||||
let time = Box::into_raw(Box::new(HostTime::new()));
|
||||
let empty = AudioBuffer { number_channels: 1, data_byte_size: 0, data: ptr::null_mut() };
|
||||
Ok(Instance {
|
||||
let inst = Instance {
|
||||
unit,
|
||||
input,
|
||||
time,
|
||||
outputs: Vec::new(),
|
||||
abl: AudioBufferList { number_buffers: 0, buffers: [empty; MAX_AU_CHANNELS] },
|
||||
has_input,
|
||||
link: std::sync::Arc::new(StateLink(std::sync::Mutex::new(LinkedUnit(Some(unit))))),
|
||||
initialized: false,
|
||||
channels: 0,
|
||||
max_frames: 0,
|
||||
sample_rate: 48_000.0,
|
||||
sample_time: 0.0,
|
||||
})
|
||||
};
|
||||
let callbacks = HostCallbackInfo {
|
||||
host_user_data: time.cast(),
|
||||
beat_and_tempo: Some(beat_and_tempo),
|
||||
musical_time_location: Some(musical_time),
|
||||
transport_state: Some(transport_state),
|
||||
transport_state2: Some(transport_state2),
|
||||
};
|
||||
// Units that never ask for host time may refuse it.
|
||||
let _ = inst.set_property(PROP_HOST_CALLBACKS, SCOPE_GLOBAL, 0, &callbacks);
|
||||
Ok(inst)
|
||||
}
|
||||
|
||||
fn time(&self) -> &HostTime {
|
||||
// SAFETY: `time` is the `HostTime` leaked in `new`, freed only in `Drop`.
|
||||
unsafe { &*self.time }
|
||||
}
|
||||
|
||||
/// The transport the host callbacks report during the next renders.
|
||||
pub fn set_transport(&self, t: &Transport) {
|
||||
self.time().set(t);
|
||||
}
|
||||
|
||||
fn set_property<T>(&self, id: u32, scope: u32, element: u32, value: &T) -> OSStatus {
|
||||
@@ -490,6 +668,7 @@ impl Instance {
|
||||
self.initialized = true;
|
||||
self.channels = ch;
|
||||
self.sample_rate = sample_rate;
|
||||
self.time().sample_rate.store(sample_rate.to_bits(), Ordering::Relaxed);
|
||||
self.outputs = (0..ch).map(|_| vec![0.0; mf]).collect();
|
||||
// SAFETY: the callback only runs inside `AudioUnitRender`, which is not running now
|
||||
// (we hold `&mut self`), so this exclusive access does not alias.
|
||||
@@ -678,35 +857,12 @@ impl Instance {
|
||||
out
|
||||
}
|
||||
|
||||
/// The unit's `ClassInfo` as a binary property list.
|
||||
pub fn save_state(&self) -> Option<Vec<u8>> {
|
||||
let mut plist: CFTypeRef = ptr::null();
|
||||
let mut size = std::mem::size_of::<CFTypeRef>() as u32;
|
||||
// SAFETY: the property is a `CFPropertyListRef` returned +1; `plist` is a local pointer.
|
||||
let st = unsafe { AudioUnitGetProperty(self.unit, PROP_CLASS_INFO, SCOPE_GLOBAL, 0, (&mut plist as *mut CFTypeRef).cast(), &mut size) };
|
||||
if st != 0 || plist.is_null() {
|
||||
release(plist);
|
||||
return None;
|
||||
}
|
||||
let mut err: CFTypeRef = ptr::null();
|
||||
// SAFETY: `plist` is a live property list; `err` is a local out pointer.
|
||||
let data = unsafe { CFPropertyListCreateData(ptr::null(), plist, CF_PROPERTY_LIST_BINARY_FORMAT, 0, &mut err) };
|
||||
release(err);
|
||||
release(plist);
|
||||
if data.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: `data` is a live CFData we own; its byte pointer is valid for its length.
|
||||
let out = unsafe {
|
||||
let len = CFDataGetLength(data);
|
||||
let p = CFDataGetBytePtr(data);
|
||||
if len <= 0 || len as usize > MAX_STATE || p.is_null() { None } else { Some(std::slice::from_raw_parts(p, len as usize).to_vec()) }
|
||||
};
|
||||
release(data);
|
||||
out
|
||||
/// The link for state handles.
|
||||
pub fn state_link(&self) -> std::sync::Arc<StateLink> {
|
||||
std::sync::Arc::clone(&self.link)
|
||||
}
|
||||
|
||||
/// Restores a blob from [`Instance::save_state`] (hostile input: parsed by CoreFoundation,
|
||||
/// Restores a blob from [`StateLink::save_state`] (hostile input: parsed by CoreFoundation,
|
||||
/// must be a dictionary, and the unit validates it).
|
||||
pub fn load_state(&self, bytes: &[u8]) -> bool {
|
||||
if bytes.is_empty() || bytes.len() > MAX_STATE {
|
||||
@@ -733,16 +889,71 @@ impl Instance {
|
||||
}
|
||||
}
|
||||
|
||||
struct LinkedUnit(Option<AudioUnit>);
|
||||
|
||||
// SAFETY: the pointer is only used under the link's mutex, while the instance keeps the unit alive.
|
||||
unsafe impl Send for LinkedUnit {}
|
||||
|
||||
/// The link between an instance and its state handles: the unit's state is read on the main
|
||||
/// thread while the instance renders on the audio thread (hosts make property calls from other
|
||||
/// threads during rendering). The instance clears the link under its lock before it disposes of
|
||||
/// the unit, so a handle never reads a dead unit.
|
||||
pub(crate) struct StateLink(std::sync::Mutex<LinkedUnit>);
|
||||
|
||||
impl StateLink {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, LinkedUnit> {
|
||||
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The unit's `ClassInfo` as a binary property list (`None` once the instance is gone).
|
||||
pub fn save_state(&self) -> Option<Vec<u8>> {
|
||||
let g = self.lock();
|
||||
let unit = g.0?;
|
||||
let mut plist: CFTypeRef = ptr::null();
|
||||
let mut size = std::mem::size_of::<CFTypeRef>() as u32;
|
||||
// SAFETY: the unit is live while the link holds it (see the type); the property is a
|
||||
// `CFPropertyListRef` returned +1; `plist` is a local pointer.
|
||||
let st = unsafe { AudioUnitGetProperty(unit, PROP_CLASS_INFO, SCOPE_GLOBAL, 0, (&mut plist as *mut CFTypeRef).cast(), &mut size) };
|
||||
if st != 0 || plist.is_null() {
|
||||
release(plist);
|
||||
return None;
|
||||
}
|
||||
let mut err: CFTypeRef = ptr::null();
|
||||
// SAFETY: `plist` is a live property list; `err` is a local out pointer.
|
||||
let data = unsafe { CFPropertyListCreateData(ptr::null(), plist, CF_PROPERTY_LIST_BINARY_FORMAT, 0, &mut err) };
|
||||
release(err);
|
||||
release(plist);
|
||||
if data.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: `data` is a live CFData we own; its byte pointer is valid for its length.
|
||||
let out = unsafe {
|
||||
let len = CFDataGetLength(data);
|
||||
let p = CFDataGetBytePtr(data);
|
||||
if len <= 0 || len as usize > MAX_STATE || p.is_null() { None } else { Some(std::slice::from_raw_parts(p, len as usize).to_vec()) }
|
||||
};
|
||||
release(data);
|
||||
out
|
||||
}
|
||||
|
||||
fn kill(&self) {
|
||||
self.lock().0 = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Instance {
|
||||
fn drop(&mut self) {
|
||||
self.link.kill();
|
||||
// SAFETY: the unit is live (disposed only here); after disposal nothing can call the
|
||||
// input callback, so the leaked input state can be freed.
|
||||
// input callback or the host callbacks, so the leaked input state and host time can be
|
||||
// freed.
|
||||
unsafe {
|
||||
if self.initialized {
|
||||
AudioUnitUninitialize(self.unit);
|
||||
}
|
||||
AudioComponentInstanceDispose(self.unit);
|
||||
drop(Box::from_raw(self.input));
|
||||
drop(Box::from_raw(self.time));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -751,6 +962,31 @@ impl Drop for Instance {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn host_callbacks_report_the_transport() {
|
||||
let time = HostTime::new();
|
||||
time.set(&Transport { playing: true, tempo: 120.0, numerator: 3, denominator: 4, samples: 48_000, quarters: 2.25, ..Transport::default() });
|
||||
let user = ptr::from_ref(&time).cast_mut().cast::<c_void>();
|
||||
let (mut beat, mut tempo) = (0.0, 0.0);
|
||||
// SAFETY: `user` points at a live `HostTime`; the out-pointers are locals.
|
||||
assert_eq!(unsafe { beat_and_tempo(user, &mut beat, &mut tempo) }, 0);
|
||||
assert_eq!((beat, tempo), (2.25, 120.0));
|
||||
let (mut next, mut num, mut den, mut down) = (0u32, 0f32, 0u32, -1.0);
|
||||
// SAFETY: as above.
|
||||
assert_eq!(unsafe { musical_time(user, &mut next, &mut num, &mut den, &mut down) }, 0);
|
||||
assert_eq!((next, num, den, down), (18_000, 3.0, 4, 0.0));
|
||||
let (mut playing, mut recording, mut changed, mut sample) = (0u8, 1u8, 0u8, 0.0);
|
||||
let (no_flag, no_beat) = (ptr::null_mut::<u8>(), ptr::null_mut::<f64>());
|
||||
// SAFETY: as above; the unit may pass null for what it doesn't want.
|
||||
let st = unsafe { transport_state2(user, &mut playing, &mut recording, &mut changed, &mut sample, no_flag, no_beat, no_beat) };
|
||||
assert_eq!((st, playing, recording, changed, sample), (0, 1, 0, 1, 48_000.0));
|
||||
// SAFETY: as above.
|
||||
unsafe { transport_state(user, &mut playing, &mut changed, no_beat, no_flag, no_beat, no_beat) };
|
||||
assert_eq!(changed, 0, "a change is reported once");
|
||||
// SAFETY: a null user pointer is refused before anything is read.
|
||||
assert_eq!(unsafe { beat_and_tempo(ptr::null_mut(), &mut beat, &mut tempo) }, -50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn struct_layouts_match_the_c_headers() {
|
||||
assert_eq!(std::mem::size_of::<AudioComponentDescription>(), 20);
|
||||
@@ -761,6 +997,7 @@ mod tests {
|
||||
assert_eq!(std::mem::offset_of!(AudioBufferList, buffers), 8);
|
||||
assert_eq!(std::mem::offset_of!(AudioBufferListHeader, first), 8);
|
||||
assert_eq!(std::mem::size_of::<AuRenderCallbackStruct>(), 16);
|
||||
assert_eq!(std::mem::size_of::<HostCallbackInfo>(), 40);
|
||||
assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, unit_name), 56);
|
||||
assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, cf_name_string), 72);
|
||||
assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, flags), 96);
|
||||
|
||||
@@ -23,8 +23,10 @@
|
||||
//! (`kAudioUnitProperty_ClassInfo`) as a binary plist; [`Plugin::load_state`] parses it back and
|
||||
//! sets it, then re-reads the parameter values.
|
||||
//!
|
||||
//! Not hosted yet: Audio Unit editor views (Cocoa `AUCocoaUIBase`), sidechain/extra buses,
|
||||
//! parameters outside the global scope, and host callbacks (tempo, transport).
|
||||
//! Host callbacks report the session's tempo, meter, position and play state.
|
||||
//!
|
||||
//! Not hosted yet: Audio Unit editor views (Cocoa `AUCocoaUIBase`), sidechain/extra buses, and
|
||||
//! parameters outside the global scope.
|
||||
//!
|
||||
//! Off macOS (and on `wasm32`) the crate compiles to stubs: scans are empty and nothing can be
|
||||
//! created.
|
||||
@@ -190,7 +192,7 @@ pub enum AuError {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod api {
|
||||
use super::*;
|
||||
use super::{AuDescriptor, AuError, Plugin, PluginInfo};
|
||||
|
||||
pub fn scan() -> Vec<AuDescriptor> {
|
||||
registry::scan()
|
||||
@@ -208,7 +210,7 @@ mod api {
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod api {
|
||||
use super::*;
|
||||
use super::{AuDescriptor, AuError, Plugin, PluginInfo};
|
||||
|
||||
pub fn scan() -> Vec<AuDescriptor> {
|
||||
Vec::new()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! `AuPlugin`: a loaded Audio Unit behind the safe `soundcraft_dsp::Plugin` trait.
|
||||
|
||||
use crate::ffi::Instance;
|
||||
use soundcraft_dsp::{Plugin, PluginInfo};
|
||||
use soundcraft_dsp::{Plugin, PluginInfo, Transport};
|
||||
|
||||
/// Most MIDI events queued between two blocks (extra events are dropped).
|
||||
const EVENT_CAP: usize = 1024;
|
||||
@@ -170,13 +170,12 @@ impl Plugin for AuPlugin {
|
||||
if ch == 1 && au_ch >= 2 {
|
||||
if let (Some(l), Some(r)) = (self.inst.output(0, n), self.inst.output(1, n)) {
|
||||
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
|
||||
let v = 0.5 * (a + b);
|
||||
*d = if v.is_finite() { v } else { 0.0 };
|
||||
*d = 0.5 * (a + b);
|
||||
}
|
||||
}
|
||||
} else if let Some(src) = self.inst.output(c.min(au_ch.saturating_sub(1)), n) {
|
||||
for (d, &s) in dst.iter_mut().zip(src) {
|
||||
*d = if s.is_finite() { s } else { 0.0 };
|
||||
*d = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,6 +199,10 @@ impl Plugin for AuPlugin {
|
||||
self.latency
|
||||
}
|
||||
|
||||
fn set_transport(&mut self, transport: &Transport) {
|
||||
self.inst.set_transport(transport);
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
self.tail
|
||||
}
|
||||
@@ -227,6 +230,13 @@ impl Plugin for AuPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
|
||||
let [status, data1, data2] = msg;
|
||||
if status & 0xF0 >= 0xA0 && status & 0xF0 < 0xF0 {
|
||||
self.push_event(MidiEvent { time: u32::try_from(offset).unwrap_or(u32::MAX), status, data1: data1 & 0x7F, data2: data2 & 0x7F });
|
||||
}
|
||||
}
|
||||
|
||||
fn all_notes_off(&mut self) {
|
||||
for k in 0..128u8 {
|
||||
if self.held.get(k as usize).copied().unwrap_or(false) {
|
||||
@@ -237,10 +247,6 @@ impl Plugin for AuPlugin {
|
||||
self.push_event(MidiEvent { time: 0, status: 0xB0, data1: 123, data2: 0 });
|
||||
}
|
||||
|
||||
fn save_state(&mut self) -> Option<Vec<u8>> {
|
||||
self.inst.save_state()
|
||||
}
|
||||
|
||||
fn load_state(&mut self, data: &[u8]) -> bool {
|
||||
let ok = self.inst.load_state(data);
|
||||
if ok {
|
||||
@@ -248,4 +254,9 @@ impl Plugin for AuPlugin {
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
fn state_reader(&mut self) -> Option<Box<dyn soundcraft_dsp::PluginStateReader>> {
|
||||
let link = self.inst.state_link();
|
||||
Some(Box::new(move || link.save_state()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod system_units;
|
||||
@@ -1,6 +1,5 @@
|
||||
//! End to end against the Audio Units Apple ships with macOS: scan → instantiate → params →
|
||||
//! process → state, plus an instrument playing a note. Skips (passes) when a unit is missing.
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use soundcraft_dsp::{Category, Plugin};
|
||||
|
||||
@@ -100,6 +99,35 @@ fn lowpass_attenuates_highs() {
|
||||
assert!(run_tone(p.as_mut(), 100.0).is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_reader_reads_while_another_thread_renders() {
|
||||
if !available(LOWPASS) {
|
||||
return;
|
||||
}
|
||||
let info = soundcraft_au_host::plugin_info(LOWPASS).unwrap();
|
||||
let cutoff = info.params.iter().find(|q| q.unit == soundcraft_dsp::Unit::Hz).unwrap();
|
||||
let mut p = soundcraft_au_host::create(LOWPASS).unwrap();
|
||||
p.prepare(48_000.0, 512, 2);
|
||||
assert!(p.set_param(cutoff.id, 500.0));
|
||||
let mut reader = p.state_reader().expect("every unit has a ClassInfo");
|
||||
let audio = std::thread::spawn(move || {
|
||||
for _ in 0..4 {
|
||||
run_tone(p.as_mut(), 100.0);
|
||||
}
|
||||
p
|
||||
});
|
||||
while !audio.is_finished() {
|
||||
assert!(reader.save_state().is_some());
|
||||
}
|
||||
let p = audio.join().unwrap();
|
||||
let state = reader.save_state().expect("ClassInfo");
|
||||
drop(p);
|
||||
let mut q = soundcraft_au_host::create(LOWPASS).unwrap();
|
||||
assert_eq!(reader.save_state(), None);
|
||||
assert!(q.load_state(&state));
|
||||
assert!((q.param(cutoff.id).unwrap() - 500.0).abs() < 1.0, "{:?}", q.param(cutoff.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delay_runs_mono_stereo_and_odd_blocks() {
|
||||
if !available(DELAY) {
|
||||
@@ -167,4 +195,23 @@ fn dls_synth_plays_a_note() {
|
||||
assert!(e > 1e-3, "{e}");
|
||||
p.note_off(0, 64);
|
||||
p.reset();
|
||||
// Controllers reach the synth: MIDI volume 0 silences a note. (In this test because creating
|
||||
// two DLS instances at once on two threads crashes inside the unit.)
|
||||
p.prepare(48_000.0, 512, 2);
|
||||
let loudest = |p: &mut Box<dyn Plugin>, volume: u8| {
|
||||
p.midi(0, [0xB0, 7, volume]);
|
||||
p.note_on(0, 60, 110);
|
||||
let mut energy = 0.0f32;
|
||||
for _ in 0..20 {
|
||||
let mut io = vec![vec![0.0f32; 512]; 2];
|
||||
p.process(&mut io, 512);
|
||||
energy = energy.max(rms(&io[0]));
|
||||
}
|
||||
p.note_off(0, 60);
|
||||
p.reset();
|
||||
energy
|
||||
};
|
||||
let full = loudest(&mut p, 127);
|
||||
let muted = loudest(&mut p, 0);
|
||||
assert!(full > 1e-3 && muted < full * 0.01, "volume 127: {full}, volume 0: {muted}");
|
||||
}
|
||||
+85
-34
@@ -1,8 +1,9 @@
|
||||
//! Native AIFF / AIFF-C reader and writer (Apple "Audio Interchange File Format" 1.3 and the
|
||||
//! AIFF-C draft: big-endian IFF chunks, 80-bit IEEE extended sample rate).
|
||||
|
||||
use crate::pcm::{PcmKind, Quantizer, be_u16, be_u32, clamp_float, deinterleave, tag, validate_buffer};
|
||||
use crate::pcm::{PcmKind, Quantizer, be_u16, be_u32, deinterleave, sample_format, tag, validate_buffer, validate_layout, write_interleaved};
|
||||
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat};
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
|
||||
/// AIFF-C version 1 timestamp for the FVER chunk.
|
||||
const AIFC_VERSION_1: u32 = 0xA280_5140;
|
||||
@@ -162,25 +163,94 @@ fn push_chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
|
||||
/// Encode AIFF (integer depths) or AIFF-C `fl32` (Float32).
|
||||
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result<Vec<u8>> {
|
||||
validate_buffer(buf)?;
|
||||
let channels = buf.channels.len();
|
||||
let channels_i16 = i16::try_from(channels).map_err(|_| AudioError::Encode("too many channels for AIFF".into()))?;
|
||||
let frames = buf.frames();
|
||||
let head_len = header(buf.channels.len(), buf.sample_rate, depth, frames as u64)?.len();
|
||||
let data_len = usize::from(sample_format(depth).0 / 8).saturating_mul(buf.channels.len()).saturating_mul(frames);
|
||||
let total = head_len.saturating_add(data_len).saturating_add(1);
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(total).map_err(|_| AudioError::TooLarge(format!("cannot allocate {total} bytes for AIFF output")))?;
|
||||
let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?;
|
||||
w.write(&buf.channels, frames)?;
|
||||
Ok(w.finish()?.into_inner())
|
||||
}
|
||||
|
||||
/// An AIFF written block by block: a placeholder header first, then the real one (the same
|
||||
/// length) written over it by [`Writer::finish`].
|
||||
pub(crate) struct Writer<W> {
|
||||
out: W,
|
||||
start: u64,
|
||||
channels: usize,
|
||||
sample_rate: u32,
|
||||
depth: BitDepth,
|
||||
quantizers: Vec<Quantizer>,
|
||||
frames: u64,
|
||||
bytes: Vec<u8>,
|
||||
/// Set when a block write has failed: the sink may hold a prefix of that block while
|
||||
/// `frames` does not count it, so the writer refuses every later call.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Writer<W> {
|
||||
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result<Self> {
|
||||
validate_layout(channels, sample_rate)?;
|
||||
let start = out.stream_position()?;
|
||||
out.write_all(&header(channels, sample_rate, depth, 0)?)?;
|
||||
let quantizers = Quantizer::per_channel(u32::from(sample_format(depth).0), dither, channels);
|
||||
Ok(Writer { out, start, channels, sample_rate, depth, quantizers, frames: 0, bytes: Vec::new(), failed: false })
|
||||
}
|
||||
|
||||
/// The frame count after `frames` more, or an error when AIFF can't hold that many (4 GiB).
|
||||
pub(crate) fn check_room(&self, frames: u64) -> Result<u64> {
|
||||
let total = self.frames.saturating_add(frames);
|
||||
header(self.channels, self.sample_rate, self.depth, total)?;
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Appends `frames` frames of planar audio (missing samples are silence), unless they don't
|
||||
/// fit (see [`Writer::check_room`]).
|
||||
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let total = self.check_room(frames as u64)?;
|
||||
if let Err(e) = write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, sample_format(self.depth), true) {
|
||||
self.failed = true;
|
||||
return Err(AudioError::from(e));
|
||||
}
|
||||
self.frames = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut self) -> Result<W> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let data_len = u64::from(sample_format(self.depth).0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
|
||||
if data_len % 2 == 1 {
|
||||
self.out.write_all(&[0])?;
|
||||
}
|
||||
let head = header(self.channels, self.sample_rate, self.depth, self.frames)?;
|
||||
self.out.seek(SeekFrom::Start(self.start))?;
|
||||
self.out.write_all(&head)?;
|
||||
self.out.seek(SeekFrom::End(0))?;
|
||||
self.out.flush()?;
|
||||
Ok(self.out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything before the sample data of an AIFF holding `frames` frames; its length doesn't
|
||||
/// depend on `frames`.
|
||||
fn header(channels: usize, sample_rate: u32, depth: BitDepth, frames: u64) -> Result<Vec<u8>> {
|
||||
let channels_i16 = i16::try_from(channels).map_err(|_| AudioError::Encode("too many channels for AIFF".into()))?;
|
||||
let frames_u32 = u32::try_from(frames).map_err(|_| AudioError::TooLarge("too many frames for AIFF".into()))?;
|
||||
let (bits, is_float): (u16, bool) = match depth {
|
||||
BitDepth::Int16 => (16, false),
|
||||
BitDepth::Int24 => (24, false),
|
||||
BitDepth::Int32 => (32, false),
|
||||
BitDepth::Float32 => (32, true),
|
||||
};
|
||||
let bytes = usize::from(bits / 8);
|
||||
let data_len =
|
||||
bytes.checked_mul(channels).and_then(|b| b.checked_mul(frames)).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
let (bits, is_float) = sample_format(depth);
|
||||
let data_len = u64::from(bits / 8).saturating_mul(channels as u64).saturating_mul(frames);
|
||||
|
||||
let mut comm = Vec::with_capacity(40);
|
||||
comm.extend_from_slice(&channels_i16.to_be_bytes());
|
||||
comm.extend_from_slice(&frames_u32.to_be_bytes());
|
||||
comm.extend_from_slice(&bits.to_be_bytes());
|
||||
comm.extend_from_slice(&u32_to_extended(buf.sample_rate));
|
||||
comm.extend_from_slice(&u32_to_extended(sample_rate));
|
||||
if is_float {
|
||||
comm.extend_from_slice(b"fl32");
|
||||
let name = b"32-bit float";
|
||||
@@ -204,29 +274,10 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result
|
||||
head.extend_from_slice(&ssnd_len.to_be_bytes());
|
||||
head.extend_from_slice(&[0u8; 8]); // offset, blockSize
|
||||
|
||||
let total = head.len().saturating_add(data_len).saturating_add(data_len & 1);
|
||||
let total = (head.len() as u64).saturating_add(data_len).saturating_add(data_len & 1);
|
||||
let form_len = u32::try_from(total - 8).map_err(|_| AudioError::TooLarge("AIFF file exceeds 4 GiB".into()))?;
|
||||
if let Some(slot) = head.get_mut(4..8) {
|
||||
slot.copy_from_slice(&form_len.to_be_bytes());
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(total).map_err(|_| AudioError::TooLarge(format!("cannot allocate {total} bytes for AIFF output")))?;
|
||||
out.extend_from_slice(&head);
|
||||
let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels);
|
||||
for f in 0..frames {
|
||||
for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) {
|
||||
let s = ch.get(f).copied().unwrap_or(0.0);
|
||||
if is_float {
|
||||
out.extend_from_slice(&clamp_float(s).to_be_bytes());
|
||||
} else {
|
||||
let v = q.quantize(s).to_be_bytes();
|
||||
out.extend_from_slice(v.get(4 - bytes..).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
if data_len % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
Ok(out)
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Size checks on CAF chunks before symphonia reads them: it allocates whatever a magic cookie,
|
||||
//! a packet table or a packet claims, and a failed allocation aborts the process.
|
||||
|
||||
use crate::pcm::{be_u16, be_u32, be_u64};
|
||||
use crate::{AudioError, Result};
|
||||
|
||||
/// Refuse the CAF stream at `at` if its magic cookie, packet table or packets claim more bytes
|
||||
/// than the file holds.
|
||||
pub(crate) fn check(b: &[u8], at: usize) -> Result<()> {
|
||||
if be_u16(b, at + 4) == Some(1) { chunks(b, at + 8) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn malformed(what: &str) -> AudioError {
|
||||
AudioError::Malformed(format!("CAF {what} is larger than the file"))
|
||||
}
|
||||
|
||||
/// Walk the chunks the way symphonia 0.5 reads them: it advances by the bytes it parses, not by
|
||||
/// the declared size, and after a `data` chunk of unknown size it reads on into the audio.
|
||||
fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
|
||||
// Whether the packet table lists each packet's size and frame count (variable per packet).
|
||||
let (mut lists_sizes, mut lists_frames, mut described) = (false, false, false);
|
||||
while let (Some(kind), Some(size)) = (b.get(pos..pos + 4), be_u64(b, pos + 4)) {
|
||||
let (body, size) = (pos + 12, size as i64);
|
||||
let used = match kind {
|
||||
b"desc" if size == 32 => {
|
||||
lists_sizes = be_u32(b, body + 16) == Some(0);
|
||||
lists_frames = be_u32(b, body + 20) == Some(0);
|
||||
described = true;
|
||||
Some(32)
|
||||
}
|
||||
b"data" if size == -1 => Some(4),
|
||||
b"chan" if size >= 12 => be_u32(b, body + 8).map(|n| 12 + u64::from(n) * 20),
|
||||
b"pakt" if size >= 24 && described => packet_table(b, body, lists_sizes, lists_frames)?,
|
||||
b"kuki" if size >= 0 && size as u64 > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
|
||||
b"desc" | b"pakt" | b"chan" => None,
|
||||
_ => u64::try_from(size).ok(),
|
||||
};
|
||||
match used.filter(|_| described).and_then(|n| usize::try_from(n).ok()).and_then(|n| body.checked_add(n)) {
|
||||
Some(next) if next < b.len() => pos = next,
|
||||
_ => return Ok(()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check a packet table's count and packet sizes; returns the bytes symphonia reads for it, or
|
||||
/// None where symphonia stops. It reserves room for every packet before reading any, so a table
|
||||
/// it would refuse after that, or that runs past the file, is refused first.
|
||||
fn packet_table(b: &[u8], body: usize, lists_sizes: bool, lists_frames: bool) -> Result<Option<u64>> {
|
||||
let valid = |at: usize| be_u64(b, at).filter(|&n| n as i64 >= 0);
|
||||
let (Some(packets), Some(_)) = (valid(body), valid(body + 8)) else { return Ok(None) };
|
||||
let mut at = body + 24;
|
||||
if packets > b.len().saturating_sub(at) as u64 {
|
||||
return Err(malformed("packet table"));
|
||||
}
|
||||
if !lists_sizes && !lists_frames {
|
||||
return if packets == 0 { Ok(Some(24)) } else { Err(AudioError::Malformed("a constant bit rate CAF lists packets".into())) };
|
||||
}
|
||||
for _ in 0..packets {
|
||||
if lists_sizes {
|
||||
let Some((size, len)) = varint(b, at) else { return Err(malformed("packet table")) };
|
||||
if size > b.len() as u64 {
|
||||
return Err(malformed("packet"));
|
||||
}
|
||||
at += len;
|
||||
}
|
||||
if lists_frames {
|
||||
let Some((_, len)) = varint(b, at) else { return Err(malformed("packet table")) };
|
||||
at += len;
|
||||
}
|
||||
}
|
||||
Ok(Some((at - body) as u64))
|
||||
}
|
||||
|
||||
/// A CAF variable-length integer at `at` and its length in bytes.
|
||||
fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> {
|
||||
let mut v = 0u64;
|
||||
for i in 0..9 {
|
||||
let x = *b.get(at + i)?;
|
||||
v |= u64::from(x & 0x7f);
|
||||
if x & 0x80 == 0 {
|
||||
return Some((v, i + 1));
|
||||
}
|
||||
v <<= 7;
|
||||
}
|
||||
None
|
||||
}
|
||||
+223
-42
@@ -1,9 +1,10 @@
|
||||
//! A small pure-Rust FLAC encoder (RFC 9639): fixed blocksize, independent channels, and per
|
||||
//! subframe the cheapest of CONSTANT, VERBATIM or FIXED (orders 0–4) prediction with
|
||||
//! partitioned Rice-coded residuals.
|
||||
//! partitioned Rice-coded residuals. Decoding is symphonia's, after [`check_metadata`].
|
||||
|
||||
use crate::pcm::{Quantizer, validate_buffer};
|
||||
use crate::{AudioBuffer, AudioError, BitDepth, Result};
|
||||
use crate::pcm::{Quantizer, be_u32, le_u32, validate_buffer};
|
||||
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat};
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
|
||||
const BLOCK_SIZE: usize = 4096;
|
||||
const MAX_PARTITION_ORDER: u32 = 6;
|
||||
@@ -269,31 +270,24 @@ fn write_subframe(w: &mut BitWriter, x: &[i64], bps: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result<Vec<u8>> {
|
||||
validate_buffer(buf)?;
|
||||
let channels = buf.channels.len();
|
||||
/// Checks that FLAC can hold the layout; returns the bits per sample.
|
||||
fn check(channels: usize, sample_rate: u32, depth: BitDepth) -> Result<u32> {
|
||||
crate::pcm::validate_layout(channels, sample_rate)?;
|
||||
if channels > 8 {
|
||||
return Err(AudioError::Unsupported(format!("FLAC supports at most 8 channels, got {channels}")));
|
||||
}
|
||||
if buf.sample_rate > 655_350 {
|
||||
return Err(AudioError::Unsupported(format!("FLAC sample rate {} exceeds 655350 Hz", buf.sample_rate)));
|
||||
if sample_rate > 655_350 {
|
||||
return Err(AudioError::Unsupported(format!("FLAC sample rate {sample_rate} exceeds 655350 Hz")));
|
||||
}
|
||||
let bps: u32 = match depth {
|
||||
BitDepth::Int16 => 16,
|
||||
BitDepth::Int24 => 24,
|
||||
BitDepth::Int32 => 32,
|
||||
BitDepth::Float32 => return Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())),
|
||||
};
|
||||
let frames = buf.frames();
|
||||
let total = frames as u64;
|
||||
if total >= 1u64 << 36 {
|
||||
return Err(AudioError::TooLarge("too many frames for FLAC".into()));
|
||||
match depth {
|
||||
BitDepth::Int16 => Ok(16),
|
||||
BitDepth::Int24 => Ok(24),
|
||||
BitDepth::Int32 => Ok(32),
|
||||
BitDepth::Float32 => Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())),
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve(frames.saturating_mul(channels).saturating_mul(bps as usize / 8) / 2)
|
||||
.map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?;
|
||||
out.extend_from_slice(b"fLaC");
|
||||
fn stream_info(channels: usize, sample_rate: u32, bps: u32, total: u64) -> Vec<u8> {
|
||||
let mut si = BitWriter::new();
|
||||
si.write(1, 1); // last metadata block
|
||||
si.write(0, 7); // STREAMINFO
|
||||
@@ -302,7 +296,7 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result
|
||||
si.write(BLOCK_SIZE as u64, 16);
|
||||
si.write(0, 24);
|
||||
si.write(0, 24);
|
||||
si.write(u64::from(buf.sample_rate), 20);
|
||||
si.write(u64::from(sample_rate), 20);
|
||||
si.write(channels as u64 - 1, 3);
|
||||
si.write(u64::from(bps - 1), 5);
|
||||
si.write(total >> 32, 4);
|
||||
@@ -310,25 +304,98 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result
|
||||
for _ in 0..4 {
|
||||
si.write(0, 32); // MD5 unknown
|
||||
}
|
||||
out.extend_from_slice(&si.into_bytes());
|
||||
si.into_bytes()
|
||||
}
|
||||
|
||||
let (sr_code, sr_extra) = sample_rate_code(buf.sample_rate);
|
||||
let mut quantizers = Quantizer::per_channel(bps, dither, channels);
|
||||
let mut block: Vec<i64> = Vec::with_capacity(BLOCK_SIZE);
|
||||
let mut frame_no: u64 = 0;
|
||||
let mut start = 0usize;
|
||||
while start < frames {
|
||||
let len = (frames - start).min(BLOCK_SIZE);
|
||||
/// A FLAC stream written block by block; [`Writer::finish`] fills in the total sample count.
|
||||
pub(crate) struct Writer<W> {
|
||||
out: W,
|
||||
start: u64,
|
||||
channels: usize,
|
||||
sample_rate: u32,
|
||||
bps: u32,
|
||||
quantizers: Vec<Quantizer>,
|
||||
pending: Vec<Vec<i64>>,
|
||||
frames: u64,
|
||||
frame_no: u64,
|
||||
/// Set when a frame write has failed: `write_frame` clears the pending samples before
|
||||
/// writing, so a failed frame is lost while `frames` still counts it; the writer refuses
|
||||
/// every later call.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Writer<W> {
|
||||
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result<Self> {
|
||||
let bps = check(channels, sample_rate, depth)?;
|
||||
let start = out.stream_position()?;
|
||||
out.write_all(b"fLaC")?;
|
||||
out.write_all(&stream_info(channels, sample_rate, bps, 0))?;
|
||||
Ok(Writer {
|
||||
out,
|
||||
start,
|
||||
channels,
|
||||
sample_rate,
|
||||
bps,
|
||||
quantizers: Quantizer::per_channel(bps, dither, channels),
|
||||
pending: (0..channels).map(|_| Vec::with_capacity(BLOCK_SIZE)).collect(),
|
||||
frames: 0,
|
||||
frame_no: 0,
|
||||
failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The frame count after `frames` more, or an error past what FLAC can count (2^36 frames).
|
||||
pub(crate) fn check_room(&self, frames: u64) -> Result<u64> {
|
||||
let total = self.frames.saturating_add(frames);
|
||||
if total >= 1u64 << 36 {
|
||||
return Err(AudioError::TooLarge("too many frames for FLAC".into()));
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Appends `frames` frames of planar audio (missing samples are silence), unless they don't
|
||||
/// fit (see [`Writer::check_room`]).
|
||||
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let total = self.check_room(frames as u64)?;
|
||||
let mut done = 0;
|
||||
while done < frames {
|
||||
let held = self.pending.first().map_or(0, Vec::len);
|
||||
let n = (BLOCK_SIZE - held).min(frames - done);
|
||||
for (c, (p, q)) in self.pending.iter_mut().zip(self.quantizers.iter_mut()).enumerate() {
|
||||
let src = block.get(c).and_then(|ch| ch.get(done..)).unwrap_or_default();
|
||||
p.extend((0..n).map(|i| i64::from(q.quantize(src.get(i).copied().unwrap_or(0.0)))));
|
||||
}
|
||||
done += n;
|
||||
if held + n == BLOCK_SIZE
|
||||
&& let Err(e) = self.write_frame()
|
||||
{
|
||||
self.failed = true;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
self.frames = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_frame(&mut self) -> Result<()> {
|
||||
let len = self.pending.first().map_or(0, Vec::len);
|
||||
if len == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let (sr_code, sr_extra) = sample_rate_code(self.sample_rate);
|
||||
let mut w = BitWriter::new();
|
||||
w.write(0x3FFE, 14);
|
||||
w.write(0, 1);
|
||||
w.write(0, 1); // fixed blocksize stream
|
||||
w.write(7, 4); // 16-bit (blocksize-1) follows
|
||||
w.write(sr_code, 4);
|
||||
w.write(channels as u64 - 1, 4);
|
||||
w.write(bps_code(bps), 3);
|
||||
w.write(self.channels as u64 - 1, 4);
|
||||
w.write(bps_code(self.bps), 3);
|
||||
w.write(0, 1);
|
||||
write_utf8_number(&mut w, frame_no);
|
||||
write_utf8_number(&mut w, self.frame_no);
|
||||
w.write(len as u64 - 1, 16);
|
||||
if let Some((v, n)) = sr_extra {
|
||||
w.write(v, n);
|
||||
@@ -339,19 +406,133 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result
|
||||
w.write(u64::from(b), 8);
|
||||
}
|
||||
w.write(u64::from(crc8(&header)), 8);
|
||||
for (ch, q) in buf.channels.iter().zip(quantizers.iter_mut()) {
|
||||
block.clear();
|
||||
block.extend(ch.get(start..start + len).unwrap_or_default().iter().map(|&s| i64::from(q.quantize(s))));
|
||||
write_subframe(&mut w, &block, bps);
|
||||
for p in &mut self.pending {
|
||||
write_subframe(&mut w, p, self.bps);
|
||||
p.clear();
|
||||
}
|
||||
let mut frame = w.into_bytes();
|
||||
let crc = crc16(&frame);
|
||||
frame.extend_from_slice(&crc.to_be_bytes());
|
||||
out.extend_from_slice(&frame);
|
||||
start += len;
|
||||
frame_no += 1;
|
||||
self.out.write_all(&frame)?;
|
||||
self.frame_no += 1;
|
||||
Ok(())
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
pub(crate) fn finish(mut self) -> Result<W> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
self.write_frame()?;
|
||||
let info = stream_info(self.channels, self.sample_rate, self.bps, self.frames);
|
||||
self.out.seek(SeekFrom::Start(self.start.saturating_add(4)))?;
|
||||
self.out.write_all(&info)?;
|
||||
self.out.seek(SeekFrom::End(0))?;
|
||||
self.out.flush()?;
|
||||
Ok(self.out)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result<Vec<u8>> {
|
||||
validate_buffer(buf)?;
|
||||
let bps = check(buf.channels.len(), buf.sample_rate, depth)?;
|
||||
let frames = buf.frames();
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve(frames.saturating_mul(buf.channels.len()).saturating_mul(bps as usize / 8) / 2)
|
||||
.map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?;
|
||||
let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?;
|
||||
w.write(&buf.channels, frames)?;
|
||||
Ok(w.finish()?.into_inner())
|
||||
}
|
||||
|
||||
/// Refuse the FLAC stream at `at` if a comment or picture field claims more bytes than its
|
||||
/// metadata block holds: symphonia allocates each field's length before reading it.
|
||||
pub(crate) fn check_metadata(b: &[u8], at: usize) -> Result<()> {
|
||||
let mut pos = at + 4;
|
||||
while let Some(head) = be_u32(b, pos) {
|
||||
let (body, len) = (pos + 4, (head & 0x00ff_ffff) as usize);
|
||||
let end = (body + len).min(b.len());
|
||||
let overruns = match (head >> 24) & 0x7f {
|
||||
4 => comments_overrun(b, body, end),
|
||||
// Picture: type, MIME type, description, four size fields, data.
|
||||
6 => overruns(b, body + 4, end, be_u32, [0, 0, 16]),
|
||||
_ => false,
|
||||
};
|
||||
if overruns {
|
||||
return Err(AudioError::Malformed("a FLAC metadata field is larger than its block".into()));
|
||||
}
|
||||
if head >> 31 == 1 {
|
||||
break;
|
||||
}
|
||||
pos = body + len;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The info of a FLAC stream with no audio frames at all: STREAMINFO declares 0 samples and
|
||||
/// nothing follows the metadata blocks. Symphonia's reader fails on these, as it looks for a first
|
||||
/// frame, so the caller answers them itself.
|
||||
pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option<AudioInfo> {
|
||||
let mut pos = at.checked_add(4)?;
|
||||
let mut first = true;
|
||||
loop {
|
||||
let head = be_u32(b, pos)?;
|
||||
let (body, len) = (pos.checked_add(4)?, (head & 0x00ff_ffff) as usize);
|
||||
let end = body.checked_add(len)?;
|
||||
if end > b.len() {
|
||||
return None;
|
||||
}
|
||||
if first && ((head >> 24) & 0x7f != 0 || len != 34) {
|
||||
return None;
|
||||
}
|
||||
first = false;
|
||||
if head >> 31 == 1 {
|
||||
if end != b.len() {
|
||||
return None;
|
||||
}
|
||||
break;
|
||||
}
|
||||
pos = end;
|
||||
}
|
||||
// STREAMINFO, from the sample rate on: 20 bits rate, 3 channels - 1, 5 bits per sample - 1, 36 bits total.
|
||||
let si = at.checked_add(4 + 4 + 10)?;
|
||||
let packed = u64::from_be_bytes(b.get(si..si.checked_add(8)?)?.try_into().ok()?);
|
||||
let sample_rate = (packed >> 44) as u32;
|
||||
let channels = ((packed >> 41) & 0x7) as u16 + 1;
|
||||
let bps = ((packed >> 36) & 0x1f) as u32 + 1;
|
||||
if packed & 0xf_ffff_ffff != 0 || sample_rate == 0 {
|
||||
return None;
|
||||
}
|
||||
let sample_format = match bps {
|
||||
1..=8 => SampleFormat::Int8,
|
||||
9..=16 => SampleFormat::Int16,
|
||||
17..=24 => SampleFormat::Int24,
|
||||
_ => SampleFormat::Int32,
|
||||
};
|
||||
Some(AudioInfo { format: FileFormat::Flac, sample_format, sample_rate, channels, frames: 0, bwf: None })
|
||||
}
|
||||
|
||||
/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds.
|
||||
/// The vendor string is skipped unread.
|
||||
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
|
||||
let Some(vendor) = le_u32(b, body) else { return false };
|
||||
let at = (body + 4).saturating_add(vendor as usize);
|
||||
let Some(count) = le_u32(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
|
||||
overruns(b, at + 4, end, le_u32, std::iter::repeat_n(0, count as usize))
|
||||
}
|
||||
|
||||
/// Whether one of the length-prefixed fields from `at` claims more than `end` leaves; `gaps` are
|
||||
/// the bytes skipped before each length, which `read` reads.
|
||||
fn overruns(b: &[u8], mut at: usize, end: usize, read: fn(&[u8], usize) -> Option<u32>, gaps: impl IntoIterator<Item = usize>) -> bool {
|
||||
for gap in gaps {
|
||||
at = at.saturating_add(gap);
|
||||
let Some(n) = read(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
|
||||
at += 4;
|
||||
if n as usize > end - at {
|
||||
return true;
|
||||
}
|
||||
at += n as usize;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -10,17 +10,22 @@
|
||||
//! encoder), with optional TPDF dither.
|
||||
//! * [`wav_float_header`] heads a float WAV written while it grows (recording).
|
||||
//! * [`peaks`] builds multi-resolution min/max overviews for waveform drawing.
|
||||
//! * [`FileEncoder`] writes the same formats block by block into any `Write + Seek`, so a file
|
||||
//! of any length never has to fit in memory.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod aiff;
|
||||
mod buffer;
|
||||
mod caf;
|
||||
mod flac;
|
||||
mod mp4;
|
||||
mod pcm;
|
||||
pub mod peaks;
|
||||
mod symph;
|
||||
mod wav;
|
||||
|
||||
pub use buffer::AudioBuffer;
|
||||
use std::io::{Seek, Write};
|
||||
|
||||
/// Errors from probing, decoding and encoding.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -37,6 +42,9 @@ pub enum AudioError {
|
||||
/// Encoding failed (e.g. an empty buffer).
|
||||
#[error("audio encode failed: {0}")]
|
||||
Encode(String),
|
||||
/// Writing the output failed.
|
||||
#[error("audio write failed: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Result alias for this crate.
|
||||
@@ -187,7 +195,7 @@ fn detect_by_ext(ext: &str) -> FileFormat {
|
||||
"flac" => FileFormat::Flac,
|
||||
"mp3" | "mp2" | "mp1" => FileFormat::Mp3,
|
||||
"ogg" | "oga" => FileFormat::Ogg,
|
||||
"aac" | "m4a" | "mp4" | "adts" => FileFormat::Aac,
|
||||
"aac" | "m4a" | "m4b" | "mp4" | "adts" => FileFormat::Aac,
|
||||
"caf" => FileFormat::Caf,
|
||||
_ => FileFormat::Other,
|
||||
}
|
||||
@@ -205,16 +213,16 @@ pub fn probe(bytes: &[u8], ext_hint: Option<&str>) -> Result<AudioInfo> {
|
||||
FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? {
|
||||
wav::WavParse::Native(l) => Ok(l.info),
|
||||
wav::WavParse::Foreign { bwf } => {
|
||||
let mut info = symph::probe(bytes, Some("wav"), format)?;
|
||||
let mut info = symph::probe(bytes, format)?;
|
||||
info.bwf = bwf;
|
||||
Ok(info)
|
||||
}
|
||||
},
|
||||
FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? {
|
||||
aiff::AiffParse::Native(l) => Ok(l.info),
|
||||
aiff::AiffParse::Foreign => symph::probe(bytes, Some("aiff"), format),
|
||||
aiff::AiffParse::Foreign => symph::probe(bytes, format),
|
||||
},
|
||||
_ => symph::probe(bytes, ext_hint, format),
|
||||
_ => symph::probe(bytes, format),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +238,7 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB
|
||||
Ok((info, buf))
|
||||
}
|
||||
wav::WavParse::Foreign { bwf } => {
|
||||
let (mut info, buf) = symph::decode(bytes, Some("wav"), format)?;
|
||||
let (mut info, buf) = symph::decode(bytes, format)?;
|
||||
info.bwf = bwf;
|
||||
Ok((info, buf))
|
||||
}
|
||||
@@ -242,9 +250,9 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB
|
||||
info.frames = buf.frames() as u64;
|
||||
Ok((info, buf))
|
||||
}
|
||||
aiff::AiffParse::Foreign => symph::decode(bytes, Some("aiff"), format),
|
||||
aiff::AiffParse::Foreign => symph::decode(bytes, format),
|
||||
},
|
||||
_ => symph::decode(bytes, ext_hint, format),
|
||||
_ => symph::decode(bytes, format),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +294,69 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u
|
||||
}
|
||||
}
|
||||
|
||||
/// The error a stream encoder returns for every call after one of its writes has failed: the
|
||||
/// sink may already hold a prefix of the failed block, so the encoder's counters can no longer
|
||||
/// describe the bytes on the sink, and later writes or `finish` must not claim success.
|
||||
pub(crate) fn failed_write_error() -> AudioError {
|
||||
AudioError::Encode("the stream encoder already failed on an earlier write; its output is incomplete".into())
|
||||
}
|
||||
|
||||
/// Writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32` or FLAC block by block, producing
|
||||
/// the same bytes [`encode_with_channel_mask`] would for the whole buffer.
|
||||
pub struct FileEncoder<W: Write + Seek>(Writer<W>);
|
||||
|
||||
enum Writer<W> {
|
||||
Wav(wav::Writer<W>),
|
||||
Aiff(aiff::Writer<W>),
|
||||
Flac(flac::Writer<W>),
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> FileEncoder<W> {
|
||||
/// Starts a file at the current position of `out`. `mask` overrides the WAV speaker mask
|
||||
/// derived from the channel count.
|
||||
pub fn new(out: W, opts: &EncodeOptions, channels: usize, sample_rate: u32, mask: Option<u32>) -> Result<Self> {
|
||||
let (depth, dither) = (opts.bit_depth, opts.dither);
|
||||
Ok(FileEncoder(match opts.format {
|
||||
FileFormat::Wav => Writer::Wav(wav::Writer::new(out, channels, sample_rate, depth, dither, opts.bwf.as_ref(), mask, false)?),
|
||||
FileFormat::Aiff => Writer::Aiff(aiff::Writer::new(out, channels, sample_rate, depth, dither)?),
|
||||
FileFormat::Flac => Writer::Flac(flac::Writer::new(out, channels, sample_rate, depth, dither)?),
|
||||
other => return Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Fails, without writing anything, when `frames` more frames would not fit the format (an
|
||||
/// AIFF holds 4 GiB, a FLAC 2^36 frames).
|
||||
pub fn check_room(&self, frames: u64) -> Result<()> {
|
||||
match &self.0 {
|
||||
Writer::Wav(w) => w.check_room(frames),
|
||||
Writer::Aiff(w) => w.check_room(frames),
|
||||
Writer::Flac(w) => w.check_room(frames),
|
||||
}
|
||||
.map(drop)
|
||||
}
|
||||
|
||||
/// Appends the first `frames` frames of `block`, one `Vec` per channel (missing samples are
|
||||
/// silence), unless they don't fit (see [`FileEncoder::check_room`]). Once a write has
|
||||
/// failed, the encoder is unusable: the sink may hold a prefix of the failed block, so
|
||||
/// every later `write` and `finish` fails instead of completing an inconsistent file.
|
||||
pub fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
match &mut self.0 {
|
||||
Writer::Wav(w) => w.write(block, frames),
|
||||
Writer::Aiff(w) => w.write(block, frames),
|
||||
Writer::Flac(w) => w.write(block, frames),
|
||||
}
|
||||
}
|
||||
|
||||
/// Completes the headers and hands back `out`, positioned at the end of the file.
|
||||
pub fn finish(self) -> Result<W> {
|
||||
match self.0 {
|
||||
Writer::Wav(w) => w.finish(),
|
||||
Writer::Aiff(w) => w.finish(),
|
||||
Writer::Flac(w) => w.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The WAVE_FORMAT_EXTENSIBLE speaker mask of a WAV file, if it has one.
|
||||
pub fn wav_channel_mask(bytes: &[u8]) -> Option<u32> {
|
||||
wav::read_channel_mask(bytes)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Size checks on MP4 atoms before symphonia reads them: it allocates whatever an atom, a sample
|
||||
//! table or a sample claims, and a failed allocation aborts the process.
|
||||
|
||||
use crate::pcm::{be_u32, be_u64};
|
||||
use crate::{AudioError, Result};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Level {
|
||||
Top,
|
||||
Moov,
|
||||
Trak,
|
||||
Mdia,
|
||||
Minf,
|
||||
Stbl,
|
||||
Udta,
|
||||
Meta,
|
||||
Ilst,
|
||||
Tag,
|
||||
}
|
||||
|
||||
/// Refuse the MP4 file whose top-level atoms start at `at` if an atom symphonia reads whole, a
|
||||
/// sample table or a sample claims more bytes than the file holds.
|
||||
pub(crate) fn check(b: &[u8], at: usize) -> Result<()> {
|
||||
atoms(b, at, b.len(), Level::Top).map(|_| ())
|
||||
}
|
||||
|
||||
fn malformed(what: &str) -> AudioError {
|
||||
AudioError::Malformed(format!("MP4 {what} is larger than the file"))
|
||||
}
|
||||
|
||||
/// Walk the atoms in `pos..end` that symphonia descends into, as it reads them. False where
|
||||
/// symphonia stops reading: at a header it refuses, or after an atom that runs past `end`.
|
||||
fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result<bool> {
|
||||
// A header that starts inside the container is read whole, even past its end.
|
||||
while pos < end {
|
||||
let (Some(size), Some(kind)) = (be_u32(b, pos), b.get(pos + 4..pos + 8)) else { return Ok(false) };
|
||||
let head = if size == 1 { 16 } else { 8 };
|
||||
// Size 0: no data, and the next atom starts at the container's end.
|
||||
let len = match size {
|
||||
0 => head,
|
||||
1 => be_u64(b, pos + 8).unwrap_or(0),
|
||||
n => u64::from(n),
|
||||
};
|
||||
if len < head {
|
||||
return Ok(false);
|
||||
}
|
||||
let body = pos + head as usize;
|
||||
let data_end = usize::try_from(len).ok().and_then(|n| pos.checked_add(n));
|
||||
let next = if size == 0 { Some(end) } else { data_end };
|
||||
let inner = data_end.map_or(b.len(), |n| n.min(b.len()));
|
||||
let more = match (level, kind) {
|
||||
(Level::Top, b"moov") => atoms(b, body, inner, Level::Moov)?,
|
||||
(Level::Top | Level::Udta, b"meta") => atoms(b, body + 4, inner, Level::Meta)?,
|
||||
(Level::Moov, b"trak") => atoms(b, body, inner, Level::Trak)?,
|
||||
(Level::Moov, b"udta") => atoms(b, body, inner, Level::Udta)?,
|
||||
(Level::Trak, b"mdia") => atoms(b, body, inner, Level::Mdia)?,
|
||||
(Level::Mdia, b"minf") => atoms(b, body, inner, Level::Minf)?,
|
||||
(Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl)?,
|
||||
(Level::Meta, b"ilst") => atoms(b, body, inner, Level::Ilst)?,
|
||||
(Level::Ilst, _) => atoms(b, body, inner, Level::Tag)?,
|
||||
(Level::Stbl, b"stts" | b"co64") => table(b, body + 4, 8, inner)?,
|
||||
(Level::Stbl, b"stsc") => table(b, body + 4, 12, inner)?,
|
||||
(Level::Stbl, b"stco") => table(b, body + 4, 4, inner)?,
|
||||
(Level::Stbl, b"stsz") => sample_sizes(b, body, inner)?,
|
||||
(Level::Mdia, b"hdlr") | (Level::Tag, b"data" | b"name" | b"mean") if len > (b.len() - pos) as u64 => {
|
||||
return Err(malformed(&format!("`{}` atom", String::from_utf8_lossy(kind))));
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
match next {
|
||||
Some(n) if more && n <= end => pos = n,
|
||||
_ => return Ok(false),
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// A sample table's entries, counted at `count`, must fit in the file: symphonia reserves room for
|
||||
/// all of them first. False if they run past the table's atom (`end`), where symphonia stops.
|
||||
fn table(b: &[u8], count: usize, entry: u64, end: usize) -> Result<bool> {
|
||||
let Some(n) = be_u32(b, count) else { return Ok(false) };
|
||||
let (first, bytes) = (count + 4, u64::from(n) * entry);
|
||||
if bytes > b.len().saturating_sub(first) as u64 {
|
||||
return Err(malformed("sample table"));
|
||||
}
|
||||
Ok(first as u64 + bytes <= end as u64)
|
||||
}
|
||||
|
||||
/// Every sample's size must fit in the file: symphonia reads each one into a buffer of that size.
|
||||
/// Sizes past the atom are never used, as symphonia stops before reading a packet.
|
||||
fn sample_sizes(b: &[u8], body: usize, end: usize) -> Result<bool> {
|
||||
let (Some(size), Some(count)) = (be_u32(b, body + 4), be_u32(b, body + 8)) else { return Ok(false) };
|
||||
let file = b.len() as u64;
|
||||
if u64::from(size) > file {
|
||||
return Err(malformed("sample"));
|
||||
}
|
||||
if size != 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
let fits = table(b, body + 8, 4, end)?;
|
||||
let (sizes, _) = b.get(body + 12..end).unwrap_or_default().as_chunks::<4>();
|
||||
if sizes.iter().take(count as usize).any(|s| u64::from(u32::from_be_bytes(*s)) > file) {
|
||||
return Err(malformed("sample"));
|
||||
}
|
||||
Ok(fits)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Byte-level PCM helpers shared by the WAV, AIFF and FLAC code: bounded readers, sample
|
||||
//! decoding to `f32`, and quantization (with optional TPDF dither) for encoding.
|
||||
|
||||
use crate::{AudioError, Result};
|
||||
use crate::{AudioError, BitDepth, Result};
|
||||
use std::io::Write;
|
||||
|
||||
/// Maximum number of decoded `f32` samples (all channels) we will produce: 4 GiB of output.
|
||||
pub(crate) const MAX_DECODE_SAMPLES: u64 = (4u64 << 30) / 4;
|
||||
@@ -36,6 +37,12 @@ pub(crate) fn be_u32(b: &[u8], pos: usize) -> Option<u32> {
|
||||
Some(u32::from_be_bytes(s))
|
||||
}
|
||||
|
||||
/// Big-endian `u64` at `pos`.
|
||||
pub(crate) fn be_u64(b: &[u8], pos: usize) -> Option<u64> {
|
||||
let s: [u8; 8] = b.get(pos..pos.checked_add(8)?)?.try_into().ok()?;
|
||||
Some(u64::from_be_bytes(s))
|
||||
}
|
||||
|
||||
/// Four-byte tag at `pos`.
|
||||
pub(crate) fn tag(b: &[u8], pos: usize) -> Option<[u8; 4]> {
|
||||
b.get(pos..pos.checked_add(4)?)?.try_into().ok()
|
||||
@@ -156,6 +163,49 @@ impl Quantizer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bits per sample at `depth`, and whether they are float.
|
||||
pub(crate) fn sample_format(depth: BitDepth) -> (u16, bool) {
|
||||
match depth {
|
||||
BitDepth::Int16 => (16, false),
|
||||
BitDepth::Int24 => (24, false),
|
||||
BitDepth::Int32 => (32, false),
|
||||
BitDepth::Float32 => (32, true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Interleaves `frames` frames of planar audio (missing samples are silence) as `bits`-bit
|
||||
/// samples and writes them to `out`, a few thousand frames at a time through `scratch`.
|
||||
pub(crate) fn write_interleaved(
|
||||
out: &mut impl Write,
|
||||
scratch: &mut Vec<u8>,
|
||||
block: &[Vec<f32>],
|
||||
frames: usize,
|
||||
quantizers: &mut [Quantizer],
|
||||
(bits, is_float): (u16, bool),
|
||||
big_endian: bool,
|
||||
) -> std::io::Result<()> {
|
||||
let size = usize::from(bits / 8);
|
||||
let mut start = 0;
|
||||
while start < frames {
|
||||
let end = frames.min(start + 4096);
|
||||
scratch.clear();
|
||||
for f in start..end {
|
||||
for (c, q) in quantizers.iter_mut().enumerate() {
|
||||
let s = block.get(c).and_then(|ch| ch.get(f)).copied().unwrap_or(0.0);
|
||||
match (is_float, big_endian) {
|
||||
(true, false) => scratch.extend_from_slice(&clamp_float(s).to_le_bytes()),
|
||||
(true, true) => scratch.extend_from_slice(&clamp_float(s).to_be_bytes()),
|
||||
(false, false) => scratch.extend_from_slice(q.quantize(s).to_le_bytes().get(..size).unwrap_or_default()),
|
||||
(false, true) => scratch.extend_from_slice(q.quantize(s).to_be_bytes().get(4 - size..).unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
out.write_all(scratch)?;
|
||||
start = end;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clamp a float sample to [-1, 1], mapping NaN to 0.
|
||||
pub(crate) fn clamp_float(x: f32) -> f32 {
|
||||
if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) }
|
||||
@@ -163,13 +213,18 @@ pub(crate) fn clamp_float(x: f32) -> f32 {
|
||||
|
||||
/// Check that a buffer is encodable: at least one channel and a non-zero sample rate.
|
||||
pub(crate) fn validate_buffer(buf: &crate::AudioBuffer) -> Result<()> {
|
||||
if buf.channels.is_empty() {
|
||||
validate_layout(buf.channels.len(), buf.sample_rate)
|
||||
}
|
||||
|
||||
/// Check that a stream is encodable: at least one channel and a non-zero sample rate.
|
||||
pub(crate) fn validate_layout(channels: usize, sample_rate: u32) -> Result<()> {
|
||||
if channels == 0 {
|
||||
return Err(AudioError::Encode("buffer has no channels".into()));
|
||||
}
|
||||
if buf.channels.len() > usize::from(u16::MAX) {
|
||||
if channels > usize::from(u16::MAX) {
|
||||
return Err(AudioError::Encode("too many channels".into()));
|
||||
}
|
||||
if buf.sample_rate == 0 {
|
||||
if sample_rate == 0 {
|
||||
return Err(AudioError::Encode("sample rate is 0".into()));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
+186
-110
@@ -1,48 +1,62 @@
|
||||
//! Multi-resolution min/max overview for waveform drawing.
|
||||
//! Multi-resolution overview for waveform drawing.
|
||||
//!
|
||||
//! Level 0 holds the (min, max) of each block of [`BLOCK`] samples; each following level merges
|
||||
//! [`FANOUT`] blocks of the level below, until a level has a single block. Range queries combine
|
||||
//! the coarsest whole blocks that fit inside the range, so a query costs O(log n) regardless of
|
||||
//! zoom.
|
||||
//! Level 0 sums up each block of [`BLOCK`] samples (min, max and sum of squares); each following
|
||||
//! level merges [`FANOUT`] blocks of the level below, until a level has a single block. Range
|
||||
//! queries combine the coarsest whole blocks that fit inside the range and read the partial blocks
|
||||
//! at its edges from the samples, so a query is exact and costs O(log n) regardless of zoom.
|
||||
|
||||
/// Samples per level-0 block.
|
||||
pub const BLOCK: usize = 32;
|
||||
/// Blocks of level `n` merged into one block of level `n + 1`.
|
||||
pub const FANOUT: usize = 4;
|
||||
/// Upper bound on the number of columns [`Peaks::columns`] will produce.
|
||||
/// Upper bound on the number of spans [`Peaks::spans`] will produce.
|
||||
pub const MAX_COLUMNS: usize = 1 << 20;
|
||||
|
||||
/// Multi-resolution min/max overview for waveform drawing. Level 0 = blocks of 32 samples; each next level merges 4.
|
||||
/// Multi-resolution overview of one channel for waveform drawing.
|
||||
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Peaks {
|
||||
len: usize,
|
||||
levels: Vec<Vec<(f32, f32)>>,
|
||||
levels: Vec<Vec<Block>>,
|
||||
}
|
||||
|
||||
const EMPTY: (f32, f32) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||
|
||||
fn merge(a: (f32, f32), b: (f32, f32)) -> (f32, f32) {
|
||||
(a.0.min(b.0), a.1.max(b.1))
|
||||
/// What a run of samples sums up to; NaN samples count for nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
struct Block {
|
||||
min: f32,
|
||||
max: f32,
|
||||
squares: f32,
|
||||
}
|
||||
|
||||
fn finish(acc: (f32, f32)) -> (f32, f32) {
|
||||
if acc.0 > acc.1 { (0.0, 0.0) } else { acc }
|
||||
impl Block {
|
||||
const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 };
|
||||
|
||||
fn point(s: f32) -> Block {
|
||||
Block { min: s, max: s, squares: if s.is_finite() { s * s } else { 0.0 } }
|
||||
}
|
||||
|
||||
fn of(samples: &[f32]) -> Block {
|
||||
samples.iter().fold(Block::EMPTY, |b, &s| b.merge(Block::point(s)))
|
||||
}
|
||||
|
||||
fn merge(self, o: Block) -> Block {
|
||||
Block { min: self.min.min(o.min), max: self.max.max(o.max), squares: self.squares + o.squares }
|
||||
}
|
||||
|
||||
/// (min, max), or (0, 0) when nothing was summed up.
|
||||
fn range(self) -> (f32, f32) {
|
||||
if self.min > self.max { (0.0, 0.0) } else { (self.min, self.max) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Peaks {
|
||||
/// Build the overview of `samples` (one channel). NaN samples are ignored.
|
||||
pub fn build(samples: &[f32]) -> Peaks {
|
||||
let mut levels = Vec::new();
|
||||
let level0: Vec<(f32, f32)> = samples.chunks(BLOCK).map(|c| finish(c.iter().fold(EMPTY, |acc, &s| merge(acc, (s, s))))).collect();
|
||||
if level0.is_empty() {
|
||||
return Peaks { len: 0, levels };
|
||||
if samples.is_empty() {
|
||||
return Peaks::default();
|
||||
}
|
||||
levels.push(level0);
|
||||
while let Some(last) = levels.last() {
|
||||
if last.len() <= 1 {
|
||||
break;
|
||||
}
|
||||
let next: Vec<(f32, f32)> = last.chunks(FANOUT).map(|c| c.iter().fold(EMPTY, |acc, &b| merge(acc, b))).collect();
|
||||
let mut levels: Vec<Vec<Block>> = vec![samples.chunks(BLOCK).map(Block::of).collect()];
|
||||
while let Some(last) = levels.last().filter(|l| l.len() > 1) {
|
||||
let next = last.chunks(FANOUT).map(|c| c.iter().fold(Block::EMPTY, |a, &b| a.merge(b))).collect();
|
||||
levels.push(next);
|
||||
}
|
||||
Peaks { len: samples.len(), levels }
|
||||
@@ -58,65 +72,84 @@ impl Peaks {
|
||||
self.levels.len()
|
||||
}
|
||||
|
||||
/// (min, max) over the sample range `[start, end)`, clamped to the data. Exact when both ends
|
||||
/// are multiples of 32 (or `end` is the data length); otherwise the partial blocks at the edges
|
||||
/// contribute their whole block's extremes, so the result always contains the true range.
|
||||
/// Returns `(0.0, 0.0)` for an empty range.
|
||||
pub fn minmax(&self, start: usize, end: usize) -> (f32, f32) {
|
||||
let end = end.min(self.len);
|
||||
if start >= end {
|
||||
return (0.0, 0.0);
|
||||
/// (min, max) over the sample range `[start, end)`, clamped to the data. `samples` must be the
|
||||
/// data the overview was built from. Returns `(0.0, 0.0)` for an empty range.
|
||||
pub fn minmax(&self, samples: &[f32], start: usize, end: usize) -> (f32, f32) {
|
||||
self.sum(samples, start, end).range()
|
||||
}
|
||||
|
||||
fn sum(&self, samples: &[f32], start: usize, end: usize) -> Block {
|
||||
let end = end.min(self.len).min(samples.len());
|
||||
let (first, last) = (start.div_ceil(BLOCK), end / BLOCK);
|
||||
let raw = |a: usize, b: usize| Block::of(samples.get(a..b).unwrap_or_default());
|
||||
if first >= last {
|
||||
return raw(start, end);
|
||||
}
|
||||
let mut lo = start / BLOCK;
|
||||
let mut hi = end.div_ceil(BLOCK);
|
||||
let mut acc = EMPTY;
|
||||
let mut lvl = 0usize;
|
||||
while lo < hi {
|
||||
let Some(level) = self.levels.get(lvl) else { break };
|
||||
let has_parent = lvl + 1 < self.levels.len();
|
||||
if !has_parent || hi - lo < 2 * FANOUT {
|
||||
for b in level.get(lo..hi.min(level.len())).unwrap_or_default() {
|
||||
acc = merge(acc, *b);
|
||||
}
|
||||
raw(start, first * BLOCK).merge(raw(last * BLOCK, end)).merge(self.blocks(first, last))
|
||||
}
|
||||
|
||||
/// The level-0 blocks `[lo, hi)`, summed from the coarsest blocks that fit.
|
||||
fn blocks(&self, mut lo: usize, mut hi: usize) -> Block {
|
||||
let mut acc = Block::EMPTY;
|
||||
for (lvl, level) in self.levels.iter().enumerate() {
|
||||
let mut add = |i: usize| acc = acc.merge(level.get(i).copied().unwrap_or(Block::EMPTY));
|
||||
if lvl + 1 == self.levels.len() || hi.saturating_sub(lo) < 2 * FANOUT {
|
||||
(lo..hi).for_each(add);
|
||||
break;
|
||||
}
|
||||
while !lo.is_multiple_of(FANOUT) && lo < hi {
|
||||
acc = merge(acc, level.get(lo).copied().unwrap_or(EMPTY));
|
||||
while !lo.is_multiple_of(FANOUT) {
|
||||
add(lo);
|
||||
lo += 1;
|
||||
}
|
||||
while !hi.is_multiple_of(FANOUT) && hi > lo {
|
||||
while !hi.is_multiple_of(FANOUT) {
|
||||
hi -= 1;
|
||||
acc = merge(acc, level.get(hi).copied().unwrap_or(EMPTY));
|
||||
add(hi);
|
||||
}
|
||||
lo /= FANOUT;
|
||||
hi /= FANOUT;
|
||||
lvl += 1;
|
||||
}
|
||||
finish(acc)
|
||||
acc
|
||||
}
|
||||
|
||||
/// Per-column (min, max) for drawing `cols` pixel columns spanning the sample range
|
||||
/// `[start, end)`. Columns that fall outside the data are `(0.0, 0.0)`. Each column covers at
|
||||
/// least one sample, so zoomed-in views repeat the containing sample's block.
|
||||
pub fn columns(&self, start: f64, end: f64, cols: usize) -> Vec<(f32, f32)> {
|
||||
let cols = cols.min(MAX_COLUMNS);
|
||||
let mut out = vec![(0.0, 0.0); cols];
|
||||
if cols == 0 || !start.is_finite() || !end.is_finite() || end <= start || self.len == 0 {
|
||||
return out;
|
||||
}
|
||||
let span = (end - start) / cols as f64;
|
||||
let len = self.len as f64;
|
||||
for (c, slot) in out.iter_mut().enumerate() {
|
||||
let s = start + span * c as f64;
|
||||
let e = start + span * (c + 1) as f64;
|
||||
if e <= 0.0 || s >= len {
|
||||
continue;
|
||||
}
|
||||
let si = s.max(0.0).floor();
|
||||
let ei = e.min(len).ceil().max(si + 1.0);
|
||||
*slot = self.minmax(si as usize, ei as usize);
|
||||
}
|
||||
out
|
||||
/// The spans of sample positions `[start + c * step, start + (c + 1) * step + overlap]` that
|
||||
/// reach the data, clamped to it.
|
||||
fn windows(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> impl Iterator<Item = Option<(f64, f64)>> {
|
||||
let n = self.len.min(samples.len());
|
||||
let valid = n > 0 && start.is_finite() && step.is_finite() && step > 0.0 && overlap.is_finite() && overlap >= 0.0;
|
||||
let last = n.saturating_sub(1) as f64;
|
||||
(0..cols.min(MAX_COLUMNS)).map(move |c| {
|
||||
let (a, b) = (start + step * c as f64, start + step * (c + 1) as f64 + overlap);
|
||||
(valid && b >= 0.0 && a <= last).then(|| (a.max(0.0), b.min(last)))
|
||||
})
|
||||
}
|
||||
|
||||
/// (min, max) of the line through `samples` over each of `cols` spans of sample positions
|
||||
/// `[start + c * step, start + (c + 1) * step + overlap]`; fractional positions interpolate
|
||||
/// between samples. Neighbouring spans share at least their boundary, so a waveform drawn from
|
||||
/// them stays connected at any zoom. `None` for spans outside the data.
|
||||
pub fn spans(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> Vec<Option<(f32, f32)>> {
|
||||
let at = |t: f64| {
|
||||
let i = t.floor();
|
||||
let a = samples.get(i as usize).copied().unwrap_or(0.0);
|
||||
let b = samples.get(i as usize + 1).copied().unwrap_or(a);
|
||||
Block::point(a + (b - a) * (t - i) as f32)
|
||||
};
|
||||
self.windows(samples, start, step, overlap, cols)
|
||||
.map(|w| w.map(|(a, b)| self.sum(samples, a.ceil() as usize, b.floor() as usize + 1).merge(at(a)).merge(at(b)).range()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The RMS of `samples` over each span [`Peaks::spans`] gives, including the samples just
|
||||
/// outside a span's ends.
|
||||
pub fn rms_spans(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> Vec<Option<f32>> {
|
||||
self.windows(samples, start, step, overlap, cols)
|
||||
.map(|w| {
|
||||
w.map(|(a, b)| {
|
||||
let (s, e) = (a.floor() as usize, (b.ceil() as usize).saturating_add(1).min(self.len).min(samples.len()));
|
||||
(self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f32).sqrt()
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,29 +167,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aligned_ranges_are_exact() {
|
||||
fn every_range_is_exact() {
|
||||
let x = signal(100_003);
|
||||
let p = Peaks::build(&x);
|
||||
for &(a, b) in &[(0, 32), (0, 100_003), (32, 64_000), (3200, 3232), (96, 99_968), (64, 100_003)] {
|
||||
assert_eq!(p.minmax(a, b), brute(&x, a, b), "range {a}..{b}");
|
||||
for &(a, b) in &[(0, 32), (0, 100_003), (32, 64_000), (3200, 3232), (96, 99_968), (64, 100_003), (5, 6), (31, 33), (1000, 1031)] {
|
||||
assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unaligned_ranges_contain_truth_and_match_block_cover() {
|
||||
let x = signal(50_000);
|
||||
let p = Peaks::build(&x);
|
||||
let mut seed = 12345u64;
|
||||
for _ in 0..2000 {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
let a = (seed >> 33) as usize % 50_000;
|
||||
let a = (seed >> 33) as usize % 100_003;
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
let b = a + 1 + (seed >> 33) as usize % (50_000 - a);
|
||||
let got = p.minmax(a, b);
|
||||
let truth = brute(&x, a, b);
|
||||
assert!(got.0 <= truth.0 && got.1 >= truth.1, "{a}..{b}: {got:?} vs {truth:?}");
|
||||
let cover = brute(&x, a / BLOCK * BLOCK, (b.div_ceil(BLOCK) * BLOCK).min(x.len()));
|
||||
assert_eq!(got, cover, "{a}..{b}");
|
||||
let b = a + 1 + (seed >> 33) as usize % (100_003 - a);
|
||||
assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,34 +187,87 @@ mod tests {
|
||||
fn degenerate_inputs() {
|
||||
let p = Peaks::build(&[]);
|
||||
assert_eq!(p.len_samples(), 0);
|
||||
assert_eq!(p.minmax(0, 10), (0.0, 0.0));
|
||||
assert_eq!(p.columns(0.0, 10.0, 4), vec![(0.0, 0.0); 4]);
|
||||
let p = Peaks::build(&[0.5, -0.25]);
|
||||
assert_eq!(p.minmax(0, 2), (-0.25, 0.5));
|
||||
assert_eq!(p.minmax(5, 3), (0.0, 0.0));
|
||||
assert_eq!(p.minmax(0, usize::MAX), (-0.25, 0.5));
|
||||
assert!(p.columns(f64::NAN, 1.0, 3).iter().all(|&c| c == (0.0, 0.0)));
|
||||
assert_eq!(p.columns(0.0, 1.0, 0).len(), 0);
|
||||
let p = Peaks::build(&[f32::NAN, 1.0]);
|
||||
assert_eq!(p.minmax(0, 2), (1.0, 1.0));
|
||||
assert_eq!(p.minmax(&[], 0, 10), (0.0, 0.0));
|
||||
assert_eq!(p.spans(&[], 0.0, 1.0, 0.0, 4), vec![None; 4]);
|
||||
let x = [0.5, -0.25];
|
||||
let p = Peaks::build(&x);
|
||||
assert_eq!(p.minmax(&x, 0, 2), (-0.25, 0.5));
|
||||
assert_eq!(p.minmax(&x, 5, 3), (0.0, 0.0));
|
||||
assert_eq!(p.minmax(&x, 0, usize::MAX), (-0.25, 0.5));
|
||||
assert_eq!(p.minmax(&x[..1], 0, 2), (0.5, 0.5));
|
||||
for (start, step, overlap) in [
|
||||
(f64::NAN, 1.0, 0.0),
|
||||
(0.0, 0.0, 0.0),
|
||||
(0.0, -1.0, 0.0),
|
||||
(0.0, f64::INFINITY, 0.0),
|
||||
(f64::NEG_INFINITY, 1.0, 0.0),
|
||||
(0.0, 1.0, -0.5),
|
||||
(0.0, 1.0, f64::NAN),
|
||||
] {
|
||||
assert_eq!(p.spans(&x, start, step, overlap, 3), vec![None; 3]);
|
||||
}
|
||||
assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, 0).len(), 0);
|
||||
assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, usize::MAX).len(), MAX_COLUMNS);
|
||||
let x = [f32::NAN, 1.0];
|
||||
let p = Peaks::build(&x);
|
||||
assert_eq!(p.minmax(&x, 0, 2), (1.0, 1.0));
|
||||
assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, 1), vec![Some((1.0, 1.0))]);
|
||||
assert!(p.rms_spans(&x, 0.0, 1.0, 0.0, 1)[0].is_some_and(f32::is_finite));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_cover_range() {
|
||||
fn rms_spans_match_the_samples() {
|
||||
let x = signal(20_000);
|
||||
let p = Peaks::build(&x);
|
||||
let rms = |s: usize, e: usize| (x[s..e].iter().map(|&v| f64::from(v) * f64::from(v)).sum::<f64>() / (e - s) as f64).sqrt() as f32;
|
||||
for (step, overlap) in [(1.5, 0.0), (37.0, 37.0), (1000.0, 0.0), (4096.0, 512.0)] {
|
||||
let cols = ((19_999.0 - overlap) / step) as usize;
|
||||
for (c, got) in p.rms_spans(&x, 0.0, step, overlap, cols).into_iter().enumerate() {
|
||||
let (a, b) = (c as f64 * step, ((c + 1) as f64 * step + overlap).min(19_999.0));
|
||||
let (got, want) = (got.unwrap(), rms(a.floor() as usize, (b.ceil() as usize + 1).min(20_000)));
|
||||
assert!((got - want).abs() <= want * 1e-4 + 1e-6, "span {c} at step {step}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
let sine: Vec<f32> = (0..48_000).map(|i| (i as f32 * 0.01).sin()).collect();
|
||||
let p = Peaks::build(&sine);
|
||||
let r = p.rms_spans(&sine, 0.0, 47_000.0, 0.0, 1)[0].unwrap();
|
||||
assert!((r - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-3, "{r}");
|
||||
assert_eq!(p.rms_spans(&sine, f64::NAN, 1.0, 0.0, 2), vec![None, None]);
|
||||
assert_eq!(p.rms_spans(&sine, 50_000.0, 1.0, 0.0, 1), vec![None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spans_cover_their_samples_and_touch_their_neighbours() {
|
||||
let x = signal(10_000);
|
||||
let p = Peaks::build(&x);
|
||||
let cols = p.columns(0.0, 10_000.0, 100);
|
||||
assert_eq!(cols.len(), 100);
|
||||
for (c, &(lo, hi)) in cols.iter().enumerate() {
|
||||
let truth = brute(&x, c * 100, c * 100 + 100);
|
||||
assert!(lo <= truth.0 && hi >= truth.1);
|
||||
for step in [0.3, 1.0, 7.5, 100.0, 333.3] {
|
||||
let cols = (9_999.0 / step) as usize;
|
||||
let spans = p.spans(&x, 0.0, step, 0.0, cols);
|
||||
for (c, s) in spans.iter().enumerate() {
|
||||
let (lo, hi) = s.unwrap_or_else(|| panic!("span {c} at step {step}"));
|
||||
let (a, b) = ((c as f64 * step).ceil() as usize, ((c + 1) as f64 * step).floor() as usize);
|
||||
if a <= b {
|
||||
let truth = brute(&x, a, b + 1);
|
||||
assert!(lo <= truth.0 && hi >= truth.1, "span {c} at step {step}");
|
||||
}
|
||||
if let Some(Some((nlo, nhi))) = spans.get(c + 1) {
|
||||
assert!(*nlo <= hi && *nhi >= lo, "spans {c} and {} at step {step} don't touch", c + 1);
|
||||
}
|
||||
}
|
||||
let all = spans.iter().flatten().fold((f32::INFINITY, f32::NEG_INFINITY), |a, s| (a.0.min(s.0), a.1.max(s.1)));
|
||||
assert_eq!(all, brute(&x, 0, ((cols as f64 * step).floor() as usize + 1).min(10_000)), "step {step}");
|
||||
}
|
||||
let all = cols.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |a, &c| (a.0.min(c.0), a.1.max(c.1)));
|
||||
assert_eq!(all, brute(&x, 0, 10_000));
|
||||
// Past the end of the data.
|
||||
let cols = p.columns(9_000.0, 11_000.0, 2);
|
||||
assert_eq!(cols[1], (0.0, 0.0));
|
||||
// Zoomed in further than one sample per column.
|
||||
assert_eq!(p.columns(0.0, 4.0, 8).len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spans_follow_the_line_between_samples() {
|
||||
let x = [0.0, 1.0, -1.0, 0.5];
|
||||
let p = Peaks::build(&x);
|
||||
assert_eq!(p.spans(&x, 0.0, 0.25, 0.0, 4), vec![Some((0.0, 0.25)), Some((0.25, 0.5)), Some((0.5, 0.75)), Some((0.75, 1.0))]);
|
||||
assert_eq!(p.spans(&x, 0.5, 1.0, 0.0, 1), vec![Some((0.0, 1.0))]);
|
||||
let spans = vec![None, Some((0.0, 0.0)), Some((0.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5)), Some((0.5, 0.5)), None];
|
||||
assert_eq!(p.spans(&x, -2.0, 1.0, 0.0, 7), spans);
|
||||
assert_eq!(p.spans(&x, 0.0, 1.0, 1.0, 3), vec![Some((-1.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5))]);
|
||||
assert_eq!(p.spans(&x, 0.0, 1.0, 0.5, 3), vec![Some((0.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5))]);
|
||||
}
|
||||
}
|
||||
|
||||
+205
-71
@@ -3,22 +3,28 @@
|
||||
use std::io::Cursor;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::{
|
||||
CODEC_TYPE_AAC, CODEC_TYPE_ADPCM_G722, CODEC_TYPE_ADPCM_G726, CODEC_TYPE_ADPCM_G726LE, CODEC_TYPE_ADPCM_IMA_QT, CODEC_TYPE_ADPCM_IMA_WAV,
|
||||
CODEC_TYPE_ADPCM_MS, CODEC_TYPE_ALAC, CODEC_TYPE_FLAC, CODEC_TYPE_MP1, CODEC_TYPE_MP2, CODEC_TYPE_MP3, CODEC_TYPE_NULL, CODEC_TYPE_OPUS,
|
||||
CODEC_TYPE_PCM_ALAW, CODEC_TYPE_PCM_F32BE, CODEC_TYPE_PCM_F32LE, CODEC_TYPE_PCM_F64BE, CODEC_TYPE_PCM_F64LE, CODEC_TYPE_PCM_MULAW,
|
||||
CODEC_TYPE_VORBIS, CodecParameters, CodecType, DecoderOptions,
|
||||
use symphonia::core::codecs::CodecParameters;
|
||||
use symphonia::core::codecs::audio::well_known::{
|
||||
CODEC_ID_AAC, CODEC_ID_ADPCM_G722, CODEC_ID_ADPCM_G726, CODEC_ID_ADPCM_G726LE, CODEC_ID_ADPCM_IMA_QT, CODEC_ID_ADPCM_IMA_WAV, CODEC_ID_ADPCM_MS,
|
||||
CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP1, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_OPUS, CODEC_ID_PCM_ALAW, CODEC_ID_PCM_F32BE, CODEC_ID_PCM_F32LE,
|
||||
CODEC_ID_PCM_F64BE, CODEC_ID_PCM_F64LE, CODEC_ID_PCM_MULAW, CODEC_ID_VORBIS,
|
||||
};
|
||||
use symphonia::core::codecs::audio::{AudioCodecId, AudioCodecParameters, AudioDecoderOptions, CODEC_ID_NULL_AUDIO};
|
||||
use symphonia::core::errors::Error as SymError;
|
||||
use symphonia::core::formats::probe::Hint;
|
||||
use symphonia::core::formats::{FormatOptions, FormatReader};
|
||||
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions};
|
||||
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ReadBytes};
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
|
||||
use crate::pcm::MAX_DECODE_SAMPLES;
|
||||
use crate::pcm::{MAX_DECODE_SAMPLES, be_u32};
|
||||
use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat};
|
||||
|
||||
/// The most frames a packet may declare: decoders allocate a packet's frames up front, and ALAC
|
||||
/// takes its frame length from the file (encoders use 4096).
|
||||
const MAX_PACKET_FRAMES: u64 = 1 << 20;
|
||||
|
||||
/// Turn a symphonia error into the audio layer's error, keeping "unsupported" and "too large"
|
||||
/// distinct from a malformed file.
|
||||
fn map_err(e: SymError) -> AudioError {
|
||||
match e {
|
||||
SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()),
|
||||
@@ -32,54 +38,170 @@ fn guarded<T>(f: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into())))
|
||||
}
|
||||
|
||||
fn open(bytes: &[u8], ext_hint: Option<&str>) -> Result<Box<dyn FormatReader>> {
|
||||
let mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = ext_hint {
|
||||
hint.with_extension(ext.trim_start_matches('.'));
|
||||
}
|
||||
let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() };
|
||||
let probed = symphonia::default::get_probe().format(&hint, mss, &fmt_opts, &MetadataOptions::default()).map_err(map_err)?;
|
||||
Ok(probed.format)
|
||||
/// What [`open`] found: a reader, or a stream that holds no audio frames and has no reader.
|
||||
enum Opened {
|
||||
Reader(Box<dyn FormatReader>),
|
||||
Empty(AudioInfo),
|
||||
}
|
||||
|
||||
fn format_from_codec(codec: CodecType, detected: FileFormat) -> FileFormat {
|
||||
/// Skip a leading ID3v2 tag before probing.
|
||||
///
|
||||
/// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims, so the tag is skipped
|
||||
/// with [`ReadBytes::ignore_bytes`] (which allocates nothing) instead of being handed to the
|
||||
/// metadata reader. Returns the stream position the container starts at.
|
||||
fn skip_leading_id3(mss: &mut MediaSourceStream<'_>, bytes: &[u8]) -> Result<usize> {
|
||||
loop {
|
||||
let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX);
|
||||
let head = bytes.get(at..).unwrap_or_default();
|
||||
if head.len() < 10 || head.get(..3) != Some(b"ID3") {
|
||||
return Ok(at);
|
||||
}
|
||||
let size = head.get(6..10).map(|size| size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f))).unwrap_or(0);
|
||||
mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// How far symphonia's probe scans for a format marker before giving up.
|
||||
const MAX_PROBE_DEPTH: usize = 1024 * 1024;
|
||||
|
||||
/// The container marker each detected format starts with, if its reader needs size checks first.
|
||||
fn container_markers(detected: FileFormat) -> &'static [&'static [u8; 4]] {
|
||||
match detected {
|
||||
// RF64 and BW64 are the >4 GiB RIFF forms.
|
||||
FileFormat::Wav => &[b"RIFF", b"RF64", b"BW64"],
|
||||
FileFormat::Flac => &[b"fLaC"],
|
||||
FileFormat::Caf => &[b"caff"],
|
||||
// MP4/M4A put `ftyp` after the first atom's size field.
|
||||
FileFormat::Aac | FileFormat::Alac => &[b"ftyp"],
|
||||
// Unknown files may carry junk before the container; symphonia's probe scans for markers.
|
||||
FileFormat::Other => &[b"caff", b"ftyp", b"fLaC", b"RIFF"],
|
||||
FileFormat::Aiff | FileFormat::Mp3 | FileFormat::Ogg => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe short names a container marker stands for.
|
||||
fn expected_probe_format(marker: &[u8; 4]) -> &'static [&'static str] {
|
||||
match marker {
|
||||
b"caff" => &["caf"],
|
||||
// The isomp4 reader reads the `ftyp` atom.
|
||||
b"ftyp" => &["isomp4"],
|
||||
b"fLaC" => &["flac"],
|
||||
b"RIFF" | b"RF64" | b"BW64" => &["wave"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe short names whose demuxer allocates from sizes in the file, so the matching container
|
||||
/// check has to run before the stream is decoded.
|
||||
const SIZE_CHECKED_PROBE_FORMATS: &[&str] = &["caf", "flac", "isomp4", "wave"];
|
||||
|
||||
/// Find the first container marker at or after `at`, within symphonia's probe depth. A RIFF form
|
||||
/// other than WAVE is skipped, as the probe skips it and reads a WAVE behind it.
|
||||
fn find_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) -> Option<(usize, &'a [u8; 4])> {
|
||||
let end = bytes.len().min(at.saturating_add(MAX_PROBE_DEPTH));
|
||||
for pos in at..end.saturating_sub(3) {
|
||||
let window = bytes.get(pos..pos + 4)?;
|
||||
if let Some(marker) = markers.iter().find(|marker| window == marker.as_slice()) {
|
||||
if matches!(*marker, b"RIFF" | b"RF64" | b"BW64") && bytes.get(pos + 8..pos + 12) != Some(&b"WAVE"[..]) {
|
||||
continue;
|
||||
}
|
||||
return Some((pos, *marker));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Probe for a container, checking the container it finds before its reader allocates whatever the
|
||||
/// file claims.
|
||||
///
|
||||
/// The check has to run on the container the probe actually selects, but the probe reports that
|
||||
/// container only through the reader it returns. The check therefore runs on the first marker the
|
||||
/// detected format allows, and the reader is refused if its format is not the checked one: a
|
||||
/// misleading marker must not let a later container slip through unchecked.
|
||||
fn open(bytes: &[u8], detected: FileFormat) -> Result<Opened> {
|
||||
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
|
||||
let at = skip_leading_id3(&mut mss, bytes)?;
|
||||
let mut checked: Option<&'static [&'static str]> = None;
|
||||
if let Some((found, marker)) = find_marker(bytes, at, container_markers(detected)) {
|
||||
match marker {
|
||||
b"caff" => {
|
||||
crate::caf::check(bytes, found)?;
|
||||
checked = Some(expected_probe_format(marker));
|
||||
}
|
||||
b"ftyp" => {
|
||||
crate::mp4::check(bytes, found.saturating_sub(4))?;
|
||||
checked = Some(expected_probe_format(marker));
|
||||
}
|
||||
b"fLaC" => {
|
||||
crate::flac::check_metadata(bytes, found)?;
|
||||
if let Some(info) = crate::flac::empty_stream_info(bytes, found) {
|
||||
return Ok(Opened::Empty(info));
|
||||
}
|
||||
checked = Some(expected_probe_format(marker));
|
||||
}
|
||||
b"RIFF" | b"RF64" | b"BW64" => {
|
||||
crate::wav::check_info(bytes, found)?;
|
||||
checked = Some(expected_probe_format(marker));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let reader = symphonia::default::get_probe().probe(&Hint::new(), mss, FormatOptions::default(), MetadataOptions::default()).map_err(map_err)?;
|
||||
let probed = reader.format_info().short_name;
|
||||
// Only containers whose readers allocate from sizes in the file need the check. A stream the
|
||||
// probe reads with another reader (MP3, Ogg, AIFF, ...) has no such size fields, so a marker
|
||||
// found in its audio data must not reject it.
|
||||
if let Some(names) = checked.filter(|_| SIZE_CHECKED_PROBE_FORMATS.contains(&probed)) {
|
||||
if !names.contains(&probed) {
|
||||
return Err(AudioError::Malformed(format!("the probed container ({probed}) is not the one whose size checks ran")));
|
||||
}
|
||||
} else if checked.is_none() && SIZE_CHECKED_PROBE_FORMATS.contains(&probed) {
|
||||
return Err(AudioError::Malformed(format!("the probed container ({probed}) was not size-checked")));
|
||||
}
|
||||
Ok(Opened::Reader(reader))
|
||||
}
|
||||
|
||||
/// The container `detected` stands for: the detection wins when it knows the file, otherwise the
|
||||
/// decoded codec names the family.
|
||||
fn format_from_codec(codec: AudioCodecId, detected: FileFormat) -> FileFormat {
|
||||
if detected != FileFormat::Other {
|
||||
return detected;
|
||||
}
|
||||
match codec {
|
||||
c if c == CODEC_TYPE_FLAC => FileFormat::Flac,
|
||||
c if c == CODEC_TYPE_MP3 || c == CODEC_TYPE_MP2 || c == CODEC_TYPE_MP1 => FileFormat::Mp3,
|
||||
c if c == CODEC_TYPE_AAC => FileFormat::Aac,
|
||||
c if c == CODEC_TYPE_ALAC => FileFormat::Alac,
|
||||
c if c == CODEC_TYPE_VORBIS => FileFormat::Ogg,
|
||||
c if c == CODEC_ID_FLAC => FileFormat::Flac,
|
||||
c if c == CODEC_ID_MP3 || c == CODEC_ID_MP2 || c == CODEC_ID_MP1 => FileFormat::Mp3,
|
||||
c if c == CODEC_ID_AAC => FileFormat::Aac,
|
||||
c if c == CODEC_ID_ALAC => FileFormat::Alac,
|
||||
c if c == CODEC_ID_VORBIS => FileFormat::Ogg,
|
||||
_ => FileFormat::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_format_of(p: &CodecParameters) -> SampleFormat {
|
||||
/// The stored sample format, using the codec and its declared bit depth. Lossy and unusual
|
||||
/// codecs decode to `Compressed`.
|
||||
fn sample_format_of(p: &AudioCodecParameters) -> SampleFormat {
|
||||
let c = p.codec;
|
||||
if c == CODEC_TYPE_PCM_F32LE || c == CODEC_TYPE_PCM_F32BE {
|
||||
if c == CODEC_ID_PCM_F32LE || c == CODEC_ID_PCM_F32BE {
|
||||
return SampleFormat::Float32;
|
||||
}
|
||||
if c == CODEC_TYPE_PCM_F64LE || c == CODEC_TYPE_PCM_F64BE {
|
||||
if c == CODEC_ID_PCM_F64LE || c == CODEC_ID_PCM_F64BE {
|
||||
return SampleFormat::Float64;
|
||||
}
|
||||
let lossy = [
|
||||
CODEC_TYPE_MP1,
|
||||
CODEC_TYPE_MP2,
|
||||
CODEC_TYPE_MP3,
|
||||
CODEC_TYPE_AAC,
|
||||
CODEC_TYPE_VORBIS,
|
||||
CODEC_TYPE_OPUS,
|
||||
CODEC_TYPE_ADPCM_MS,
|
||||
CODEC_TYPE_ADPCM_IMA_WAV,
|
||||
CODEC_TYPE_ADPCM_IMA_QT,
|
||||
CODEC_TYPE_ADPCM_G722,
|
||||
CODEC_TYPE_ADPCM_G726,
|
||||
CODEC_TYPE_ADPCM_G726LE,
|
||||
CODEC_TYPE_PCM_ALAW,
|
||||
CODEC_TYPE_PCM_MULAW,
|
||||
CODEC_ID_MP1,
|
||||
CODEC_ID_MP2,
|
||||
CODEC_ID_MP3,
|
||||
CODEC_ID_AAC,
|
||||
CODEC_ID_VORBIS,
|
||||
CODEC_ID_OPUS,
|
||||
CODEC_ID_ADPCM_MS,
|
||||
CODEC_ID_ADPCM_IMA_WAV,
|
||||
CODEC_ID_ADPCM_IMA_QT,
|
||||
CODEC_ID_ADPCM_G722,
|
||||
CODEC_ID_ADPCM_G726,
|
||||
CODEC_ID_ADPCM_G726LE,
|
||||
CODEC_ID_PCM_ALAW,
|
||||
CODEC_ID_PCM_MULAW,
|
||||
];
|
||||
let lossless = !lossy.contains(&c);
|
||||
match (lossless, p.bits_per_sample) {
|
||||
@@ -91,15 +213,18 @@ fn sample_format_of(p: &CodecParameters) -> SampleFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// The first track with a decodable audio codec, with the stream information the UI shows.
|
||||
fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, AudioInfo)> {
|
||||
let track = reader
|
||||
let (track, p) = reader
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.find_map(|t| match t.codec_params.as_ref().and_then(CodecParameters::audio) {
|
||||
Some(p) if p.codec != CODEC_ID_NULL_AUDIO => Some((t, p)),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
|
||||
let p = &track.codec_params;
|
||||
let sample_rate = p.sample_rate.filter(|&r| r > 0).ok_or_else(|| AudioError::Malformed("unknown sample rate".into()))?;
|
||||
let channels = p.channels.map(|c| c.count()).unwrap_or(0);
|
||||
let channels = p.channels.as_ref().map(|c| c.count()).unwrap_or(0);
|
||||
let channels = u16::try_from(channels).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
|
||||
Ok((
|
||||
track.id,
|
||||
@@ -108,17 +233,23 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi
|
||||
sample_format: sample_format_of(p),
|
||||
sample_rate,
|
||||
channels,
|
||||
frames: p.n_frames.unwrap_or(0),
|
||||
frames: track.num_frames.unwrap_or(0),
|
||||
bwf: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Decode the whole first audio track. When `count_only`, samples are counted but not stored.
|
||||
fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
|
||||
let mut reader = open(bytes, ext_hint)?;
|
||||
fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
|
||||
let mut reader = match open(bytes, detected)? {
|
||||
Opened::Reader(reader) => reader,
|
||||
Opened::Empty(info) => {
|
||||
let buf = AudioBuffer::new(info.sample_rate, usize::from(info.channels), 0);
|
||||
return Ok((info, buf));
|
||||
}
|
||||
};
|
||||
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
|
||||
if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.codec_params.n_frames) {
|
||||
if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.num_frames) {
|
||||
let total = n.saturating_mul(u64::from(info.channels.max(1)));
|
||||
if !count_only && total > MAX_DECODE_SAMPLES {
|
||||
return Err(AudioError::TooLarge(format!("{total} samples exceeds the decode limit")));
|
||||
@@ -128,17 +259,24 @@ fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: b
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.id == track_id)
|
||||
.map(|t| t.codec_params.clone())
|
||||
.and_then(|t| t.codec_params.as_ref())
|
||||
.and_then(CodecParameters::audio)
|
||||
.cloned()
|
||||
.ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
|
||||
let mut decoder = symphonia::default::get_codecs().make(¶ms, &DecoderOptions::default()).map_err(map_err)?;
|
||||
let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0));
|
||||
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) {
|
||||
return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into()));
|
||||
}
|
||||
let mut decoder = symphonia::default::get_codecs().make_audio_decoder(¶ms, &AudioDecoderOptions::default()).map_err(map_err)?;
|
||||
|
||||
let mut planar: Vec<Vec<f32>> = Vec::new();
|
||||
let mut scratch: Vec<Vec<f32>> = Vec::new();
|
||||
let mut frames: u64 = 0;
|
||||
let mut sbuf: Option<SampleBuffer<f32>> = None;
|
||||
let mut errors = 0usize;
|
||||
loop {
|
||||
let packet = match reader.next_packet() {
|
||||
Ok(p) => p,
|
||||
Ok(Some(packet)) => packet,
|
||||
Ok(None) => break,
|
||||
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
||||
Err(SymError::ResetRequired) => break,
|
||||
Err(e) => {
|
||||
@@ -149,7 +287,7 @@ fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: b
|
||||
break;
|
||||
}
|
||||
};
|
||||
if packet.track_id() != track_id {
|
||||
if packet.track_id != track_id {
|
||||
continue;
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
@@ -165,8 +303,8 @@ fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: b
|
||||
Err(SymError::IoError(_)) => break,
|
||||
Err(e) => return Err(map_err(e)),
|
||||
};
|
||||
let spec = *decoded.spec();
|
||||
let ch = spec.channels.count();
|
||||
let spec = decoded.spec();
|
||||
let ch = spec.channels().count();
|
||||
if ch == 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -183,16 +321,9 @@ fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: b
|
||||
if count_only {
|
||||
continue;
|
||||
}
|
||||
let needed = decoded.capacity() as u64;
|
||||
let sb = match &mut sbuf {
|
||||
Some(sb) if sb.capacity() >= decoded.capacity() * ch => sb,
|
||||
_ => sbuf.insert(SampleBuffer::<f32>::new(needed, spec)),
|
||||
};
|
||||
sb.copy_interleaved_ref(decoded);
|
||||
for frame in sb.samples().chunks_exact(ch) {
|
||||
for (dst, &s) in planar.iter_mut().zip(frame) {
|
||||
dst.push(if s.is_finite() { s } else { 0.0 });
|
||||
}
|
||||
decoded.copy_to_vecs_planar::<f32>(&mut scratch);
|
||||
for (dst, src) in planar.iter_mut().zip(&scratch) {
|
||||
dst.extend(src.iter().map(|&s| if s.is_finite() { s } else { 0.0 }));
|
||||
}
|
||||
}
|
||||
if info.channels == 0 {
|
||||
@@ -206,18 +337,21 @@ fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: b
|
||||
Ok((info, AudioBuffer { sample_rate, channels: planar }))
|
||||
}
|
||||
|
||||
pub(crate) fn probe(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat) -> Result<AudioInfo> {
|
||||
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
|
||||
guarded(|| {
|
||||
let reader = open(bytes, ext_hint)?;
|
||||
let reader = match open(bytes, detected)? {
|
||||
Opened::Reader(reader) => reader,
|
||||
Opened::Empty(info) => return Ok(info),
|
||||
};
|
||||
let (_, info) = info_of(reader.as_ref(), detected)?;
|
||||
if info.frames > 0 && info.channels > 0 {
|
||||
return Ok(info);
|
||||
}
|
||||
// Length not in the header (e.g. MP3 without a Xing frame): count by decoding.
|
||||
run(bytes, ext_hint, detected, true).map(|(i, _)| i)
|
||||
run(bytes, detected, true).map(|(i, _)| i)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> {
|
||||
guarded(|| run(bytes, ext_hint, detected, false))
|
||||
pub(crate) fn decode(bytes: &[u8], detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> {
|
||||
guarded(|| run(bytes, detected, false))
|
||||
}
|
||||
|
||||
+186
-30
@@ -1,8 +1,9 @@
|
||||
//! Native WAV / BWF / RF64 reader and writer (RIFF chunk layout per the Microsoft multimedia
|
||||
//! specification, `bext` per EBU Tech 3285, `ds64` per EBU Tech 3306).
|
||||
|
||||
use crate::pcm::{PcmKind, Quantizer, clamp_float, deinterleave, le_u16, le_u32, le_u64, tag, validate_buffer};
|
||||
use crate::pcm::{PcmKind, Quantizer, deinterleave, le_u16, le_u32, le_u64, sample_format, tag, validate_buffer, validate_layout, write_interleaved};
|
||||
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, BwfInfo, FileFormat, Result, SampleFormat};
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
|
||||
const WAVE_FORMAT_PCM: u16 = 1;
|
||||
const WAVE_FORMAT_IEEE_FLOAT: u16 = 3;
|
||||
@@ -83,11 +84,86 @@ fn parse_bext(c: &[u8]) -> Option<BwfInfo> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Refuse the WAVE file at `at`, which symphonia reads (A-law, ADPCM, ...), if an INFO entry
|
||||
/// claims more bytes than the file holds: symphonia allocates each entry's length. Chunks are
|
||||
/// walked where symphonia reads them: after the bytes a chunk's parser used, not its length.
|
||||
pub(crate) fn check_info(b: &[u8], at: usize) -> Result<()> {
|
||||
if tag(b, at + 8) != Some(*b"WAVE") {
|
||||
return Ok(());
|
||||
}
|
||||
let mut pos = at + 12;
|
||||
while let Some(id) = tag(b, pos) {
|
||||
let Some(len) = le_u32(b, pos + 4).map(|n| n as usize) else { break };
|
||||
let (body, pad) = (pos + 8, len & 1);
|
||||
let used = match &id {
|
||||
b"data" => break,
|
||||
b"fmt " => fmt_used(b, body, len),
|
||||
b"LIST" if len < 4 => break,
|
||||
b"LIST" => {
|
||||
let entries = if tag(b, body) == Some(*b"INFO") { info_entries(b, body + 4, len - 4)? } else { 0 };
|
||||
// The list pads itself, then the chunk is padded again.
|
||||
4 + entries.max(len - 4) + pad
|
||||
}
|
||||
_ => len,
|
||||
};
|
||||
pos = body.saturating_add(used).saturating_add(pad);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The bytes symphonia reads of a `fmt ` chunk: some formats' parsers go by their own extra size.
|
||||
fn fmt_used(b: &[u8], body: usize, len: usize) -> usize {
|
||||
match le_u16(b, body) {
|
||||
Some(2 | 6 | 7 | 0x11) => le_u16(b, body + 16).map_or(len, |extra| 18 + usize::from(extra)),
|
||||
Some(0xfffe) => 40,
|
||||
_ => len,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the INFO entries in the `len` bytes from `start`; returns the bytes symphonia reads of
|
||||
/// them, with the pad it reads after an odd entry.
|
||||
fn info_entries(b: &[u8], start: usize, len: usize) -> Result<usize> {
|
||||
let mut used = 0;
|
||||
while used + 8 <= len {
|
||||
let Some(n) = le_u32(b, start + used + 4).map(|n| n as usize) else { break };
|
||||
if n > b.len().saturating_sub(start + used + 8) {
|
||||
return Err(AudioError::Malformed("a WAV INFO entry is larger than the file".into()));
|
||||
}
|
||||
used += 8 + n;
|
||||
used += used & 1;
|
||||
}
|
||||
Ok(used)
|
||||
}
|
||||
|
||||
/// True when the bytes start like a RIFF/RF64/BW64 WAVE file.
|
||||
pub(crate) fn is_wav(b: &[u8]) -> bool {
|
||||
matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE")
|
||||
}
|
||||
|
||||
/// True when everything from `from` to the end of the file is whole RIFF chunks (four printable
|
||||
/// ASCII id bytes, a size that fits, the pad byte after an odd size; the last chunk's pad may be
|
||||
/// missing): the metadata of a finished file, not samples.
|
||||
fn only_chunks_follow(b: &[u8], from: usize) -> bool {
|
||||
let mut pos = from;
|
||||
while pos < b.len() {
|
||||
let (Some(id), Some(size)) = (tag(b, pos), le_u32(b, pos + 4)) else { return false };
|
||||
if !id.iter().all(|c| (0x20..0x7f).contains(c)) {
|
||||
return false;
|
||||
}
|
||||
let size = size as usize;
|
||||
let Some(end) = pos.checked_add(8).and_then(|body| body.checked_add(size)) else { return false };
|
||||
if end > b.len() {
|
||||
return false;
|
||||
}
|
||||
let next = end + (size & 1);
|
||||
if end == b.len() || next == b.len() {
|
||||
return true;
|
||||
}
|
||||
pos = next;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
|
||||
if !is_wav(b) {
|
||||
return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into()));
|
||||
@@ -116,7 +192,12 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
|
||||
if &id == b"data" {
|
||||
// Lenient: a truncated recording (or a streaming writer's 0 / 0xFFFFFFFF size) still
|
||||
// yields whatever audio is actually present.
|
||||
let len = if size32 == 0 && !is_rf64 { remaining } else { declared.min(remaining) };
|
||||
// A size of 0 in a file whose RIFF size is right and that has only chunks after it is an
|
||||
// empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording
|
||||
// that was never finalised, and what follows is audio.
|
||||
let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;
|
||||
let empty = size32 == 0 && !is_rf64 && finished && only_chunks_follow(b, body);
|
||||
let len = if size32 == 0 && !is_rf64 && !empty { remaining } else { declared.min(remaining) };
|
||||
if data.is_none() {
|
||||
data = Some((body, len));
|
||||
}
|
||||
@@ -255,6 +336,99 @@ fn push_chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A WAV written block by block: a placeholder header first, then the real one (the same length,
|
||||
/// RF64 above 4 GiB, or always when `force_rf64`) written over it by [`Writer::finish`].
|
||||
pub(crate) struct Writer<W> {
|
||||
out: W,
|
||||
start: u64,
|
||||
channels: usize,
|
||||
sample_rate: u32,
|
||||
format: (u16, bool),
|
||||
bwf: Option<BwfInfo>,
|
||||
mask: Option<u32>,
|
||||
force_rf64: bool,
|
||||
quantizers: Vec<Quantizer>,
|
||||
frames: u64,
|
||||
bytes: Vec<u8>,
|
||||
/// Set when a block write has failed: the sink may hold a prefix of that block while
|
||||
/// `frames` does not count it, so the writer refuses every later call.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Writer<W> {
|
||||
pub(crate) fn new(
|
||||
mut out: W,
|
||||
channels: usize,
|
||||
sample_rate: u32,
|
||||
depth: BitDepth,
|
||||
dither: bool,
|
||||
bwf: Option<&BwfInfo>,
|
||||
mask: Option<u32>,
|
||||
force_rf64: bool,
|
||||
) -> Result<Self> {
|
||||
validate_layout(channels, sample_rate)?;
|
||||
let format = sample_format(depth);
|
||||
let start = out.stream_position()?;
|
||||
out.write_all(&header(channels, sample_rate, format, bwf, mask, 0, force_rf64)?)?;
|
||||
let quantizers = Quantizer::per_channel(u32::from(format.0), dither, channels);
|
||||
Ok(Writer {
|
||||
out,
|
||||
start,
|
||||
channels,
|
||||
sample_rate,
|
||||
format,
|
||||
bwf: bwf.cloned(),
|
||||
mask,
|
||||
force_rf64,
|
||||
quantizers,
|
||||
frames: 0,
|
||||
bytes: Vec::new(),
|
||||
failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn header(&self, frames: u64) -> Result<Vec<u8>> {
|
||||
header(self.channels, self.sample_rate, self.format, self.bwf.as_ref(), self.mask, frames, self.force_rf64)
|
||||
}
|
||||
|
||||
/// The frame count after `frames` more (RF64 has room for any bounce).
|
||||
pub(crate) fn check_room(&self, frames: u64) -> Result<u64> {
|
||||
let total = self.frames.saturating_add(frames);
|
||||
self.header(total)?;
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Appends `frames` frames of planar audio (missing samples are silence).
|
||||
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let total = self.check_room(frames as u64)?;
|
||||
if let Err(e) = write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, self.format, false) {
|
||||
self.failed = true;
|
||||
return Err(AudioError::from(e));
|
||||
}
|
||||
self.frames = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut self) -> Result<W> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let data_len = u64::from(self.format.0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
|
||||
if data_len % 2 == 1 {
|
||||
self.out.write_all(&[0])?;
|
||||
}
|
||||
let head = self.header(self.frames)?;
|
||||
self.out.seek(SeekFrom::Start(self.start))?;
|
||||
self.out.write_all(&head)?;
|
||||
self.out.seek(SeekFrom::End(0))?;
|
||||
self.out.flush()?;
|
||||
Ok(self.out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a WAV file. `force_rf64` writes the RF64 layout even for small files (for tests);
|
||||
/// it is used automatically when the file would exceed 4 GiB.
|
||||
/// `mask` overrides the speaker mask derived from the channel count.
|
||||
@@ -269,36 +443,15 @@ pub(crate) fn encode(
|
||||
validate_buffer(buf)?;
|
||||
let channels = buf.channels.len();
|
||||
let frames = buf.frames();
|
||||
let (bits, is_float): (u16, bool) = match depth {
|
||||
BitDepth::Int16 => (16, false),
|
||||
BitDepth::Int24 => (24, false),
|
||||
BitDepth::Int32 => (32, false),
|
||||
BitDepth::Float32 => (32, true),
|
||||
};
|
||||
let bytes = usize::from(bits / 8);
|
||||
let head = header(channels, buf.sample_rate, (bits, is_float), bwf, mask, frames as u64, force_rf64)?;
|
||||
let data_len = (bytes * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
|
||||
let format = sample_format(depth);
|
||||
let head = header(channels, buf.sample_rate, format, bwf, mask, frames as u64, force_rf64)?;
|
||||
let data_len = (usize::from(format.0 / 8) * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1))
|
||||
.map_err(|_| AudioError::TooLarge(format!("cannot allocate {data_len} bytes for WAV output")))?;
|
||||
out.extend_from_slice(&head);
|
||||
let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels);
|
||||
for f in 0..frames {
|
||||
for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) {
|
||||
let s = ch.get(f).copied().unwrap_or(0.0);
|
||||
if is_float {
|
||||
out.extend_from_slice(&clamp_float(s).to_le_bytes());
|
||||
} else {
|
||||
let v = q.quantize(s).to_le_bytes();
|
||||
out.extend_from_slice(v.get(..bytes).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
if data_len % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
Ok(out)
|
||||
let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, bwf, mask, force_rf64)?;
|
||||
w.write(&buf.channels, frames)?;
|
||||
Ok(w.finish()?.into_inner())
|
||||
}
|
||||
|
||||
/// Everything before the sample data of a WAV holding `frames` frames.
|
||||
@@ -343,7 +496,10 @@ pub(crate) fn header(
|
||||
if is_float {
|
||||
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
|
||||
}
|
||||
let total = (4 + (8 + rf64_body_len) + meta.len() + 8) as u64 + data_len + (data_len & 1);
|
||||
let total = ((4 + (8 + rf64_body_len) + meta.len() + 8) as u64)
|
||||
.checked_add(data_len)
|
||||
.and_then(|t| t.checked_add(data_len & 1))
|
||||
.ok_or_else(|| AudioError::TooLarge("WAV size overflow".into()))?;
|
||||
let rf64 = force_rf64 || u32::try_from(total).is_err();
|
||||
|
||||
if rf64 {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
mod encode_format;
|
||||
mod malformed;
|
||||
mod roundtrip;
|
||||
mod stream;
|
||||
mod wav_empty_data;
|
||||
@@ -153,6 +153,206 @@ fn absurd_chunk_sizes() {
|
||||
assert_eq!(decode(&aiff, None).unwrap().1.frames(), 0);
|
||||
}
|
||||
|
||||
fn caf(chunks: &[(&[u8; 4], i64, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut out = b"caff\0\x01\0\0".to_vec();
|
||||
for (kind, size, body) in chunks {
|
||||
out.extend_from_slice(*kind);
|
||||
out.extend_from_slice(&size.to_be_bytes());
|
||||
out.extend_from_slice(body);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn caf_desc(format: &[u8; 4], bytes_per_packet: u32, frames_per_packet: u32, channels: u32, bits: u32) -> (&'static [u8; 4], i64, Vec<u8>) {
|
||||
let mut d = 48_000f64.to_be_bytes().to_vec();
|
||||
d.extend_from_slice(format);
|
||||
for v in [0, bytes_per_packet, frames_per_packet, channels, bits] {
|
||||
d.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
(b"desc", 32, d)
|
||||
}
|
||||
|
||||
fn packet_table(packets: i64, frames: i64, entries: &[u8]) -> Vec<u8> {
|
||||
[&packets.to_be_bytes()[..], &frames.to_be_bytes(), &[0; 8], entries].concat()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caf_sizes_beyond_the_file_are_errors_not_aborts() {
|
||||
let aac = || caf_desc(b"aac ", 0, 1024, 2, 0);
|
||||
let huge = 1i64 << 50;
|
||||
let cookie = caf(&[aac(), (b"kuki", huge, vec![0; 8])]);
|
||||
let files = [
|
||||
cookie.clone(),
|
||||
[vec![0; 100], cookie].concat(),
|
||||
caf(&[aac(), (b"data", -1, [&[0; 4][..], b"kuki", &huge.to_be_bytes()].concat())]),
|
||||
caf(&[aac(), (b"pakt", 24, packet_table(huge, 1024, &[])), (b"data", -1, vec![0; 16])]),
|
||||
caf(&[aac(), (b"pakt", 32, packet_table(1, 1024, &[0x82, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0])), (b"data", -1, vec![0; 64])]),
|
||||
caf(&[aac(), (b"pakt", 25, packet_table(1, 1024, &[0x80]))]),
|
||||
];
|
||||
for f in files {
|
||||
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
let constant = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"pakt", 25, packet_table(1, 1, &[1])), (b"data", -1, vec![0; 16])]);
|
||||
assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_formed_caf_still_reads() {
|
||||
let pcm: Vec<u8> = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect();
|
||||
let chan = [((100u32 << 16) | 1).to_be_bytes(), [0; 4], [0; 4]].concat();
|
||||
let data = [vec![0; 4], pcm].concat();
|
||||
let lpcm = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"chan", 12, chan), (b"free", 8, vec![0; 8]), (b"data", data.len() as i64, data)]);
|
||||
let (info, buf) = decode(&lpcm, None).unwrap();
|
||||
assert_eq!((info.format, buf.frames()), (FileFormat::Caf, 480));
|
||||
let table = packet_table(3, 3072, &[10, 0x81, 0x48, 5]);
|
||||
let aac = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"pakt", table.len() as i64, table), (b"data", 219, vec![0; 219])]);
|
||||
assert_eq!(probe(&aac, None).unwrap().frames, 3072);
|
||||
}
|
||||
|
||||
fn atom(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
[&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat()
|
||||
}
|
||||
|
||||
/// A mono 16-bit, 48 kHz audio sample entry of `version`, its version fields and codec atoms.
|
||||
fn sample_entry(kind: &[u8; 4], version: u8, fields: &[u8], codec: &[u8]) -> Vec<u8> {
|
||||
let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, version, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
|
||||
atom(kind, &[&base[..], &(48_000u32 << 16).to_be_bytes(), fields, codec].concat())
|
||||
}
|
||||
|
||||
/// An MP4 of `frames` 2-byte samples described by `entry`; `swap` may replace any version-0 atom.
|
||||
fn mp4(frames: u32, entry: &[u8], swap: impl Fn(&[u8; 4], Vec<u8>) -> Vec<u8>) -> Vec<u8> {
|
||||
let full = |kind: &[u8; 4], fields: &[u32]| {
|
||||
let body: Vec<u8> = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect();
|
||||
swap(kind, atom(kind, &body))
|
||||
};
|
||||
let moov = |offset: u32| {
|
||||
let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], entry].concat());
|
||||
let stbl =
|
||||
[stsd, full(b"stts", &[1, frames, 1]), full(b"stsc", &[1, 1, frames, 1]), full(b"stsz", &[2, frames]), full(b"stco", &[1, offset])];
|
||||
let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat();
|
||||
let mdia = [full(b"mdhd", &[0, 0, 48_000, frames, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)];
|
||||
let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat();
|
||||
atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, frames, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat())
|
||||
};
|
||||
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
|
||||
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
|
||||
let pcm: Vec<u8> = (0..frames as i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
|
||||
[ftyp, moov(offset), atom(b"mdat", &pcm)].concat()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mp4_sizes_beyond_the_file_are_errors_not_aborts() {
|
||||
let pcm = sample_entry(b"sowt", 0, &[], &[]);
|
||||
let (_, buf) = decode(&mp4(480, &pcm, |_, a| a), None).unwrap();
|
||||
assert_eq!(buf.frames(), 480);
|
||||
let huge = |kind: &[u8; 4], a: Vec<u8>| [&1u32.to_be_bytes()[..], kind, &(1u64 << 60).to_be_bytes(), &a[8..]].concat();
|
||||
// The first field after version and flags: stts's entry count, stsz's sample size.
|
||||
let first_field_max = |mut a: Vec<u8>| {
|
||||
a[12..16].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
a
|
||||
};
|
||||
let sizes: Vec<u8> = (0..480u32).flat_map(|i| if i == 479 { u32::MAX } else { 2 }.to_be_bytes()).collect();
|
||||
let per_sample = atom(b"stsz", &[&[0; 8][..], &480u32.to_be_bytes(), &sizes].concat());
|
||||
// A header that starts inside its container is read whole, past the container's end.
|
||||
let mdia = atom(b"mdia", &1u32.to_be_bytes());
|
||||
let straddling = [&b"hdlr"[..], &(1u64 << 60).to_be_bytes(), &[0; 24]].concat();
|
||||
let files = [
|
||||
mp4(480, &pcm, |k, a| if k == b"hdlr" { huge(k, a) } else { a }),
|
||||
mp4(480, &pcm, |k, a| if k == b"stts" { first_field_max(a) } else { a }),
|
||||
mp4(480, &pcm, |k, a| if k == b"stsz" { first_field_max(a) } else { a }),
|
||||
mp4(480, &pcm, |k, a| if k == b"stsz" { per_sample.clone() } else { a }),
|
||||
[atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), atom(b"moov", &atom(b"trak", &mdia)), straddling].concat(),
|
||||
];
|
||||
for f in files {
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atoms_running_past_their_parent_are_walked_once() {
|
||||
// Each moov holds a trak that runs to the end of the file; symphonia stops after the first.
|
||||
let unit = atom(b"moov", &[&u32::MAX.to_be_bytes()[..], b"trak"].concat());
|
||||
let f = [atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), unit.repeat(1 << 18)].concat();
|
||||
assert!(decode(&f, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packets_longer_than_any_codec_uses_are_refused() {
|
||||
let be = |v: &[u32]| v.iter().flat_map(|x| x.to_be_bytes()).collect::<Vec<u8>>();
|
||||
let v2 = |frames: u32| [&be(&[72])[..], &48_000f64.to_be_bytes(), &be(&[1, 0x7f00_0000, 16, 4, 2, frames])].concat();
|
||||
let lpcm = |frames: u32| sample_entry(b"lpcm", 2, &v2(frames), &[]);
|
||||
let config = |frames: u32| [&be(&[0, frames])[..], &[0, 16, 40, 10, 14, 1, 0, 255], &be(&[0, 0, 48_000])].concat();
|
||||
let alac = |frames: u32| sample_entry(b"alac", 0, &[], &atom(b"alac", &config(frames)));
|
||||
let (_, buf) = decode(&mp4(480, &lpcm(1), |_, a| a), None).unwrap();
|
||||
assert_eq!(buf.frames(), 480);
|
||||
for entry in [lpcm(1 << 21), alac(1 << 21)] {
|
||||
assert!(matches!(decode(&mp4(480, &entry, |_, a| a), None), Err(AudioError::Malformed(m)) if m.contains("declares packets")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_metadata_fields_beyond_their_block_are_refused() {
|
||||
let with_block = |kind: u8, body: &[u8]| {
|
||||
let f = small(FileFormat::Flac, BitDepth::Int16, 1);
|
||||
let head = ((0x80 | u32::from(kind)) << 24 | body.len() as u32).to_be_bytes();
|
||||
[&f[..4], &[f[4] & 0x7f], &f[5..42], &head, body, &f[42..]].concat()
|
||||
};
|
||||
let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat();
|
||||
assert_eq!(decode(&with_block(6, &picture(3)), None).unwrap().1.frames(), 300);
|
||||
let comment = [&0u32.to_le_bytes()[..], &1u32.to_le_bytes(), &u32::MAX.to_le_bytes()].concat();
|
||||
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment)] {
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_id3_tag_before_the_audio_is_skipped_unread() {
|
||||
let frame = [&b"TIT2"[..], &0x7fff_ffffu32.to_be_bytes(), &[0, 0, 3, b'h', b'i', 0]].concat();
|
||||
let tag = [&b"ID3\x03\0\0\0\0\0"[..], &[frame.len() as u8], &frame].concat();
|
||||
let flac = small(FileFormat::Flac, BitDepth::Int16, 1);
|
||||
assert_eq!(decode(&[tag, flac].concat(), None).unwrap().1.frames(), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_info_entries_beyond_the_file_are_refused() {
|
||||
let alaw = |len: u32| {
|
||||
let info = [&b"INFO"[..], b"INAM", &len.to_le_bytes(), b"hi"].concat();
|
||||
riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])])
|
||||
};
|
||||
assert_eq!(decode(&alaw(2), None).unwrap().1.frames(), 800);
|
||||
let mut f = alaw(0x7fff_fff0);
|
||||
f[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let list = f.windows(4).position(|w| w == b"LIST").unwrap();
|
||||
f[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes());
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wave_behind_another_riff_form_is_checked() {
|
||||
let info = [&b"INFO"[..], b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
|
||||
let mut wav = riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])]);
|
||||
wav[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let list = wav.windows(4).position(|w| w == b"LIST").unwrap();
|
||||
wav[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes());
|
||||
// The probe skips the AVI form and reads the WAVE behind it.
|
||||
let f = [&b"RIFF"[..], &4u32.to_le_bytes(), b"AVI ", &wav].concat();
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_info_entries_are_found_where_symphonia_reads_them() {
|
||||
let list = [&b"LIST"[..], &0x7fff_fffcu32.to_le_bytes(), b"INFO", b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
|
||||
let alaw = |extra: u16| (b"fmt ", [fmt(6, 1, 8_000, 8), extra.to_le_bytes().to_vec()].concat());
|
||||
// A-law's extra bytes are read past its chunk; an odd list is padded twice.
|
||||
let files =
|
||||
[riff(&[alaw(12), (b"JUNK", [&[0; 4][..], &list].concat())]), [riff(&[alaw(0), (b"LIST", b"adtl\0".to_vec())]), vec![0], list].concat()];
|
||||
for mut f in files {
|
||||
f[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_tiny_chunks_terminate() {
|
||||
let mut chunks: Vec<(&[u8; 4], Vec<u8>)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))];
|
||||
@@ -240,6 +440,7 @@ fn detect_format_by_magic_and_hint() {
|
||||
assert_eq!(detect_format(b"", Some(".WAV")), FileFormat::Wav);
|
||||
assert_eq!(detect_format(b"zz", Some("aif")), FileFormat::Aiff);
|
||||
assert_eq!(detect_format(b"zz", Some("m4a")), FileFormat::Aac);
|
||||
assert_eq!(detect_format(b"zz", Some("M4B")), FileFormat::Aac);
|
||||
assert_eq!(detect_format(b"zz", Some("flac")), FileFormat::Flac);
|
||||
assert_eq!(detect_format(b"zz", Some("xyz")), FileFormat::Other);
|
||||
assert_eq!(detect_format(b"zz", None), FileFormat::Other);
|
||||
@@ -262,6 +463,20 @@ fn garbage_in_compressed_containers_errors() {
|
||||
assert!(decode(&[], None).is_err());
|
||||
}
|
||||
|
||||
/// A marker that passes its container check must not let the probe select a different container
|
||||
/// without its own checks: the reader is refused when the probed format disagrees.
|
||||
#[test]
|
||||
fn a_probed_container_that_disagrees_with_the_checked_marker_is_refused() {
|
||||
// The junk byte hides both magics, so the hint picks the marker: the FLAC is checked, but the
|
||||
// probe reads the WAVE before it.
|
||||
let bytes = [vec![0], small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Flac, BitDepth::Int24, 2)].concat();
|
||||
match decode(&bytes, Some("flac")) {
|
||||
Err(AudioError::Malformed(message)) => assert!(message.contains("probed container"), "unexpected message: {message}"),
|
||||
Err(other) => panic!("unexpected error kind: {other:?}"),
|
||||
Ok(_) => panic!("the mismatched container must be refused"),
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
|
||||
|
||||
@@ -284,3 +284,35 @@ fn float_wav_header_is_rewritten_in_place_as_the_file_grows() {
|
||||
assert_eq!((&big[..4], big.len()), (&b"RF64"[..], len));
|
||||
assert!(wav_float_header(0, 48_000, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_frame_flac_round_trips_like_wav_and_aiff() {
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
for depth in [BitDepth::Int16, BitDepth::Int24] {
|
||||
for channels in [1, 2] {
|
||||
let opts = EncodeOptions { format, bit_depth: depth, ..EncodeOptions::default() };
|
||||
let bytes = encode(&AudioBuffer::new(48_000, channels, 0), &opts).unwrap();
|
||||
let info = probe(&bytes, None).unwrap();
|
||||
assert_eq!((info.format, info.sample_format, info.sample_rate, info.frames), (format, expected_sf(depth), 48_000, 0));
|
||||
assert_eq!(usize::from(info.channels), channels);
|
||||
let (info, buf) = decode(&bytes, None).unwrap();
|
||||
assert_eq!((info.frames, buf.frames(), buf.num_channels()), (0, 0, channels));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_claiming_frames_but_holding_none_still_errors() {
|
||||
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, ..EncodeOptions::default() };
|
||||
let mut bytes = encode(&AudioBuffer::new(48_000, 2, 0), &opts).unwrap();
|
||||
bytes[25] = 1; // STREAMINFO total samples (low byte of the 36-bit field) = 1
|
||||
assert!(decode(&bytes, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() {
|
||||
// 4 bytes per frame: the data length fits in u64, but adding the header to it does not.
|
||||
let frames = u64::MAX / 4;
|
||||
assert!(matches!(wav_float_header(1, 48_000, frames), Err(soundcraft_audio_io::AudioError::TooLarge(_))));
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, BwfInfo, EncodeOptions, FileEncoder, FileFormat, encode, encode_with_channel_mask};
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
|
||||
fn tone(channels: usize, frames: usize) -> AudioBuffer {
|
||||
let channels = (0..channels).map(|c| (0..frames).map(|i| ((i * (c + 3)) as f32 * 0.013).sin() * 1.2).collect()).collect();
|
||||
AudioBuffer { sample_rate: 44_100, channels }
|
||||
}
|
||||
|
||||
fn streamed(buf: &AudioBuffer, opts: &EncodeOptions, mask: Option<u32>) -> Vec<u8> {
|
||||
let mut out = Cursor::new(b"pre".to_vec());
|
||||
out.set_position(3);
|
||||
let mut enc = FileEncoder::new(out, opts, buf.channels.len(), buf.sample_rate, mask).unwrap();
|
||||
let mut start = 0;
|
||||
for len in [1, 7, 4095, 4097, 333].into_iter().cycle() {
|
||||
if start >= buf.frames() {
|
||||
break;
|
||||
}
|
||||
let len = len.min(buf.frames() - start);
|
||||
let block: Vec<Vec<f32>> = buf.channels.iter().map(|c| c[start..start + len].to_vec()).collect();
|
||||
enc.write(&block, len).unwrap();
|
||||
start += len;
|
||||
}
|
||||
enc.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_files_match_whole_buffer_encodes() {
|
||||
let bwf = BwfInfo { description: "take".into(), time_reference: 123_456, ..Default::default() };
|
||||
let cases = [
|
||||
(FileFormat::Wav, BitDepth::Int24, true, None, None),
|
||||
(FileFormat::Wav, BitDepth::Float32, false, None, None),
|
||||
(FileFormat::Wav, BitDepth::Int16, true, Some(bwf), Some(0x60F)),
|
||||
(FileFormat::Aiff, BitDepth::Int24, true, None, None),
|
||||
(FileFormat::Aiff, BitDepth::Float32, false, None, None),
|
||||
(FileFormat::Flac, BitDepth::Int16, true, None, None),
|
||||
(FileFormat::Flac, BitDepth::Int24, false, None, None),
|
||||
];
|
||||
for (format, bit_depth, dither, bwf, mask) in cases {
|
||||
let opts = EncodeOptions { format, bit_depth, dither, bwf };
|
||||
for channels in [1, 6] {
|
||||
let buf = tone(channels, 20_001);
|
||||
let whole = match mask {
|
||||
Some(m) => encode_with_channel_mask(&buf, &opts, m),
|
||||
None => encode(&buf, &opts),
|
||||
}
|
||||
.unwrap();
|
||||
assert_eq!(streamed(&buf, &opts, mask), [b"pre".as_slice(), &whole].concat(), "{opts:?}, {channels} channels");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Write + Seek` sink that accepts `budget` bytes in total and then fails every write, so
|
||||
/// a `write_all` on it appends a prefix of the block and only then reports the error.
|
||||
struct FailAfter {
|
||||
inner: Cursor<Vec<u8>>,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
impl Write for FailAfter {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
if self.budget == 0 {
|
||||
return Err(std::io::Error::other("sink is full"));
|
||||
}
|
||||
let n = buf.len().min(self.budget);
|
||||
self.budget -= n;
|
||||
self.inner.write(&buf[..n])
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for FailAfter {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
self.inner.seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_poisons_the_encoder() {
|
||||
let buf = tone(2, 20_001);
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
let sink = FailAfter { inner: Cursor::new(Vec::new()), budget: 256 };
|
||||
let mut enc = FileEncoder::new(sink, &opts, 2, buf.sample_rate, None).unwrap();
|
||||
// The sink accepts a prefix of the first block, then fails.
|
||||
assert!(enc.write(&buf.channels, buf.frames()).is_err(), "{format:?}");
|
||||
// Later calls must not claim success: the bytes on the sink no longer match the
|
||||
// encoder's counters (and for FLAC the failed frame's samples are already gone).
|
||||
assert!(matches!(enc.write(&buf.channels, 16), Err(AudioError::Encode(_))), "{format:?}");
|
||||
assert!(matches!(enc.finish(), Err(AudioError::Encode(_))), "{format:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_past_a_format_limit_are_refused_before_anything_is_written() {
|
||||
let buf = tone(2, 100);
|
||||
for (format, limit) in [(FileFormat::Aiff, 1u64 << 30), (FileFormat::Flac, 1u64 << 36)] {
|
||||
let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
let mut enc = FileEncoder::new(Cursor::new(Vec::new()), &opts, 2, buf.sample_rate, None).unwrap();
|
||||
enc.write(&buf.channels, 100).unwrap();
|
||||
assert!(enc.check_room(limit / 2).is_ok(), "{format:?}");
|
||||
assert!(matches!(enc.check_room(limit), Err(AudioError::TooLarge(_))), "{format:?}");
|
||||
if let Ok(frames) = usize::try_from(limit) {
|
||||
assert!(matches!(enc.write(&[], frames), Err(AudioError::TooLarge(_))), "{format:?}");
|
||||
}
|
||||
assert_eq!(enc.finish().unwrap().into_inner(), encode(&buf, &opts).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! A WAV whose data chunk is empty and followed by metadata decodes to no audio; a recording
|
||||
//! that was never finalised (RIFF and data sizes left at 0) still yields what was written (#466).
|
||||
|
||||
use soundcraft_audio_io::{decode, probe};
|
||||
|
||||
fn fmt16(channels: u16, sr: u32) -> Vec<u8> {
|
||||
let align = channels * 2;
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(&1u16.to_le_bytes());
|
||||
f.extend_from_slice(&channels.to_le_bytes());
|
||||
f.extend_from_slice(&sr.to_le_bytes());
|
||||
f.extend_from_slice(&(sr * u32::from(align)).to_le_bytes());
|
||||
f.extend_from_slice(&align.to_le_bytes());
|
||||
f.extend_from_slice(&16u16.to_le_bytes());
|
||||
f
|
||||
}
|
||||
|
||||
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut c = id.to_vec();
|
||||
c.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
c.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
c.push(0);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// A finished file: correct RIFF size, chunks as given.
|
||||
fn riff(chunks: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut body = b"WAVE".to_vec();
|
||||
for c in chunks {
|
||||
body.extend_from_slice(c);
|
||||
}
|
||||
let mut out = b"RIFF".to_vec();
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out
|
||||
}
|
||||
|
||||
fn list_info() -> Vec<u8> {
|
||||
let mut l = b"INFO".to_vec();
|
||||
l.extend_from_slice(&chunk(b"INAM", b"Silence"));
|
||||
chunk(b"LIST", &l)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_data_chunk_followed_by_metadata_has_no_frames() {
|
||||
let file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[]), list_info(), chunk(b"bext", &[0u8; 602])]);
|
||||
let info = probe(&file, Some("wav")).unwrap();
|
||||
assert_eq!(info.frames, 0, "the LIST and bext chunks are not samples");
|
||||
let (info, buf) = decode(&file, Some("wav")).unwrap();
|
||||
assert_eq!((info.frames, buf.frames()), (0, 0));
|
||||
// The same with the metadata first.
|
||||
let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), list_info(), chunk(b"data", &[])]);
|
||||
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0);
|
||||
// And with a single trailing chunk of odd size and no pad byte at the very end.
|
||||
let mut file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &[]), chunk(b"note", b"abc")]);
|
||||
file.pop();
|
||||
let riff_size = (file.len() - 8) as u32;
|
||||
file[4..8].copy_from_slice(&riff_size.to_le_bytes());
|
||||
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_that_was_never_finalised_still_gives_its_audio() {
|
||||
let samples: Vec<u8> = (0..400i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
|
||||
// RIFF size and data size both left at 0, as a recorder writes them until it finishes.
|
||||
let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]);
|
||||
file.extend_from_slice(&samples);
|
||||
file[4..8].copy_from_slice(&0u32.to_le_bytes());
|
||||
let (info, buf) = decode(&file, Some("wav")).unwrap();
|
||||
assert_eq!(info.frames, 200);
|
||||
assert_eq!(buf.frames(), 200);
|
||||
// Even if the RIFF size is the file's, audio that is not a run of chunks is still audio.
|
||||
let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]);
|
||||
file.extend_from_slice(&samples);
|
||||
let riff_size = (file.len() - 8) as u32;
|
||||
file[4..8].copy_from_slice(&riff_size.to_le_bytes());
|
||||
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_data_chunk_with_audio_is_unchanged() {
|
||||
let samples: Vec<u8> = (0..100i16).flat_map(|i| i.to_le_bytes()).collect();
|
||||
let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &samples), list_info()]);
|
||||
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 100);
|
||||
}
|
||||
@@ -11,8 +11,10 @@ pub mod mcp;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_engine::file_access::{self, AutomationRoots};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Answers control-protocol methods.
|
||||
@@ -26,16 +28,22 @@ pub trait Backend {
|
||||
/// An in-process engine (no UI).
|
||||
pub struct Headless {
|
||||
pub engine: Engine,
|
||||
roots: Option<Arc<AutomationRoots>>,
|
||||
}
|
||||
|
||||
impl Headless {
|
||||
pub fn new(engine: Engine) -> Self {
|
||||
Headless { engine }
|
||||
Headless { engine, roots: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Headless {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
/// Confine every file the session's commands read and write to `roots`
|
||||
/// (`soundcraft-cli mcp --automation-read-root … --automation-write-root …`).
|
||||
pub fn with_automation_roots(mut self, roots: Option<Arc<AutomationRoots>>) -> Self {
|
||||
self.roots = roots;
|
||||
self
|
||||
}
|
||||
|
||||
fn answer(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
match method {
|
||||
"engine.execute" | "command" => {
|
||||
let id = params.get("command").or_else(|| params.get("id")).and_then(Value::as_str).ok_or("`command` required")?;
|
||||
@@ -56,6 +64,13 @@ impl Backend for Headless {
|
||||
other => Err(format!("unknown method `{other}`")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Headless {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let roots = self.roots.clone();
|
||||
file_access::confine(roots.as_ref(), || self.answer(method, params))
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -91,37 +106,48 @@ impl Remote {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, String> {
|
||||
self.connect()?;
|
||||
/// One request/reply exchange. On failure the flag says whether the request line was already
|
||||
/// written to the socket (so the app may have executed it).
|
||||
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, (String, bool)> {
|
||||
self.connect().map_err(|e| (e, false))?;
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let line = json!({"id": id, "method": method, "params": params}).to_string();
|
||||
let (w, r) = self.conn.as_mut().ok_or("not connected")?;
|
||||
writeln!(w, "{line}").map_err(|e| e.to_string())?;
|
||||
let (w, r) = self.conn.as_mut().ok_or_else(|| ("not connected".to_string(), false))?;
|
||||
writeln!(w, "{line}").map_err(|e| (e.to_string(), false))?;
|
||||
let mut buf = String::new();
|
||||
r.read_line(&mut buf).map_err(|e| e.to_string())?;
|
||||
r.read_line(&mut buf).map_err(|e| (e.to_string(), true))?;
|
||||
if buf.is_empty() {
|
||||
return Err("connection closed".into());
|
||||
return Err(("connection closed".into(), true));
|
||||
}
|
||||
let v: Value = serde_json::from_str(&buf).map_err(|e| e.to_string())?;
|
||||
let v: Value = serde_json::from_str(&buf).map_err(|e| (e.to_string(), true))?;
|
||||
if v.get("ok").and_then(Value::as_bool).unwrap_or(false) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())
|
||||
Err((v.get("error").and_then(Value::as_str).unwrap_or("error").to_string(), true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Methods that only read state, so repeating them after a lost reply is harmless.
|
||||
fn is_read_only(method: &str) -> bool {
|
||||
matches!(method, "session.inspect" | "document.inspect" | "engine.commands" | "engine.parity" | "ui.inspect" | "ui.menu.list")
|
||||
}
|
||||
|
||||
impl Backend for Remote {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
match self.roundtrip(method, ¶ms) {
|
||||
Ok(v) => Ok(v),
|
||||
Err(e) if e.contains("closed") || e.contains("Broken pipe") || e.contains("reset") => {
|
||||
// Retry once on a fresh connection.
|
||||
Err((e, sent)) if e.contains("closed") || e.contains("Broken pipe") || e.contains("reset") => {
|
||||
self.conn = None;
|
||||
self.roundtrip(method, ¶ms)
|
||||
if sent && !is_read_only(method) {
|
||||
// The app may already have executed the request; repeating it could apply it twice.
|
||||
return Err(format!("{e}; `{method}` may or may not have been executed, check the session before repeating it"));
|
||||
}
|
||||
// Retry once on a fresh connection.
|
||||
self.roundtrip(method, ¶ms).map_err(|(e, _)| e)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
Err((e, _)) => Err(e),
|
||||
}
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
@@ -152,4 +178,43 @@ mod tests {
|
||||
let mut r = Remote::new("127.0.0.1:1");
|
||||
assert!(r.call("session.inspect", json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Fake app: accepts one connection per entry in `replies`, counts the request line it reads,
|
||||
/// then either drops the connection (`false`) or answers ok (`true`).
|
||||
fn fake_app(replies: Vec<bool>) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let seen = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counter = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
for answer in replies {
|
||||
let Ok((mut sock, _)) = listener.accept() else { return };
|
||||
let mut line = String::new();
|
||||
if BufReader::new(sock.try_clone().unwrap()).read_line(&mut line).unwrap_or(0) > 0 {
|
||||
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
if answer {
|
||||
let _ = writeln!(sock, "{}", json!({"id": 1, "ok": true, "result": {"done": true}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
(addr, seen)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_does_not_repeat_a_mutation_after_a_lost_reply() {
|
||||
let (addr, seen) = fake_app(vec![false, true]);
|
||||
let mut r = Remote::new(&addr);
|
||||
let err = r.call("engine.execute", json!({"command": "track.new"})).unwrap_err();
|
||||
assert!(err.contains("may or may not have been executed"), "{err}");
|
||||
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_retries_a_read_after_a_lost_reply() {
|
||||
let (addr, seen) = fake_app(vec![false, true]);
|
||||
let mut r = Remote::new(&addr);
|
||||
assert!(r.call("session.inspect", json!({})).is_ok());
|
||||
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::io::{BufRead, Write};
|
||||
|
||||
pub const PROTOCOL_VERSION: &str = "2025-06-18";
|
||||
const SUPPORTED: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
|
||||
const MAX_BATCH_CALLS: usize = 1000;
|
||||
|
||||
const INSTRUCTIONS: &str = "SoundCraft is a digital audio workstation (Pro Tools-class). Everything a user can do is a \
|
||||
command: call `list_commands` to discover ids and their params, then `execute` (or `batch`). Positions are samples \
|
||||
@@ -37,8 +38,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
),
|
||||
tool(
|
||||
"batch",
|
||||
"Run several commands in order; stops at the first error unless keep_going.",
|
||||
json!({"calls": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}}}, "keep_going": {"type": "boolean"}}),
|
||||
"Run up to 1000 commands in order; stops at the first error unless keep_going. Larger batches are rejected before any command runs.",
|
||||
json!({"calls": {"type": "array", "maxItems": MAX_BATCH_CALLS, "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}}}, "keep_going": {"type": "boolean"}}),
|
||||
&["calls"],
|
||||
),
|
||||
tool(
|
||||
@@ -135,9 +136,12 @@ impl<B: Backend> Server<B> {
|
||||
let Some(calls) = args.get("calls").and_then(Value::as_array) else {
|
||||
return error_result("`calls` must be an array");
|
||||
};
|
||||
if calls.len() > MAX_BATCH_CALLS {
|
||||
return error_result(&format!("`calls` must contain at most {MAX_BATCH_CALLS} commands; no commands were executed"));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut failed = false;
|
||||
for c in calls.iter().take(1000) {
|
||||
for c in calls {
|
||||
let Some(cmd) = c.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else {
|
||||
out.push(json!({"command": "", "ok": false, "error": "`command` required"}));
|
||||
failed = true;
|
||||
|
||||
@@ -31,6 +31,8 @@ fn handshake_and_tool_list() {
|
||||
assert_eq!(r["result"]["serverInfo"]["name"], "soundcraft");
|
||||
let r = rpc(&mut s, 2, "tools/list", json!({}));
|
||||
assert!(r["result"]["tools"].as_array().unwrap().len() >= 10);
|
||||
let batch = r["result"]["tools"].as_array().unwrap().iter().find(|tool| tool["name"] == "batch").unwrap();
|
||||
assert_eq!(batch["inputSchema"]["properties"]["calls"]["maxItems"], 1000);
|
||||
assert!(s.handle(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).is_none());
|
||||
let r = rpc(&mut s, 3, "nope", json!({}));
|
||||
assert_eq!(r["error"]["code"], -32601);
|
||||
@@ -171,3 +173,35 @@ fn batch_rejects_non_array_calls() {
|
||||
assert!(text.contains("`calls` must be an array"), "{text}");
|
||||
assert_eq!(session(&mut s)["tracks"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_rejects_oversize_calls_before_any_mutation() {
|
||||
for keep_going in [false, true] {
|
||||
let mut s = Server::new(Headless::new(Engine::default()));
|
||||
let before = session(&mut s);
|
||||
let mut calls = vec![json!({"command": "engine.history"}); 1001];
|
||||
calls[0] = json!({"command": "track.new", "params": {"name": "Must not be created"}});
|
||||
let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": calls, "keep_going": keep_going}}));
|
||||
assert_eq!(session(&mut s), before, "oversize batch mutated the session (keep_going={keep_going})");
|
||||
assert!(s.backend.engine.journal.is_empty());
|
||||
assert!(!s.backend.engine.can_undo());
|
||||
assert_eq!(r["result"]["isError"], true);
|
||||
let text = r["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("1000"), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_accepts_empty_and_limit_sized_calls_in_order() {
|
||||
for count in [0, 1000] {
|
||||
let mut s = Server::new(Headless::new(Engine::default()));
|
||||
let calls: Vec<_> = (0..count).map(|at| json!({"command": "session.format_time", "params": {"at": at, "format": "samples"}})).collect();
|
||||
let results = tool(&mut s, "batch", json!({"calls": calls}));
|
||||
let results = results.as_array().unwrap();
|
||||
assert_eq!(results.len(), count);
|
||||
for (at, result) in results.iter().enumerate() {
|
||||
assert_eq!(result["ok"], true);
|
||||
assert_eq!(result["result"]["text"], at.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! `Headless::with_automation_roots` through the real MCP protocol: every file a command reads
|
||||
//! stays inside the read root, every file it writes inside the write root.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_automation::{Headless, mcp::Server};
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_engine::file_access::AutomationRoots;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A marker that must never come back from a file outside the read root.
|
||||
const SECRET: &str = "TOP-SECRET-MARKER-5f3a";
|
||||
|
||||
struct Fixture {
|
||||
base: PathBuf,
|
||||
read: PathBuf,
|
||||
write: PathBuf,
|
||||
outside: PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.base);
|
||||
}
|
||||
}
|
||||
|
||||
fn s(p: impl AsRef<Path>) -> String {
|
||||
p.as_ref().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// `in/` (read root), `out/` (write root) and `outside/`, each holding the same real files:
|
||||
/// a session with audio, a WAV, a MIDI file, a track preset and a clip group file. `outside/`
|
||||
/// also holds text files holding [`SECRET`] under the names a command might open.
|
||||
fn fixture(name: &str) -> Fixture {
|
||||
let base = std::env::temp_dir().join(format!("sc-roots-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let (read, write, outside) = (base.join("in"), base.join("out"), base.join("outside"));
|
||||
for d in [&read, &write, &outside] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
// Real files, made by an unconfined engine.
|
||||
let mut e = soundcraft_engine::demo::demo_engine();
|
||||
for dir in [&read, &outside] {
|
||||
let run = |e: &mut Engine, id: &str, p: Value| e.execute(id, &p).unwrap_or_else(|err| panic!("{id}: {err}"));
|
||||
run(&mut e, "file.bounce_mix", json!({"path": s(dir.join("tone.wav")), "start": 0, "end": 4800}));
|
||||
run(&mut e, "file.export_midi", json!({"path": s(dir.join("keys.mid")), "tracks": ["Keys"]}));
|
||||
run(&mut e, "edit.select", json!({"tracks": ["Bass"]}));
|
||||
run(&mut e, "track.save_preset", json!({"path": s(dir.join("bass.json"))}));
|
||||
run(&mut e, "session.save_as", json!({"path": s(dir.join("song.scraft")), "replace": true}));
|
||||
let clips = e.session().track_by_name("Kick").unwrap().clips().iter().map(|c| c.id.0).collect::<Vec<_>>();
|
||||
run(&mut e, "file.export_clip_groups", json!({"path": s(dir.join("group.scgrp")), "clips": clips}));
|
||||
}
|
||||
for f in ["secret.wav", "secret.mid", "secret.scraft", "secret.json", "secret.scgrp", "secret.mp4"] {
|
||||
std::fs::write(outside.join(f), SECRET).unwrap();
|
||||
}
|
||||
Fixture { base, read, write, outside }
|
||||
}
|
||||
|
||||
fn server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> {
|
||||
let roots = AutomationRoots::new(read, write).unwrap();
|
||||
Server::new(Headless::new(Engine::default()).with_automation_roots(roots))
|
||||
}
|
||||
|
||||
/// One `execute` call: whether it failed and the text of the result.
|
||||
fn run(s: &mut Server<Headless>, command: &str, params: Value) -> (bool, String) {
|
||||
let r = s
|
||||
.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": {"name": "execute", "arguments": {"command": command, "params": params}}}))
|
||||
.unwrap();
|
||||
let res = &r["result"];
|
||||
(res["isError"].as_bool().unwrap(), res["content"][0]["text"].as_str().unwrap().to_string())
|
||||
}
|
||||
|
||||
fn ok(s: &mut Server<Headless>, command: &str, params: Value) -> String {
|
||||
let (failed, text) = run(s, command, params);
|
||||
assert!(!failed, "{command} failed: {text}");
|
||||
text
|
||||
}
|
||||
|
||||
fn refused(s: &mut Server<Headless>, command: &str, params: Value, needle: &str) {
|
||||
let (failed, text) = run(s, command, params.clone());
|
||||
assert!(failed, "{command} {params} must be refused, got {text}");
|
||||
assert!(text.contains(needle), "{command}: {text}");
|
||||
assert!(!text.contains(SECRET), "{command} leaked file contents: {text}");
|
||||
}
|
||||
|
||||
/// Every file under `dir`, to show nothing was written.
|
||||
fn listing(dir: &Path) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
for entry in std::fs::read_dir(dir).unwrap().flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_dir() {
|
||||
out.extend(listing(&p));
|
||||
}
|
||||
out.push(s(&p));
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
fn inspect(s: &mut Server<Headless>) -> Value {
|
||||
let r = s
|
||||
.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": {"name": "inspect_session", "arguments": {"detail": "full"}}}))
|
||||
.unwrap();
|
||||
serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Every command that reads a user-named file, with params for a file in `dir`.
|
||||
fn reads(dir: &Path, name: &dyn Fn(&str) -> String) -> Vec<(&'static str, Value)> {
|
||||
let p = |f: &str| s(dir.join(name(f)));
|
||||
vec![
|
||||
("session.open", json!({"path": p("song.scraft")})),
|
||||
("file.import_audio", json!({"path": p("tone.wav")})),
|
||||
("file.import_audio", json!({"paths": [p("tone.wav")]})),
|
||||
("file.import_midi", json!({"path": p("keys.mid")})),
|
||||
("file.import_video", json!({"path": p("movie.mp4")})),
|
||||
("file.import_clip_groups", json!({"path": p("group.scgrp")})),
|
||||
("file.import_session_data", json!({"path": p("song.scraft")})),
|
||||
("track.load_preset", json!({"path": p("bass.json"), "tracks": ["Bass"]})),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_outside_the_read_root_are_refused_and_nothing_reaches_the_result() {
|
||||
let f = fixture("reads");
|
||||
let mut srv = server(Some(&f.read), Some(&f.write));
|
||||
ok(&mut srv, "session.new", json!({"name": "Work"}));
|
||||
ok(&mut srv, "track.new", json!({"name": "Bass", "count": 1}));
|
||||
ok(&mut srv, "track.new", json!({"name": "Pads", "count": 1, "kind": "instrument"}));
|
||||
let before = inspect(&mut srv);
|
||||
// The secret files hold the marker under the names the commands read, so a read would show.
|
||||
let secret_name = |f: &str| format!("secret.{}", f.rsplit('.').next().unwrap());
|
||||
for (command, params) in reads(&f.outside, &secret_name) {
|
||||
refused(&mut srv, command, params, "outside the read root");
|
||||
}
|
||||
refused(&mut srv, "mix.sampler_load", json!({"track": "Pads", "path": s(f.outside.join("secret.wav"))}), "outside the read root");
|
||||
// The open session is exactly as it was: nothing was imported or opened.
|
||||
assert_eq!(inspect(&mut srv), before);
|
||||
// The same commands work on files inside the root.
|
||||
ok(&mut srv, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
|
||||
ok(&mut srv, "file.import_midi", json!({"path": s(f.read.join("keys.mid"))}));
|
||||
ok(&mut srv, "file.import_clip_groups", json!({"path": s(f.read.join("group.scgrp"))}));
|
||||
ok(&mut srv, "file.import_session_data", json!({"path": s(f.read.join("song.scraft")), "tracks": ["Kick"]}));
|
||||
ok(&mut srv, "track.load_preset", json!({"path": s(f.read.join("bass.json")), "tracks": ["Bass"]}));
|
||||
ok(&mut srv, "mix.sampler_load", json!({"track": "Pads", "path": s(f.read.join("tone.wav"))}));
|
||||
// A missing movie inside the root is an ordinary error, not a refusal.
|
||||
let (failed, text) = run(&mut srv, "file.import_video", json!({"path": s(f.read.join("movie.mp4"))}));
|
||||
assert!(failed && !text.contains("read root"), "{text}");
|
||||
ok(&mut srv, "session.open", json!({"path": s(f.read.join("song.scraft"))}));
|
||||
// The typed tools take the same path through the same checks.
|
||||
let r = srv
|
||||
.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": {"name": "open_session", "arguments": {"path": s(f.outside.join("secret.scraft"))}}}))
|
||||
.unwrap();
|
||||
assert_eq!(r["result"]["isError"], true);
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read root"));
|
||||
}
|
||||
|
||||
/// A clip of the demo session.
|
||||
fn kick_clip() -> u64 {
|
||||
soundcraft_engine::demo::demo_engine().session().track_by_name("Kick").unwrap().clips()[0].id.0
|
||||
}
|
||||
|
||||
/// Every command that writes a user-named file or folder, with params for `dir`.
|
||||
fn writes(dir: &Path) -> Vec<(&'static str, Value)> {
|
||||
let p = |f: &str| s(dir.join(f));
|
||||
let clip = kick_clip();
|
||||
vec![
|
||||
("session.save", json!({"path": p("a.scraft")})),
|
||||
("session.save_as", json!({"path": p("b.scraft")})),
|
||||
("session.save_copy", json!({"path": p("c.scraft")})),
|
||||
("session.save_template", json!({"path": p("d.scraft")})),
|
||||
("file.bounce_mix", json!({"path": p("e.wav"), "start": 0, "end": 4800})),
|
||||
("file.bounce_stems", json!({"dir": p("stems"), "start": 0, "end": 4800})),
|
||||
("file.export_midi", json!({"path": p("f.mid")})),
|
||||
("file.export_session_text", json!({"path": p("g.txt")})),
|
||||
("file.export_clips", json!({"dir": p("clips"), "clips": [clip]})),
|
||||
("file.export_selected_tracks_as_session", json!({"path": p("h.scraft"), "tracks": ["Kick"]})),
|
||||
("file.export_sibelius", json!({"path": p("i.musicxml")})),
|
||||
("file.send_to_sibelius", json!({"path": p("j.musicxml")})),
|
||||
("file.print_score", json!({"path": p("k.svg")})),
|
||||
("file.export_clip_groups", json!({"path": p("l.scgrp"), "clips": [clip]})),
|
||||
("track.save_preset", json!({"path": p("m.json"), "tracks": ["Kick"]})),
|
||||
]
|
||||
}
|
||||
|
||||
fn demo_server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> {
|
||||
let roots = AutomationRoots::new(read, write).unwrap();
|
||||
Server::new(Headless::new(soundcraft_engine::demo::demo_engine()).with_automation_roots(roots))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_writing_command_is_refused_outside_the_write_root_and_writes_nothing() {
|
||||
let f = fixture("writes");
|
||||
let mut srv = demo_server(Some(&f.read), Some(&f.write));
|
||||
let before = listing(&f.base);
|
||||
let outside_before = listing(&f.outside);
|
||||
for (command, params) in writes(&f.outside) {
|
||||
refused(&mut srv, command, params, "outside the write root");
|
||||
}
|
||||
// Not even the temporary file a save goes through, nor a folder.
|
||||
assert_eq!(listing(&f.base), before, "nothing may appear anywhere");
|
||||
// With no path, the score goes to the temp folder: outside the write root, so refused too.
|
||||
refused(&mut srv, "file.send_to_sibelius", json!({}), "outside the write root");
|
||||
refused(&mut srv, "file.print_score", json!({}), "outside the write root");
|
||||
assert_eq!(listing(&f.base), before);
|
||||
// Inside the write root every one of them works.
|
||||
for (command, params) in writes(&f.write) {
|
||||
let text = ok(&mut srv, command, params);
|
||||
assert!(!text.is_empty(), "{command}");
|
||||
}
|
||||
assert!(f.write.join("e.wav").is_file() && f.write.join("stems").is_dir() && f.write.join("l.scgrp").is_file());
|
||||
// A save puts the audio in `Audio Files/` beside it, inside the root as well.
|
||||
assert!(f.write.join("Audio Files").is_dir());
|
||||
assert_eq!(listing(&f.outside), outside_before, "outside is as it was");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_over_an_existing_file_outside_does_not_report_that_it_exists() {
|
||||
let f = fixture("exists");
|
||||
let mut srv = demo_server(Some(&f.read), Some(&f.write));
|
||||
// Without `replace`, an existing target is reported as existing: not for a path outside the root.
|
||||
refused(&mut srv, "session.save_as", json!({"path": s(f.outside.join("song.scraft"))}), "outside the write root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_typed_bounce_and_save_tools_are_confined() {
|
||||
let f = fixture("typed");
|
||||
let mut srv = demo_server(Some(&f.read), Some(&f.write));
|
||||
for (tool, args) in [
|
||||
("bounce_mix", json!({"path": s(f.outside.join("x.wav")), "start": 0, "end": 4800})),
|
||||
("save_session", json!({"path": s(f.outside.join("x.scraft"))})),
|
||||
] {
|
||||
let r = srv.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args}})).unwrap();
|
||||
assert_eq!(r["result"]["isError"], true, "{tool}");
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("outside the write root"), "{tool}");
|
||||
}
|
||||
assert!(!f.outside.join("x.wav").exists() && !f.outside.join("x.scraft").exists());
|
||||
let r = srv
|
||||
.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": {"name": "bounce_mix", "arguments": {"path": s(f.write.join("x.wav")), "start": 0, "end": 4800}}}))
|
||||
.unwrap();
|
||||
assert_eq!(r["result"]["isError"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_root_grants_only_its_own_access() {
|
||||
let f = fixture("one-root");
|
||||
// Read root only: reads work, nothing can be written anywhere.
|
||||
let mut srv = demo_server(Some(&f.read), None);
|
||||
ok(&mut srv, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
|
||||
let before = listing(&f.base);
|
||||
for (command, params) in writes(&f.write) {
|
||||
refused(&mut srv, command, params, "write authority is absent");
|
||||
}
|
||||
for (command, params) in writes(&f.read) {
|
||||
refused(&mut srv, command, params, "write authority is absent");
|
||||
}
|
||||
assert_eq!(listing(&f.base), before);
|
||||
// Write root only: writes work, nothing can be read anywhere.
|
||||
let mut srv = demo_server(None, Some(&f.write));
|
||||
for (command, params) in writes(&f.write) {
|
||||
ok(&mut srv, command, params);
|
||||
}
|
||||
let name = |f: &str| f.to_string();
|
||||
for (command, params) in reads(&f.read, &name) {
|
||||
refused(&mut srv, command, params, "read authority is absent");
|
||||
}
|
||||
refused(&mut srv, "file.import_audio", json!({"path": s(f.write.join("e.wav"))}), "read authority is absent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_roots_may_be_the_same_folder_and_are_compared_by_whole_names() {
|
||||
let f = fixture("same");
|
||||
let mut srv = demo_server(Some(&f.read), Some(&f.read));
|
||||
ok(&mut srv, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
|
||||
ok(&mut srv, "file.bounce_mix", json!({"path": s(f.read.join("again.wav")), "start": 0, "end": 4800}));
|
||||
// `in2` shares the prefix of `in` and is a different folder.
|
||||
let sibling = f.base.join("in2");
|
||||
std::fs::create_dir_all(&sibling).unwrap();
|
||||
std::fs::copy(f.read.join("tone.wav"), sibling.join("tone.wav")).unwrap();
|
||||
refused(&mut srv, "file.import_audio", json!({"path": s(sibling.join("tone.wav"))}), "outside the read root");
|
||||
refused(&mut srv, "file.bounce_mix", json!({"path": s(sibling.join("x.wav"))}), "outside the write root");
|
||||
// `..` out of the root is outside it.
|
||||
let up = format!("{}/../outside/tone.wav", s(&f.read));
|
||||
refused(&mut srv, "file.import_audio", json!({"path": up}), "outside the read root");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn links_out_of_the_roots_are_refused() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let f = fixture("links");
|
||||
symlink(f.outside.join("tone.wav"), f.read.join("leak.wav")).unwrap();
|
||||
symlink(&f.outside, f.write.join("away")).unwrap();
|
||||
symlink(f.outside.join("not-yet.wav"), f.write.join("dangling.wav")).unwrap();
|
||||
let mut srv = demo_server(Some(&f.read), Some(&f.write));
|
||||
refused(&mut srv, "file.import_audio", json!({"path": s(f.read.join("leak.wav"))}), "outside the read root");
|
||||
let before = listing(&f.outside);
|
||||
refused(&mut srv, "file.bounce_mix", json!({"path": s(f.write.join("away/x.wav"))}), "outside the write root");
|
||||
refused(&mut srv, "file.bounce_stems", json!({"dir": s(f.write.join("away/stems"))}), "outside the write root");
|
||||
refused(&mut srv, "session.save_as", json!({"path": s(f.write.join("away/x.scraft"))}), "outside the write root");
|
||||
refused(&mut srv, "file.bounce_mix", json!({"path": s(f.write.join("dangling.wav"))}), "a link that leads nowhere");
|
||||
assert_eq!(listing(&f.outside), before, "nothing was written through a link");
|
||||
assert!(!f.outside.join("not-yet.wav").exists());
|
||||
// A link to a folder inside the root stays inside it.
|
||||
symlink(f.read.join("."), f.base.join("in-alias")).unwrap();
|
||||
let mut via_alias = demo_server(Some(&f.base.join("in-alias")), None);
|
||||
ok(&mut via_alias, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
|
||||
}
|
||||
|
||||
/// A session file is hostile input too: media it names outside the read root is not loaded,
|
||||
/// and the refusal is listed with the other missing media.
|
||||
#[test]
|
||||
fn media_a_session_names_outside_the_read_root_is_not_loaded() {
|
||||
let f = fixture("media");
|
||||
let session = f.read.join("song.scraft");
|
||||
let text = std::fs::read_to_string(&session).unwrap();
|
||||
let outside_wav = s(f.outside.join("tone.wav"));
|
||||
let swapped = {
|
||||
let v: Value = serde_json::from_str(&text).unwrap();
|
||||
let mut rel = vec![];
|
||||
for src in v["sources"].as_array().unwrap() {
|
||||
rel.push(src["path"].as_str().unwrap().to_string());
|
||||
}
|
||||
assert!(!rel.is_empty());
|
||||
rel.iter().fold(text.clone(), |t, r| t.replace(&format!("\"{r}\""), &format!("{}", json!(outside_wav))))
|
||||
};
|
||||
std::fs::write(&session, swapped).unwrap();
|
||||
let mut srv = server(Some(&f.read), None);
|
||||
let opened = ok(&mut srv, "session.open", json!({"path": s(&session)}));
|
||||
let opened: Value = serde_json::from_str(&opened).unwrap();
|
||||
let missing = opened["missing"].as_array().unwrap();
|
||||
assert!(!missing.is_empty() && missing.iter().all(|m| m.as_str().unwrap().contains("outside the read root")), "{opened}");
|
||||
let sources = inspect(&mut srv)["sources"].clone();
|
||||
assert!(sources.as_array().unwrap().iter().all(|x| x["loaded"] == false), "{sources}");
|
||||
// The same session opened without roots does load the file it names.
|
||||
let mut free = server(None, None);
|
||||
ok(&mut free, "session.open", json!({"path": s(&session)}));
|
||||
assert!(inspect(&mut free)["sources"].as_array().unwrap().iter().all(|x| x["loaded"] == true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_roots_nothing_is_confined() {
|
||||
let f = fixture("free");
|
||||
let mut srv = server(None, None);
|
||||
ok(&mut srv, "file.import_audio", json!({"path": s(f.outside.join("tone.wav"))}));
|
||||
ok(&mut srv, "file.bounce_mix", json!({"path": s(f.outside.join("free.wav")), "start": 0, "end": 4800}));
|
||||
assert!(f.outside.join("free.wav").is_file());
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
mod agent_tasks;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod automation_roots;
|
||||
@@ -13,13 +13,17 @@ serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
# The Windows host window for embedded editors (a top-level HWND the plugin parents its GUI in).
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[[test]]
|
||||
name = "editor_main_thread"
|
||||
harness = false
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
clap-sys = "0.5"
|
||||
libloading = "0.8"
|
||||
libloading = "0.9"
|
||||
|
||||
# The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace
|
||||
# `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the
|
||||
|
||||
+439
-35
@@ -14,11 +14,13 @@ use crate::{ClapError, scan};
|
||||
use clap_sys::audio_buffer::clap_audio_buffer;
|
||||
use clap_sys::entry::clap_plugin_entry;
|
||||
use clap_sys::events::{
|
||||
CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE, clap_event_header, clap_event_midi,
|
||||
clap_event_note, clap_event_param_value, clap_input_events, clap_output_events,
|
||||
CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE, CLAP_EVENT_TRANSPORT,
|
||||
CLAP_TRANSPORT_HAS_BEATS_TIMELINE, CLAP_TRANSPORT_HAS_SECONDS_TIMELINE, CLAP_TRANSPORT_HAS_TEMPO, CLAP_TRANSPORT_HAS_TIME_SIGNATURE,
|
||||
CLAP_TRANSPORT_IS_PLAYING, CLAP_TRANSPORT_IS_RECORDING, clap_event_header, clap_event_midi, clap_event_note, clap_event_param_value,
|
||||
clap_event_transport, clap_input_events, clap_output_events,
|
||||
};
|
||||
use clap_sys::ext::audio_ports::{CLAP_AUDIO_PORT_IS_MAIN, CLAP_EXT_AUDIO_PORTS, clap_audio_port_info, clap_plugin_audio_ports};
|
||||
use clap_sys::ext::gui::{CLAP_EXT_GUI, clap_host_gui, clap_plugin_gui};
|
||||
use clap_sys::ext::gui::{CLAP_EXT_GUI, clap_host_gui, clap_plugin_gui, clap_window, clap_window_handle};
|
||||
use clap_sys::ext::latency::{CLAP_EXT_LATENCY, clap_host_latency, clap_plugin_latency};
|
||||
use clap_sys::ext::log::{CLAP_EXT_LOG, CLAP_LOG_ERROR, CLAP_LOG_FATAL, CLAP_LOG_WARNING, clap_host_log, clap_log_severity};
|
||||
use clap_sys::ext::note_ports::{CLAP_EXT_NOTE_PORTS, clap_note_port_info, clap_plugin_note_ports};
|
||||
@@ -27,16 +29,18 @@ use clap_sys::ext::params::{
|
||||
};
|
||||
use clap_sys::ext::state::{CLAP_EXT_STATE, clap_host_state, clap_plugin_state};
|
||||
use clap_sys::factory::plugin_factory::{CLAP_PLUGIN_FACTORY_ID, clap_plugin_factory};
|
||||
use clap_sys::fixedpoint::{CLAP_BEATTIME_FACTOR, CLAP_SECTIME_FACTOR};
|
||||
use clap_sys::host::clap_host;
|
||||
use clap_sys::id::clap_id;
|
||||
use clap_sys::plugin::clap_plugin;
|
||||
use clap_sys::process::{CLAP_PROCESS_ERROR, clap_process};
|
||||
use clap_sys::stream::{clap_istream, clap_ostream};
|
||||
use clap_sys::version::{CLAP_VERSION, clap_version_is_compatible};
|
||||
use soundcraft_dsp::Transport;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::{CStr, CString, c_char, c_void};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
|
||||
|
||||
/// Most plugins one factory may report (hostile-input cap).
|
||||
@@ -275,7 +279,7 @@ impl Bundle {
|
||||
inst.state_ext = inst.extension::<clap_plugin_state>(CLAP_EXT_STATE);
|
||||
let gui = inst.extension::<clap_plugin_gui>(CLAP_EXT_GUI);
|
||||
inst.gui = Arc::new(GuiLink {
|
||||
inner: Mutex::new(GuiInner { plugin, gui, alive: !gui.is_null(), created: false, visible: false }),
|
||||
inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false, window: None }),
|
||||
host: Arc::clone(&inst.state),
|
||||
});
|
||||
Ok(inst)
|
||||
@@ -310,6 +314,8 @@ pub(crate) struct HostState {
|
||||
pub gui_destroyed: AtomicBool,
|
||||
pub gui_show: AtomicBool,
|
||||
pub gui_hide: AtomicBool,
|
||||
/// A size an embedded editor asked for (`width << 32 | height`; 0 = none pending).
|
||||
pub gui_resize: AtomicU64,
|
||||
/// Parameter changes the plugin reported (output events: typically edits in its GUI),
|
||||
/// waiting for the UI. Bounded by `EDIT_CAP`; the audio thread only `try_lock`s it.
|
||||
pub edits: Mutex<Vec<(u32, f64)>>,
|
||||
@@ -328,6 +334,7 @@ impl Default for HostState {
|
||||
gui_destroyed: AtomicBool::new(false),
|
||||
gui_show: AtomicBool::new(false),
|
||||
gui_hide: AtomicBool::new(false),
|
||||
gui_resize: AtomicU64::new(0),
|
||||
edits: Mutex::new(Vec::with_capacity(EDIT_CAP)),
|
||||
}
|
||||
}
|
||||
@@ -408,8 +415,16 @@ unsafe extern "C" fn host_state_mark_dirty(host: *const clap_host) {
|
||||
|
||||
unsafe extern "C" fn host_gui_resize_hints_changed(_host: *const clap_host) {}
|
||||
|
||||
unsafe extern "C" fn host_gui_request_resize(_host: *const clap_host, _width: u32, _height: u32) -> bool {
|
||||
// Floating windows belong to the plugin, which resizes them itself.
|
||||
unsafe extern "C" fn host_gui_request_resize(host: *const clap_host, width: u32, height: u32) -> bool {
|
||||
// Floating windows belong to the plugin, which resizes them itself; an embedded editor's host
|
||||
// window is resized by the editor's `idle` on the main thread.
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
if let Some(s) = unsafe { state(host) }
|
||||
&& width > 0
|
||||
&& height > 0
|
||||
{
|
||||
s.gui_resize.store((u64::from(width) << 32) | u64::from(height), Ordering::Relaxed);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -579,6 +594,40 @@ impl RawEvent {
|
||||
}
|
||||
}
|
||||
|
||||
fn transport_event(t: &Transport) -> clap_event_transport {
|
||||
let fixed = |x: f64, factor: i64| if x.is_finite() { (x * factor as f64).round() as i64 } else { 0 };
|
||||
let mut flags =
|
||||
CLAP_TRANSPORT_HAS_TEMPO | CLAP_TRANSPORT_HAS_BEATS_TIMELINE | CLAP_TRANSPORT_HAS_SECONDS_TIMELINE | CLAP_TRANSPORT_HAS_TIME_SIGNATURE;
|
||||
if t.playing {
|
||||
flags |= CLAP_TRANSPORT_IS_PLAYING;
|
||||
}
|
||||
if t.recording {
|
||||
flags |= CLAP_TRANSPORT_IS_RECORDING;
|
||||
}
|
||||
clap_event_transport {
|
||||
header: clap_event_header {
|
||||
size: u32::try_from(size_of::<clap_event_transport>()).unwrap_or(u32::MAX),
|
||||
time: 0,
|
||||
space_id: CLAP_CORE_EVENT_SPACE_ID,
|
||||
type_: CLAP_EVENT_TRANSPORT,
|
||||
flags: 0,
|
||||
},
|
||||
flags,
|
||||
song_pos_beats: fixed(t.quarters, CLAP_BEATTIME_FACTOR),
|
||||
song_pos_seconds: fixed(t.seconds, CLAP_SECTIME_FACTOR),
|
||||
tempo: t.tempo,
|
||||
tempo_inc: 0.0,
|
||||
loop_start_beats: 0,
|
||||
loop_end_beats: 0,
|
||||
loop_start_seconds: 0,
|
||||
loop_end_seconds: 0,
|
||||
bar_start: fixed(t.bar_start, CLAP_BEATTIME_FACTOR),
|
||||
bar_number: i32::try_from(t.bar).unwrap_or(0),
|
||||
tsig_num: u16::try_from(t.numerator).unwrap_or(4),
|
||||
tsig_denom: u16::try_from(t.denominator).unwrap_or(4),
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn in_events_size(list: *const clap_input_events) -> u32 {
|
||||
if list.is_null() {
|
||||
return 0;
|
||||
@@ -696,17 +745,21 @@ fn window_api() -> Option<&'static CStr> {
|
||||
struct GuiInner {
|
||||
plugin: *const clap_plugin,
|
||||
gui: *const clap_plugin_gui,
|
||||
state: *const clap_plugin_state,
|
||||
/// The plugin instance exists (cleared by `Instance::drop` before it destroys the plugin).
|
||||
alive: bool,
|
||||
created: bool,
|
||||
visible: bool,
|
||||
/// The host window of an embedded editor (`None` for floating editors).
|
||||
window: Option<hostwin::HostWindow>,
|
||||
}
|
||||
|
||||
/// The link between an instance and its editor handles. The instance may process on the audio
|
||||
/// thread while the editor is driven from the main thread: CLAP allows `clap.gui` (main-thread)
|
||||
/// calls concurrently with audio-thread calls. Every GUI call happens under this lock, and the
|
||||
/// instance clears `alive` (destroying any GUI) under the same lock before it destroys the
|
||||
/// plugin, so a handle can never call into a destroyed plugin.
|
||||
/// The link between an instance and its editor and state handles. The instance may process on
|
||||
/// the audio thread while the editor is driven and the state saved from the main thread: CLAP
|
||||
/// allows `clap.gui` and `clap.state` (main-thread) calls concurrently with audio-thread calls.
|
||||
/// Every such call happens under this lock, and the instance clears `alive` (destroying any GUI)
|
||||
/// under the same lock before it destroys the plugin, so a handle can never call into a destroyed
|
||||
/// plugin.
|
||||
pub(crate) struct GuiLink {
|
||||
inner: Mutex<GuiInner>,
|
||||
host: Arc<HostState>,
|
||||
@@ -715,7 +768,15 @@ pub(crate) struct GuiLink {
|
||||
impl Default for GuiLink {
|
||||
fn default() -> Self {
|
||||
GuiLink {
|
||||
inner: Mutex::new(GuiInner { plugin: std::ptr::null(), gui: std::ptr::null(), alive: false, created: false, visible: false }),
|
||||
inner: Mutex::new(GuiInner {
|
||||
plugin: std::ptr::null(),
|
||||
gui: std::ptr::null(),
|
||||
state: std::ptr::null(),
|
||||
alive: false,
|
||||
created: false,
|
||||
visible: false,
|
||||
window: None,
|
||||
}),
|
||||
host: Arc::new(HostState::default()),
|
||||
}
|
||||
}
|
||||
@@ -731,7 +792,8 @@ impl GuiLink {
|
||||
}
|
||||
|
||||
pub fn available(&self) -> bool {
|
||||
self.lock().alive
|
||||
let g = self.lock();
|
||||
g.alive && !g.gui.is_null()
|
||||
}
|
||||
|
||||
pub fn host(&self) -> &HostState {
|
||||
@@ -755,6 +817,9 @@ impl GuiLink {
|
||||
// SAFETY: live plugin with a created GUI; main thread (checked).
|
||||
unsafe { show(g.plugin) };
|
||||
}
|
||||
if let Some(w) = &g.window {
|
||||
w.raise();
|
||||
}
|
||||
g.visible = true;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -762,7 +827,11 @@ impl GuiLink {
|
||||
let (Some(supported), Some(create)) = (vt.is_api_supported, vt.create) else { return Err("incomplete clap.gui extension".into()) };
|
||||
// SAFETY: live plugin; `api` is a static NUL-terminated string; main thread.
|
||||
if !unsafe { supported(g.plugin, api.as_ptr(), true) } {
|
||||
return Err("this plugin offers only an embedded editor; SoundCraft hosts floating CLAP editors".into());
|
||||
// SAFETY: as above.
|
||||
if hostwin::SUPPORTED && unsafe { supported(g.plugin, api.as_ptr(), false) } {
|
||||
return self.open_embedded(&mut g, vt, api, title);
|
||||
}
|
||||
return Err("this plugin offers only an embedded editor, which SoundCraft hosts on Windows only".into());
|
||||
}
|
||||
self.host.gui_closed.store(false, Ordering::Relaxed);
|
||||
self.host.gui_destroyed.store(false, Ordering::Relaxed);
|
||||
@@ -794,6 +863,61 @@ impl GuiLink {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Embeds the editor in a host window of ours (plugins that offer no floating editor).
|
||||
fn open_embedded(&self, g: &mut GuiInner, vt: clap_plugin_gui, api: &CStr, title: &str) -> Result<(), String> {
|
||||
let (Some(create), Some(set_parent)) = (vt.create, vt.set_parent) else { return Err("incomplete clap.gui extension".into()) };
|
||||
self.host.gui_closed.store(false, Ordering::Relaxed);
|
||||
self.host.gui_destroyed.store(false, Ordering::Relaxed);
|
||||
self.host.gui_resize.store(0, Ordering::Relaxed);
|
||||
// SAFETY: live plugin; `api` is a static NUL-terminated string; main thread; no GUI yet.
|
||||
if !unsafe { create(g.plugin, api.as_ptr(), false) } {
|
||||
return Err("the plugin could not create its editor".into());
|
||||
}
|
||||
g.created = true;
|
||||
if let Some(f) = vt.set_scale {
|
||||
// SAFETY: live plugin with a created GUI.
|
||||
unsafe { f(g.plugin, 1.0) };
|
||||
}
|
||||
let (mut w, mut h) = (640u32, 480u32);
|
||||
if let Some(f) = vt.get_size {
|
||||
let (mut a, mut b) = (0u32, 0u32);
|
||||
// SAFETY: live plugin with a created GUI; both out-pointers are valid locals.
|
||||
if unsafe { f(g.plugin, &mut a, &mut b) } && a > 0 && b > 0 {
|
||||
(w, h) = (a.min(16_384), b.min(16_384));
|
||||
}
|
||||
}
|
||||
let win = match hostwin::HostWindow::new(title, f64::from(w), f64::from(h)) {
|
||||
Ok(win) => win,
|
||||
Err(e) => {
|
||||
GuiLink::destroy(g);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let parent = clap_window { api: api.as_ptr(), specific: clap_window_handle { win32: win.parent() } };
|
||||
// SAFETY: live plugin with a created embedded GUI; `parent` names our live window and
|
||||
// outlives the call.
|
||||
if !unsafe { set_parent(g.plugin, &parent) } {
|
||||
win.close();
|
||||
GuiLink::destroy(g);
|
||||
return Err("the editor could not attach to its window".into());
|
||||
}
|
||||
g.window = Some(win);
|
||||
let shown = match vt.show {
|
||||
// SAFETY: live plugin with a created, parented GUI.
|
||||
Some(f) => unsafe { f(g.plugin) },
|
||||
None => true,
|
||||
};
|
||||
if !shown {
|
||||
GuiLink::destroy(g);
|
||||
return Err("the plugin could not show its editor".into());
|
||||
}
|
||||
if let Some(win) = &g.window {
|
||||
win.raise();
|
||||
}
|
||||
g.visible = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn destroy(g: &mut GuiInner) {
|
||||
if g.created
|
||||
&& let Some(f) = GuiLink::vt(g).and_then(|v| v.destroy)
|
||||
@@ -803,6 +927,26 @@ impl GuiLink {
|
||||
}
|
||||
g.created = false;
|
||||
g.visible = false;
|
||||
// The plugin's child window is gone; now its host window can go too.
|
||||
if let Some(w) = g.window.take() {
|
||||
w.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// `clap_plugin_state.save` into memory (`None` without the extension, on failure, or once the
|
||||
/// plugin is gone).
|
||||
pub fn save_state(&self) -> Option<Vec<u8>> {
|
||||
let g = self.lock();
|
||||
if !g.alive {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: `state` is null or the plugin's static extension vtable, valid while `alive`.
|
||||
let save = unsafe { g.state.as_ref() }?.save?;
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let stream = clap_ostream { ctx: (&mut out as *mut Vec<u8>).cast::<c_void>(), write: Some(ostream_write) };
|
||||
// SAFETY: live plugin (`alive`, under the lock); the stream and its context live on this
|
||||
// frame for the call.
|
||||
unsafe { save(g.plugin, &stream) }.then_some(out)
|
||||
}
|
||||
|
||||
/// Closes (destroys) the editor.
|
||||
@@ -815,7 +959,7 @@ impl GuiLink {
|
||||
|
||||
pub fn is_open(&self) -> bool {
|
||||
let g = self.lock();
|
||||
g.alive && g.created && g.visible
|
||||
g.alive && g.created && g.visible && g.window.as_ref().is_none_or(hostwin::HostWindow::is_visible)
|
||||
}
|
||||
|
||||
/// Main-thread housekeeping: runs a requested `on_main_thread`, follows the plugin's
|
||||
@@ -836,7 +980,21 @@ impl GuiLink {
|
||||
}
|
||||
}
|
||||
if g.created {
|
||||
if self.host.gui_closed.swap(false, Ordering::Relaxed) {
|
||||
let resize = self.host.gui_resize.swap(0, Ordering::Relaxed);
|
||||
if resize != 0
|
||||
&& let Some(win) = &g.window
|
||||
{
|
||||
let (w, h) = ((resize >> 32) as u32, resize as u32);
|
||||
hostwin::resize(win.raw(), f64::from(w), f64::from(h));
|
||||
if let Some(f) = GuiLink::vt(&g).and_then(|v| v.set_size) {
|
||||
// SAFETY: live plugin with a created embedded GUI, main thread.
|
||||
unsafe { f(g.plugin, w, h) };
|
||||
}
|
||||
}
|
||||
if g.window.as_ref().is_some_and(|w| !w.is_visible()) {
|
||||
// The user closed the embedded editor's host window: free the editor.
|
||||
GuiLink::destroy(&mut g);
|
||||
} else if self.host.gui_closed.swap(false, Ordering::Relaxed) {
|
||||
// The user closed the floating window (or the plugin lost its GUI): free it.
|
||||
self.host.gui_destroyed.store(false, Ordering::Relaxed);
|
||||
GuiLink::destroy(&mut g);
|
||||
@@ -872,6 +1030,8 @@ impl GuiLink {
|
||||
} else {
|
||||
log::warn!("clap: editor still open while its plugin is destroyed off the main thread");
|
||||
g.created = false;
|
||||
// Window calls are main-thread only: leave the host window rather than crash.
|
||||
g.window = None;
|
||||
}
|
||||
}
|
||||
g.alive = false;
|
||||
@@ -1012,19 +1172,14 @@ impl Instance {
|
||||
self.gui.available().then(|| Arc::clone(&self.gui))
|
||||
}
|
||||
|
||||
/// `clap_plugin_state.save` into memory (`None` without the extension or on failure).
|
||||
pub fn save_state(&self) -> Option<Vec<u8>> {
|
||||
// SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin.
|
||||
let save = unsafe { self.state_ext.as_ref() }?.save?;
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let stream = clap_ostream { ctx: (&mut out as *mut Vec<u8>).cast::<c_void>(), write: Some(ostream_write) };
|
||||
// SAFETY: valid instance; the stream and its context live on this frame for the call.
|
||||
unsafe { save(self.plugin, &stream) }.then_some(out)
|
||||
/// The same link, for state handles (`None` when the plugin has no `clap.state`).
|
||||
pub fn state_link(&self) -> Option<Arc<GuiLink>> {
|
||||
(!self.state_ext.is_null()).then(|| Arc::clone(&self.gui))
|
||||
}
|
||||
|
||||
/// `clap_plugin_state.load` from memory. `false` without the extension or when refused.
|
||||
pub fn load_state(&self, data: &[u8]) -> bool {
|
||||
// SAFETY: see `save_state`.
|
||||
// SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin.
|
||||
let Some(load) = (unsafe { self.state_ext.as_ref() }).and_then(|s| s.load) else { return false };
|
||||
let mut cursor = ReadCursor { data, pos: 0 };
|
||||
let stream = clap_istream { ctx: (&mut cursor as *mut ReadCursor<'_>).cast::<c_void>(), read: Some(istream_read) };
|
||||
@@ -1086,25 +1241,38 @@ impl Instance {
|
||||
ok.then(|| read_name(&buf)).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub fn audio_ports(&self, is_input: bool) -> Vec<PortInfo> {
|
||||
/// The plugin's audio ports in one direction. A layout beyond `MAX_PORTS` ports or
|
||||
/// `MAX_PORT_CHANNELS` channels per port is an error: we never hand a plugin fewer ports or
|
||||
/// channels than it advertised.
|
||||
pub fn audio_ports(&self, is_input: bool) -> Result<Vec<PortInfo>, ClapError> {
|
||||
// SAFETY: see `params`.
|
||||
let Some(p) = (unsafe { self.ports.as_ref() }) else { return Vec::new() };
|
||||
let (Some(count), Some(get)) = (p.count, p.get) else { return Vec::new() };
|
||||
let Some(p) = (unsafe { self.ports.as_ref() }) else { return Ok(Vec::new()) };
|
||||
let (Some(count), Some(get)) = (p.count, p.get) else { return Ok(Vec::new()) };
|
||||
let dir = if is_input { "input" } else { "output" };
|
||||
// SAFETY: vtable call with its instance.
|
||||
let n = unsafe { count(self.plugin, is_input) }.min(MAX_PORTS);
|
||||
let n = unsafe { count(self.plugin, is_input) };
|
||||
if n > MAX_PORTS {
|
||||
return Err(ClapError::Process(format!("{n} audio {dir} ports (at most {MAX_PORTS} supported)")));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for i in 0..n {
|
||||
// SAFETY: plain data; all-zero (null port_type pointer) is valid.
|
||||
let mut info: clap_audio_port_info = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: index below the count; `info` is writable.
|
||||
if unsafe { get(self.plugin, i, is_input, &mut info) } {
|
||||
out.push(PortInfo { channels: info.channel_count.min(MAX_PORT_CHANNELS), main: info.flags & CLAP_AUDIO_PORT_IS_MAIN != 0 });
|
||||
if info.channel_count > MAX_PORT_CHANNELS {
|
||||
return Err(ClapError::Process(format!(
|
||||
"audio {dir} port {i} has {} channels (at most {MAX_PORT_CHANNELS} supported)",
|
||||
info.channel_count
|
||||
)));
|
||||
}
|
||||
out.push(PortInfo { channels: info.channel_count, main: info.flags & CLAP_AUDIO_PORT_IS_MAIN != 0 });
|
||||
} else {
|
||||
// Keep port indices aligned: an unreadable port still needs a (silent) buffer slot.
|
||||
out.push(PortInfo { channels: 0, main: false });
|
||||
}
|
||||
}
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn note_port(&self) -> Option<NotePort> {
|
||||
@@ -1186,6 +1354,7 @@ impl Instance {
|
||||
outputs: &mut PortBuffers,
|
||||
events: &[Event],
|
||||
offset: u32,
|
||||
transport: Option<&Transport>,
|
||||
) -> Result<i32, ClapError> {
|
||||
if !self.active {
|
||||
return Err(ClapError::Process("not active".into()));
|
||||
@@ -1219,10 +1388,11 @@ impl Instance {
|
||||
self.out_params.clear();
|
||||
let out_events =
|
||||
clap_output_events { ctx: (&mut self.out_params as *mut Vec<(u32, f64)>).cast::<c_void>(), try_push: Some(out_events_try_push) };
|
||||
let transport = transport.map(transport_event);
|
||||
let p = clap_process {
|
||||
steady_time,
|
||||
frames_count: u32::try_from(frames).unwrap_or(0),
|
||||
transport: std::ptr::null(),
|
||||
transport: transport.as_ref().map_or(std::ptr::null(), std::ptr::from_ref),
|
||||
audio_inputs: inputs.bufs.as_ptr(),
|
||||
audio_outputs: outputs.bufs.as_mut_ptr(),
|
||||
audio_inputs_count: u32::try_from(inputs.bufs.len()).unwrap_or(0),
|
||||
@@ -1232,8 +1402,8 @@ impl Instance {
|
||||
};
|
||||
// SAFETY: valid, processing instance. Every buffer pointer refers to `frames` (or more)
|
||||
// valid f32s owned by `inputs`/`outputs`, which are borrowed for the whole call; the event
|
||||
// lists and their contexts live on this stack frame / in `self.raw`, untouched until the
|
||||
// call returns.
|
||||
// lists and their contexts and the transport live on this stack frame / in `self.raw`,
|
||||
// untouched until the call returns.
|
||||
let status = unsafe { process(self.plugin, &p) };
|
||||
if !self.out_params.is_empty()
|
||||
&& let Ok(mut edits) = self.state.edits.try_lock()
|
||||
@@ -1264,3 +1434,237 @@ impl Drop for Instance {
|
||||
self.created = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- host window for embedded editors ------------------------------------------------------
|
||||
|
||||
/// The Windows host window for embedded editors: a top-level window owned by the app's active
|
||||
/// window (no taskbar button) whose client area is the plugin's parent `HWND`. Closing it only
|
||||
/// hides it; the editor link notices in `idle`, destroys the plugin's GUI, then the window.
|
||||
/// Messages are dispatched by the app's own event loop on the main thread.
|
||||
#[cfg(windows)]
|
||||
mod hostwin {
|
||||
use std::ffi::c_void;
|
||||
use std::sync::OnceLock;
|
||||
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM};
|
||||
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetActiveWindow;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
AdjustWindowRectEx, CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DestroyWindow, GWL_EXSTYLE, GWL_STYLE,
|
||||
GetWindowLongPtrW, IDC_ARROW, IsWindow, IsWindowVisible, LoadCursorW, RegisterClassExW, SW_HIDE, SW_SHOW, SWP_NOACTIVATE, SWP_NOMOVE,
|
||||
SWP_NOZORDER, SetForegroundWindow, SetWindowPos, ShowWindow, WM_CLOSE, WNDCLASSEXW, WS_CAPTION, WS_CLIPCHILDREN, WS_OVERLAPPED, WS_SYSMENU,
|
||||
};
|
||||
|
||||
pub const SUPPORTED: bool = true;
|
||||
|
||||
/// Rust names the process's first thread "main"; the app runs its UI (and message loop) there.
|
||||
fn on_main_thread() -> bool {
|
||||
std::thread::current().name() == Some("main")
|
||||
}
|
||||
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().filter(|&c| c != 0).chain(Some(0)).collect()
|
||||
}
|
||||
|
||||
const STYLE: u32 = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN;
|
||||
|
||||
unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
if msg == WM_CLOSE {
|
||||
// SAFETY: `hwnd` is the window this procedure was called for.
|
||||
unsafe { ShowWindow(hwnd, SW_HIDE) };
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: default handling of a message for the window it was sent to.
|
||||
unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }
|
||||
}
|
||||
|
||||
/// Registers the window class once; the name lives for the whole process.
|
||||
fn class_name() -> Option<&'static [u16]> {
|
||||
static CLASS: OnceLock<Option<Vec<u16>>> = OnceLock::new();
|
||||
CLASS
|
||||
.get_or_init(|| {
|
||||
let name = wide("SoundCraftClapEditor");
|
||||
// SAFETY: null asks for this process's module handle.
|
||||
let instance = unsafe { GetModuleHandleW(std::ptr::null()) };
|
||||
let wc = WNDCLASSEXW {
|
||||
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
|
||||
style: CS_HREDRAW | CS_VREDRAW,
|
||||
lpfnWndProc: Some(wndproc),
|
||||
cbClsExtra: 0,
|
||||
cbWndExtra: 0,
|
||||
hInstance: instance,
|
||||
hIcon: std::ptr::null_mut(),
|
||||
// SAFETY: a system cursor (null instance + predefined id).
|
||||
hCursor: unsafe { LoadCursorW(std::ptr::null_mut(), IDC_ARROW) },
|
||||
hbrBackground: std::ptr::null_mut(),
|
||||
lpszMenuName: std::ptr::null(),
|
||||
lpszClassName: name.as_ptr(),
|
||||
hIconSm: std::ptr::null_mut(),
|
||||
};
|
||||
// SAFETY: a fully initialised class description whose strings outlive the call
|
||||
// (the name is kept for the process lifetime in this `OnceLock`).
|
||||
(unsafe { RegisterClassExW(&wc) } != 0).then_some(name)
|
||||
})
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
/// Outer window size for a client area of `w`×`h`.
|
||||
fn outer_size(style: u32, ex_style: u32, w: f64, h: f64) -> (i32, i32) {
|
||||
let mut r = RECT { left: 0, top: 0, right: w as i32, bottom: h as i32 };
|
||||
// SAFETY: `r` is a valid, writable rect.
|
||||
unsafe { AdjustWindowRectEx(&mut r, style, 0, ex_style) };
|
||||
(r.right.saturating_sub(r.left), r.bottom.saturating_sub(r.top))
|
||||
}
|
||||
|
||||
/// Owns the window handle; destroyed in `close` (main thread only).
|
||||
pub struct HostWindow {
|
||||
hwnd: HWND,
|
||||
}
|
||||
|
||||
// SAFETY: the handle is only used on the main thread (every method checks); the struct
|
||||
// merely carries it between threads' locks.
|
||||
unsafe impl Send for HostWindow {}
|
||||
|
||||
impl HostWindow {
|
||||
fn live(&self) -> Option<HWND> {
|
||||
// SAFETY: `IsWindow` accepts any value and only reports whether it names a window.
|
||||
(on_main_thread() && unsafe { IsWindow(self.hwnd) } != 0).then_some(self.hwnd)
|
||||
}
|
||||
|
||||
pub fn new(title: &str, w: f64, h: f64) -> Result<HostWindow, String> {
|
||||
if !on_main_thread() {
|
||||
return Err("plugin editors must be opened on the main thread".into());
|
||||
}
|
||||
let class = class_name().ok_or_else(|| "cannot register the editor window class".to_string())?;
|
||||
let title = wide(title);
|
||||
let (ow, oh) = outer_size(STYLE, 0, w, h);
|
||||
// SAFETY: main thread; the active window (or null) of this thread's queue.
|
||||
let owner = unsafe { GetActiveWindow() };
|
||||
// SAFETY: registered class and NUL-terminated strings that outlive the call; the
|
||||
// owner is null or a window of this thread.
|
||||
let hwnd = unsafe {
|
||||
CreateWindowExW(
|
||||
0,
|
||||
class.as_ptr(),
|
||||
title.as_ptr(),
|
||||
STYLE,
|
||||
CW_USEDEFAULT,
|
||||
CW_USEDEFAULT,
|
||||
ow,
|
||||
oh,
|
||||
owner,
|
||||
std::ptr::null_mut(),
|
||||
GetModuleHandleW(std::ptr::null()),
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if hwnd.is_null() {
|
||||
return Err("cannot create the editor window".into());
|
||||
}
|
||||
Ok(HostWindow { hwnd })
|
||||
}
|
||||
|
||||
/// The window handle (for resizes).
|
||||
pub fn raw(&self) -> *mut c_void {
|
||||
self.hwnd
|
||||
}
|
||||
|
||||
/// The `HWND` the plugin creates its child window in.
|
||||
pub fn parent(&self) -> *mut c_void {
|
||||
self.live().unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
pub fn raise(&self) {
|
||||
if let Some(h) = self.live() {
|
||||
// SAFETY: a live window of this thread.
|
||||
unsafe {
|
||||
ShowWindow(h, SW_SHOW);
|
||||
SetForegroundWindow(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_visible(&self) -> bool {
|
||||
// SAFETY: a live window of this thread.
|
||||
self.live().is_some_and(|h| unsafe { IsWindowVisible(h) } != 0)
|
||||
}
|
||||
|
||||
pub fn close(self) {
|
||||
if let Some(h) = self.live() {
|
||||
// SAFETY: our own live window; the plugin's GUI was destroyed first.
|
||||
unsafe { DestroyWindow(h) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resizes a host window's client area (`window` from [`HostWindow::raw`]); main thread only.
|
||||
pub fn resize(window: *mut c_void, w: f64, h: f64) {
|
||||
// SAFETY: `IsWindow` accepts any value.
|
||||
if !on_main_thread() || window.is_null() || unsafe { IsWindow(window) } == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: a live window of this thread; style values are read, not changed.
|
||||
let (style, ex) = unsafe { (GetWindowLongPtrW(window, GWL_STYLE) as u32, GetWindowLongPtrW(window, GWL_EXSTYLE) as u32) };
|
||||
let (ow, oh) = outer_size(style, ex, w, h);
|
||||
// SAFETY: as above; only the size changes.
|
||||
unsafe { SetWindowPos(window, std::ptr::null_mut(), 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Other platforms: embedded editors are not hosted (floating ones still are).
|
||||
#[cfg(not(windows))]
|
||||
mod hostwin {
|
||||
use std::ffi::c_void;
|
||||
|
||||
pub const SUPPORTED: bool = false;
|
||||
|
||||
pub struct HostWindow;
|
||||
|
||||
impl HostWindow {
|
||||
pub fn new(_title: &str, _w: f64, _h: f64) -> Result<HostWindow, String> {
|
||||
Err("embedded plugin editors are not hosted on this platform".into())
|
||||
}
|
||||
pub fn raw(&self) -> *mut c_void {
|
||||
std::ptr::null_mut()
|
||||
}
|
||||
pub fn parent(&self) -> *mut c_void {
|
||||
std::ptr::null_mut()
|
||||
}
|
||||
pub fn raise(&self) {}
|
||||
pub fn is_visible(&self) -> bool {
|
||||
false
|
||||
}
|
||||
pub fn close(self) {}
|
||||
}
|
||||
|
||||
pub fn resize(_window: *mut c_void, _w: f64, _h: f64) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_transport_reaches_clap_in_fixed_point() {
|
||||
let t = Transport {
|
||||
playing: true,
|
||||
tempo: 90.0,
|
||||
numerator: 3,
|
||||
denominator: 4,
|
||||
samples: 96_000,
|
||||
seconds: 2.0,
|
||||
quarters: 3.5,
|
||||
bar_start: 3.0,
|
||||
bar: 1,
|
||||
..Transport::default()
|
||||
};
|
||||
let e = transport_event(&t);
|
||||
assert_eq!((e.header.type_, e.header.size as usize), (CLAP_EVENT_TRANSPORT, size_of::<clap_event_transport>()));
|
||||
assert_eq!(e.flags & CLAP_TRANSPORT_IS_PLAYING, CLAP_TRANSPORT_IS_PLAYING);
|
||||
assert_eq!(e.flags & CLAP_TRANSPORT_IS_RECORDING, 0);
|
||||
assert_eq!((e.song_pos_beats, e.bar_start), (7 * CLAP_BEATTIME_FACTOR / 2, 3 * CLAP_BEATTIME_FACTOR));
|
||||
assert_eq!(e.song_pos_seconds, 2 * CLAP_SECTIME_FACTOR);
|
||||
assert_eq!((e.tempo, e.bar_number, e.tsig_num, e.tsig_denom), (90.0, 1, 3, 4));
|
||||
let nan = transport_event(&Transport { quarters: f64::NAN, ..t });
|
||||
assert_eq!(nan.song_pos_beats, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
//! Plugin state (`clap.state`) round-trips through [`Plugin::save_state`] /
|
||||
//! [`Plugin::load_state`] as an opaque blob. Plugin editors (`clap.gui`) open as the plugin's own
|
||||
//! floating window ([`Plugin::open_editor`], or [`Plugin::editor`] for a handle usable on the main
|
||||
//! thread while the instance processes on the audio thread); plugins that offer only embedded
|
||||
//! editors are not hosted yet, and SoundCraft's generic parameter editor still works for them.
|
||||
//! thread while the instance processes on the audio thread). Plugins that offer only an embedded
|
||||
//! editor get a host window of SoundCraft's on Windows (resized on `request_resize`, closed by
|
||||
//! its title bar); elsewhere they are not hosted yet, and SoundCraft's generic parameter editor
|
||||
//! still works for them.
|
||||
//! Parameter changes made in the plugin's GUI arrive as output events while the plugin processes
|
||||
//! and are reported by the editor's `idle`.
|
||||
|
||||
@@ -86,8 +88,11 @@ pub enum ClapError {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod api {
|
||||
use super::*;
|
||||
use super::{ClapDescriptor, ClapError, Path, PathBuf, Plugin, PluginInfo, registry};
|
||||
|
||||
pub fn cached() -> Option<Vec<ClapDescriptor>> {
|
||||
registry::cached()
|
||||
}
|
||||
pub fn scan() -> Vec<ClapDescriptor> {
|
||||
registry::scan()
|
||||
}
|
||||
@@ -113,8 +118,11 @@ mod api {
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api {
|
||||
use super::*;
|
||||
use super::{ClapDescriptor, ClapError, Path, PathBuf, Plugin, PluginInfo};
|
||||
|
||||
pub fn cached() -> Option<Vec<ClapDescriptor>> {
|
||||
Some(Vec::new())
|
||||
}
|
||||
pub fn scan() -> Vec<ClapDescriptor> {
|
||||
Vec::new()
|
||||
}
|
||||
@@ -161,6 +169,11 @@ pub fn scan_paths(dirs: &[PathBuf]) -> Vec<ClapDescriptor> {
|
||||
api::scan_paths(dirs)
|
||||
}
|
||||
|
||||
/// The plugins of the last scan, without scanning: `None` until something has scanned.
|
||||
pub fn cached() -> Option<Vec<ClapDescriptor>> {
|
||||
api::cached()
|
||||
}
|
||||
|
||||
/// The plugins in one `.clap` file or bundle.
|
||||
pub fn load_bundle(path: &Path) -> Result<Vec<ClapDescriptor>, ClapError> {
|
||||
api::load_bundle(path)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use crate::ffi::{EVENT_CAP, Event, GuiLink, Instance, NotePort, PortBuffers};
|
||||
use clap_sys::ext::note_ports::{CLAP_NOTE_DIALECT_CLAP, CLAP_NOTE_DIALECT_MIDI};
|
||||
use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo};
|
||||
use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
@@ -33,11 +33,14 @@ pub struct ClapPlugin {
|
||||
in_main: Option<usize>,
|
||||
out_main: Option<usize>,
|
||||
notes: NoteMode,
|
||||
/// The note port takes raw MIDI (controllers, pitch bend, program changes).
|
||||
midi_in: bool,
|
||||
held: [bool; 128],
|
||||
channels: usize,
|
||||
max_block: usize,
|
||||
latency: usize,
|
||||
steady: i64,
|
||||
transport: Option<Transport>,
|
||||
}
|
||||
|
||||
impl ClapPlugin {
|
||||
@@ -55,11 +58,13 @@ impl ClapPlugin {
|
||||
in_main: None,
|
||||
out_main: None,
|
||||
notes: NoteMode::None,
|
||||
midi_in: false,
|
||||
held: [false; 128],
|
||||
channels: 2,
|
||||
max_block: 0,
|
||||
latency: 0,
|
||||
steady: 0,
|
||||
transport: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,13 +113,28 @@ impl Plugin for ClapPlugin {
|
||||
self.channels = channels.clamp(1, MAX_CHANNELS);
|
||||
self.inst.deactivate();
|
||||
self.inst.service_callback();
|
||||
let ins = self.inst.audio_ports(true);
|
||||
let outs = self.inst.audio_ports(false);
|
||||
let ports = self.inst.audio_ports(true).and_then(|ins| Ok((ins, self.inst.audio_ports(false)?)));
|
||||
let (ins, outs) = match ports {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// Unsupported layout: stay inactive (`process` passes audio through untouched).
|
||||
log::warn!("{}: {e}", self.info.id);
|
||||
self.inputs = PortBuffers::new(&[], 0);
|
||||
self.outputs = PortBuffers::new(&[], 0);
|
||||
self.in_main = None;
|
||||
self.out_main = None;
|
||||
self.max_block = 0;
|
||||
self.latency = 0;
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.in_main = pick_main(&ins);
|
||||
self.out_main = pick_main(&outs);
|
||||
self.inputs = PortBuffers::new(&ins.iter().map(|p| p.channels).collect::<Vec<_>>(), mb);
|
||||
self.outputs = PortBuffers::new(&outs.iter().map(|p| p.channels).collect::<Vec<_>>(), mb);
|
||||
self.notes = note_mode(self.inst.note_port());
|
||||
let port = self.inst.note_port();
|
||||
self.notes = note_mode(port);
|
||||
self.midi_in = port.is_some_and(|p| p.supported & CLAP_NOTE_DIALECT_MIDI != 0);
|
||||
self.max_block = mb;
|
||||
self.inst.state().restart.store(false, Ordering::Relaxed);
|
||||
match self.inst.activate(sr, 1, u32::try_from(mb).unwrap_or(u32::MAX)) {
|
||||
@@ -187,7 +207,7 @@ impl Plugin for ClapPlugin {
|
||||
let k = self.queue.iter().position(|e| e.time() >= end).unwrap_or(self.queue.len());
|
||||
let offset = u32::try_from(start).unwrap_or(u32::MAX);
|
||||
let events = self.queue.get(..k).unwrap_or(&[]);
|
||||
let r = self.inst.process(n, self.steady, &mut self.inputs, &mut self.outputs, events, offset);
|
||||
let r = self.inst.process(n, self.steady, &mut self.inputs, &mut self.outputs, events, offset, self.transport.as_ref());
|
||||
self.queue.drain(..k);
|
||||
self.steady = self.steady.saturating_add(i64::try_from(n).unwrap_or(0));
|
||||
match (r, self.out_main) {
|
||||
@@ -217,6 +237,10 @@ impl Plugin for ClapPlugin {
|
||||
self.latency
|
||||
}
|
||||
|
||||
fn set_transport(&mut self, transport: &Transport) {
|
||||
self.transport = Some(*transport);
|
||||
}
|
||||
|
||||
fn note_on(&mut self, offset: usize, note: u8, velocity: u8) {
|
||||
if note > 127 {
|
||||
return;
|
||||
@@ -248,6 +272,12 @@ impl Plugin for ClapPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
|
||||
if self.midi_in {
|
||||
self.push(Event::Midi { time: u32::try_from(offset).unwrap_or(u32::MAX), data: msg });
|
||||
}
|
||||
}
|
||||
|
||||
fn all_notes_off(&mut self) {
|
||||
for k in 0..128u8 {
|
||||
if self.held.get(k as usize).copied().unwrap_or(false) {
|
||||
@@ -256,10 +286,6 @@ impl Plugin for ClapPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
fn save_state(&mut self) -> Option<Vec<u8>> {
|
||||
self.inst.save_state()
|
||||
}
|
||||
|
||||
fn load_state(&mut self, data: &[u8]) -> bool {
|
||||
let ok = self.inst.load_state(data);
|
||||
if ok {
|
||||
@@ -283,6 +309,11 @@ impl Plugin for ClapPlugin {
|
||||
let link = self.inst.gui()?;
|
||||
Some(Box::new(ClapEditor { link, info: self.info, ids: self.ids.clone() }))
|
||||
}
|
||||
|
||||
fn state_reader(&mut self) -> Option<Box<dyn soundcraft_dsp::PluginStateReader>> {
|
||||
let link = self.inst.state_link()?;
|
||||
Some(Box::new(move || link.save_state()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A CLAP plugin's floating editor, driven from the main thread (see [`GuiLink`]).
|
||||
|
||||
@@ -83,6 +83,10 @@ pub fn scan() -> Vec<ClapDescriptor> {
|
||||
ensure_scanned(&mut r).clone()
|
||||
}
|
||||
|
||||
pub fn cached() -> Option<Vec<ClapDescriptor>> {
|
||||
registry().lock().unwrap_or_else(PoisonError::into_inner).scanned.clone()
|
||||
}
|
||||
|
||||
pub fn rescan() -> Vec<ClapDescriptor> {
|
||||
let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
r.scanned = None;
|
||||
|
||||
@@ -51,7 +51,7 @@ pub fn os_search_paths(home: Option<PathBuf>, common_program_files: Option<PathB
|
||||
|
||||
/// True when the path names a CLAP plugin (`*.clap`, case-insensitive).
|
||||
pub fn is_clap_path(p: &Path) -> bool {
|
||||
p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("clap"))
|
||||
p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("clap"))
|
||||
}
|
||||
|
||||
/// Recursively collects `.clap` files and bundles under `dirs` (missing directories are skipped).
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn fixture_dir() -> &'static Path {
|
||||
std::fs::create_dir_all(dir.join("vendor")).unwrap();
|
||||
std::fs::copy(&built, dir.join("vendor/Fixture.clap")).unwrap();
|
||||
}
|
||||
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 2);
|
||||
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 5);
|
||||
dir
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
//! - `org.soundcraft.test.gain`: stereo in/out, param 7 "Gain" (0..2, default 1), param 3 "Mode"
|
||||
//! (stepped enum A/B/C), hidden param 9, latency 3 samples.
|
||||
//! - `org.soundcraft.test.synth`: instrument, stereo out, CLAP note port; outputs a constant equal
|
||||
//! to the velocity of the held note (0 when none), switching at the event's sample offset.
|
||||
//! to the velocity of the held note (0 when none), switching at the event's sample offset. A
|
||||
//! MIDI CC 7 (volume) sets the constant to its value / 127.
|
||||
//!
|
||||
//! - `org.soundcraft.test.wide16`, `.wide17ports`, `.wide65ch`: effects advertising wide layouts
|
||||
//! (16 ports of 64 channels, 17 ports of 2 channels, one port of 65 channels per direction).
|
||||
//! `process` writes 1.0 into every output channel it was given when the host's buffers match the
|
||||
//! advertised layout, -1.0 when they do not.
|
||||
//!
|
||||
//! The gain plugin also implements `clap.state` (gain and mode as two little-endian f64s) and a
|
||||
//! floating-only `clap.gui` without a real window: showing it "edits" the gain to 0.25 in the GUI,
|
||||
@@ -35,6 +41,27 @@ unsafe impl<const N: usize> Sync for Features<N> {}
|
||||
static GAIN_FEATURES: Features<3> = Features([c"audio-effect".as_ptr(), c"stereo".as_ptr(), std::ptr::null()]);
|
||||
static SYNTH_FEATURES: Features<3> = Features([c"instrument".as_ptr(), c"synthesizer".as_ptr(), std::ptr::null()]);
|
||||
|
||||
static WIDE_FEATURES: Features<2> = Features([c"audio-effect".as_ptr(), std::ptr::null()]);
|
||||
|
||||
const fn wide_desc(id: &'static CStr, name: &'static CStr) -> clap_plugin_descriptor {
|
||||
clap_plugin_descriptor {
|
||||
clap_version: CLAP_VERSION,
|
||||
id: id.as_ptr(),
|
||||
name: name.as_ptr(),
|
||||
vendor: c"SoundCraft tests".as_ptr(),
|
||||
url: std::ptr::null(),
|
||||
manual_url: std::ptr::null(),
|
||||
support_url: std::ptr::null(),
|
||||
version: c"1.0.0".as_ptr(),
|
||||
description: std::ptr::null(),
|
||||
features: WIDE_FEATURES.0.as_ptr(),
|
||||
}
|
||||
}
|
||||
|
||||
static WIDE16_DESC: clap_plugin_descriptor = wide_desc(c"org.soundcraft.test.wide16", c"Fixture Wide 16x64");
|
||||
static WIDE17_DESC: clap_plugin_descriptor = wide_desc(c"org.soundcraft.test.wide17ports", c"Fixture Wide 17 ports");
|
||||
static WIDE65_DESC: clap_plugin_descriptor = wide_desc(c"org.soundcraft.test.wide65ch", c"Fixture Wide 65 channels");
|
||||
|
||||
static GAIN_DESC: clap_plugin_descriptor = clap_plugin_descriptor {
|
||||
clap_version: CLAP_VERSION,
|
||||
id: c"org.soundcraft.test.gain".as_ptr(),
|
||||
@@ -65,6 +92,8 @@ struct Fx {
|
||||
plugin: clap_plugin,
|
||||
host: *const clap_host,
|
||||
synth: bool,
|
||||
/// `(ports per direction, channels per port)` for the wide fixtures.
|
||||
wide: Option<(u32, u32)>,
|
||||
gain: f64,
|
||||
mode: f64,
|
||||
level: f64,
|
||||
@@ -150,6 +179,12 @@ fn apply(f: &mut Fx, h: &clap_event_header) {
|
||||
f.level = e.velocity;
|
||||
}
|
||||
CLAP_EVENT_NOTE_OFF => f.level = 0.0,
|
||||
CLAP_EVENT_MIDI => {
|
||||
let e = unsafe { &*(h as *const clap_event_header as *const clap_event_midi) };
|
||||
if e.data[0] & 0xF0 == 0xB0 && e.data[1] == 7 {
|
||||
f.level = f64::from(e.data[2]) / 127.0;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -161,6 +196,22 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
|
||||
let n = pr.frames_count as usize;
|
||||
let ev = unsafe { &*pr.in_events };
|
||||
let count = unsafe { (ev.size.unwrap())(pr.in_events) };
|
||||
if let Some((ports, channels)) = f.wide {
|
||||
let fits = |bufs: *const clap_audio_buffer, n: u32| {
|
||||
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
|
||||
};
|
||||
let ok = fits(pr.audio_inputs, pr.audio_inputs_count) && fits(pr.audio_outputs, pr.audio_outputs_count);
|
||||
if pr.audio_outputs_count > 0 {
|
||||
let out = unsafe { &*pr.audio_outputs };
|
||||
for c in 0..out.channel_count as usize {
|
||||
let o = unsafe { *out.data32.add(c) };
|
||||
for i in 0..n {
|
||||
unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } };
|
||||
}
|
||||
}
|
||||
}
|
||||
return CLAP_PROCESS_CONTINUE;
|
||||
}
|
||||
if pr.audio_outputs_count != 1 {
|
||||
return CLAP_PROCESS_ERROR;
|
||||
}
|
||||
@@ -317,10 +368,26 @@ static PARAMS: clap_plugin_params = clap_plugin_params {
|
||||
};
|
||||
|
||||
unsafe extern "C" fn ports_count(p: *const clap_plugin, is_input: bool) -> u32 {
|
||||
if let Some((ports, _)) = unsafe { fx(p) }.wide {
|
||||
return ports;
|
||||
}
|
||||
if is_input && unsafe { fx(p) }.synth { 0 } else { 1 }
|
||||
}
|
||||
|
||||
unsafe extern "C" fn ports_get(p: *const clap_plugin, index: u32, is_input: bool, info: *mut clap_audio_port_info) -> bool {
|
||||
if let Some((ports, channels)) = unsafe { fx(p) }.wide {
|
||||
if index >= ports {
|
||||
return false;
|
||||
}
|
||||
let info = unsafe { &mut *info };
|
||||
info.id = index * 2 + u32::from(is_input);
|
||||
write_name(&mut info.name, c"wide");
|
||||
info.flags = if index == 0 { CLAP_AUDIO_PORT_IS_MAIN } else { 0 };
|
||||
info.channel_count = channels;
|
||||
info.port_type = std::ptr::null();
|
||||
info.in_place_pair = clap_sys::id::CLAP_INVALID_ID;
|
||||
return true;
|
||||
}
|
||||
if index != 0 || (is_input && unsafe { fx(p) }.synth) {
|
||||
return false;
|
||||
}
|
||||
@@ -491,23 +558,32 @@ unsafe extern "C" fn on_main_thread(_p: *const clap_plugin) {}
|
||||
// ---- factory + entry ----
|
||||
|
||||
unsafe extern "C" fn factory_count(_f: *const clap_plugin_factory) -> u32 {
|
||||
2
|
||||
5
|
||||
}
|
||||
|
||||
unsafe extern "C" fn factory_desc(_f: *const clap_plugin_factory, i: u32) -> *const clap_plugin_descriptor {
|
||||
match i {
|
||||
0 => &GAIN_DESC,
|
||||
1 => &SYNTH_DESC,
|
||||
2 => &WIDE16_DESC,
|
||||
3 => &WIDE17_DESC,
|
||||
4 => &WIDE65_DESC,
|
||||
_ => std::ptr::null(),
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const clap_host, id: *const c_char) -> *const clap_plugin {
|
||||
let id = unsafe { CStr::from_ptr(id) };
|
||||
let (desc, synth) = if id == c"org.soundcraft.test.gain" {
|
||||
(&GAIN_DESC as *const _, false)
|
||||
let (desc, synth, wide) = if id == c"org.soundcraft.test.gain" {
|
||||
(&GAIN_DESC as *const _, false, None)
|
||||
} else if id == c"org.soundcraft.test.synth" {
|
||||
(&SYNTH_DESC as *const _, true)
|
||||
(&SYNTH_DESC as *const _, true, None)
|
||||
} else if id == c"org.soundcraft.test.wide16" {
|
||||
(&WIDE16_DESC as *const _, false, Some((16, 64)))
|
||||
} else if id == c"org.soundcraft.test.wide17ports" {
|
||||
(&WIDE17_DESC as *const _, false, Some((17, 2)))
|
||||
} else if id == c"org.soundcraft.test.wide65ch" {
|
||||
(&WIDE65_DESC as *const _, false, Some((1, 65)))
|
||||
} else {
|
||||
return std::ptr::null();
|
||||
};
|
||||
@@ -528,6 +604,7 @@ unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const
|
||||
},
|
||||
host,
|
||||
synth,
|
||||
wide,
|
||||
gain: 1.0,
|
||||
mode: 0.0,
|
||||
level: 0.0,
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
|
||||
use soundcraft_dsp::{Category, Unit};
|
||||
|
||||
mod common;
|
||||
use common::fixture_dir;
|
||||
use crate::common::fixture_dir;
|
||||
|
||||
const GAIN: &str = "clap:org.soundcraft.test.gain";
|
||||
const SYNTH: &str = "clap:org.soundcraft.test.synth";
|
||||
@@ -13,7 +12,7 @@ const SYNTH: &str = "clap:org.soundcraft.test.synth";
|
||||
fn scan_lists_both_fixture_plugins() {
|
||||
let dir = fixture_dir();
|
||||
let found = soundcraft_clap_host::scan_paths(&[dir.to_path_buf()]);
|
||||
assert_eq!(found.len(), 2);
|
||||
assert_eq!(found.len(), 5);
|
||||
let g = found.iter().find(|d| d.id == GAIN).unwrap();
|
||||
assert_eq!(g.name, "Fixture Gain");
|
||||
assert_eq!(g.vendor, "SoundCraft tests");
|
||||
@@ -105,6 +104,30 @@ fn synth_plays_notes_at_their_offsets() {
|
||||
p.all_notes_off();
|
||||
p.process(&mut io, 64);
|
||||
assert!(io[0].iter().all(|x| x.abs() < 1e-6));
|
||||
p.midi(20, [0xB0, 7, 64]);
|
||||
p.process(&mut io, 64);
|
||||
assert!(io[0][19].abs() < 1e-6 && (io[0][20] - 64.0 / 127.0).abs() < 1e-6, "controllers arrive as MIDI at their offset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
|
||||
fixture_dir();
|
||||
// Control: exactly at the limits (16 ports of 64 channels) activates and gets its full layout.
|
||||
let mut p = soundcraft_clap_host::create("clap:org.soundcraft.test.wide16").unwrap();
|
||||
p.prepare(44_100.0, 64, 2);
|
||||
assert_eq!(p.latency(), 3);
|
||||
let mut io = vec![vec![0.5f32; 64]; 2];
|
||||
p.process(&mut io, 64);
|
||||
assert!(io.iter().flatten().all(|&x| x == 1.0), "16x64 layout delivered as advertised: {:?}", &io[0][..4]);
|
||||
// 17 ports, or a 65-channel port: never activated, never processed with a cut-down layout.
|
||||
for id in ["clap:org.soundcraft.test.wide17ports", "clap:org.soundcraft.test.wide65ch"] {
|
||||
let mut p = soundcraft_clap_host::create(id).unwrap();
|
||||
p.prepare(44_100.0, 64, 2);
|
||||
assert_eq!(p.latency(), 0, "{id}: not activated");
|
||||
let mut io = vec![vec![0.5f32; 64]; 2];
|
||||
p.process(&mut io, 64);
|
||||
assert!(io.iter().flatten().all(|&x| x == 0.5), "{id}: the plugin never ran: {:?}", &io[0][..4]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -146,11 +169,36 @@ fn state_saves_and_restores_into_a_new_instance() {
|
||||
// Plugins without clap.state.
|
||||
let mut s = soundcraft_clap_host::create(SYNTH).unwrap();
|
||||
assert!(s.save_state().is_none());
|
||||
assert!(s.state_reader().is_none());
|
||||
assert!(!s.load_state(&blob));
|
||||
assert!(s.editor().is_none(), "the synth has no GUI");
|
||||
assert!(s.open_editor().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_reader_reads_while_another_thread_processes() {
|
||||
fixture_dir();
|
||||
let mut p = soundcraft_clap_host::create(GAIN).unwrap();
|
||||
p.prepare(48_000.0, 64, 2);
|
||||
assert!(p.set_param("7", 1.5));
|
||||
p.process(&mut vec![vec![0.5f32; 64]; 2], 64);
|
||||
let mut reader = p.state_reader().expect("the gain fixture has clap.state");
|
||||
let blob = p.save_state().unwrap();
|
||||
let audio = std::thread::spawn(move || {
|
||||
for _ in 0..2_000 {
|
||||
p.process(&mut vec![vec![0.5f32; 64]; 2], 64);
|
||||
}
|
||||
p
|
||||
});
|
||||
while !audio.is_finished() {
|
||||
assert_eq!(reader.save_state().as_ref(), Some(&blob));
|
||||
}
|
||||
let p = audio.join().unwrap();
|
||||
assert_eq!(reader.save_state(), Some(blob));
|
||||
drop(p);
|
||||
assert_eq!(reader.save_state(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editors_refuse_to_open_off_the_main_thread() {
|
||||
fixture_dir();
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
#[path = "../common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
mod fixture_e2e;
|
||||
+56
-4
@@ -169,6 +169,24 @@ impl PluginInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// The host's transport at the start of a block, for tempo-synced plugins.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct Transport {
|
||||
pub playing: bool,
|
||||
pub recording: bool,
|
||||
/// Quarter notes per minute.
|
||||
pub tempo: f64,
|
||||
pub numerator: u32,
|
||||
pub denominator: u32,
|
||||
/// Timeline position.
|
||||
pub samples: i64,
|
||||
pub seconds: f64,
|
||||
pub quarters: f64,
|
||||
/// Start of the current bar, in quarter notes, and its number (0 for the first bar).
|
||||
pub bar_start: f64,
|
||||
pub bar: i64,
|
||||
}
|
||||
|
||||
/// A realtime audio processor or instrument.
|
||||
///
|
||||
/// Contract: `process` never allocates or panics. It processes `min(frames, io[c].len())` frames
|
||||
@@ -195,9 +213,14 @@ pub trait Plugin: Send {
|
||||
fn gain_reduction_db(&self) -> f32 {
|
||||
0.0
|
||||
}
|
||||
/// The transport for the next block, given before `process`.
|
||||
fn set_transport(&mut self, _transport: &Transport) {}
|
||||
/// Instruments: note events at sample offsets within the next block.
|
||||
fn note_on(&mut self, _offset: usize, _note: u8, _velocity: u8) {}
|
||||
fn note_off(&mut self, _offset: usize, _note: u8) {}
|
||||
/// Instruments: any other MIDI 1.0 channel message (control change, pitch bend, pressure,
|
||||
/// program change) at a sample offset within the next block.
|
||||
fn midi(&mut self, _offset: usize, _msg: [u8; 3]) {}
|
||||
fn all_notes_off(&mut self) {}
|
||||
/// Sample-playing instruments (the Sampler): the recorded audio to play, `None` to clear it.
|
||||
/// `false` when the plugin does not play samples. Does not allocate; sounding voices stop.
|
||||
@@ -206,11 +229,12 @@ pub trait Plugin: Send {
|
||||
fn set_sample(&mut self, _sample: Option<Arc<dyn SampleSource>>) -> bool {
|
||||
false
|
||||
}
|
||||
/// The plugin's complete internal state as an opaque blob (third-party plugins), for saving
|
||||
/// in the session. `None` when the plugin has no state beyond its parameters (built-ins) or
|
||||
/// the plugin refused. May allocate: never call it in steady-state audio processing.
|
||||
/// The plugin's complete internal state as an opaque blob (third-party plugins), as its
|
||||
/// [`Plugin::state_reader`] reads it. `None` when the plugin has no state beyond its
|
||||
/// parameters (built-ins) or the plugin refused. May allocate: never call it in steady-state
|
||||
/// audio processing.
|
||||
fn save_state(&mut self) -> Option<Vec<u8>> {
|
||||
None
|
||||
self.state_reader()?.save_state()
|
||||
}
|
||||
/// Restores a blob from [`Plugin::save_state`]. `false` if unsupported or the plugin rejected
|
||||
/// it (the plugin keeps working with its previous state). The data is hostile input.
|
||||
@@ -229,6 +253,10 @@ pub trait Plugin: Send {
|
||||
fn editor(&mut self) -> Option<Box<dyn PluginEditor>> {
|
||||
None
|
||||
}
|
||||
/// Reads this instance's state from the UI thread (`None` when it has no state of its own).
|
||||
fn state_reader(&mut self) -> Option<Box<dyn PluginStateReader>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A plugin's editor (GUI), driven from the main (UI) thread. Obtained from
|
||||
@@ -247,6 +275,25 @@ pub trait PluginEditor: Send {
|
||||
/// Tells the editor that a parameter changed elsewhere (generic editor, automation), so the
|
||||
/// GUI shows it. Values are in SoundCraft units.
|
||||
fn set_param(&mut self, _id: &str, _value: f32) {}
|
||||
/// The plugin's own text for a parameter value (SoundCraft units) when SoundCraft's
|
||||
/// [`ParamInfo::format`] would be wrong for it. Main thread only; `None` means "format it
|
||||
/// yourself".
|
||||
fn value_text(&mut self, _id: &str, _value: f32) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a plugin's state on the main (UI) thread while the instance processes on the audio
|
||||
/// thread, as plugin formats allow. Obtained from [`Plugin::state_reader`]; closures are readers.
|
||||
pub trait PluginStateReader: Send {
|
||||
/// What [`Plugin::save_state`] would return; `None` once the instance is gone.
|
||||
fn save_state(&mut self) -> Option<Vec<u8>>;
|
||||
}
|
||||
|
||||
impl<F: FnMut() -> Option<Vec<u8>> + Send> PluginStateReader for F {
|
||||
fn save_state(&mut self) -> Option<Vec<u8>> {
|
||||
self()
|
||||
}
|
||||
}
|
||||
|
||||
/// Recorded audio a sample-playing instrument reads: planar `f32` at its own sample rate (the
|
||||
@@ -301,6 +348,11 @@ pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> {
|
||||
plugins::REGISTRY.iter().copied().find(|i| i.id == id)
|
||||
}
|
||||
|
||||
/// Whether a plugin makes sound instead of processing its input (it ignores the input).
|
||||
pub fn is_generator(id: &str) -> bool {
|
||||
id == "signal_generator"
|
||||
}
|
||||
|
||||
/// Silence floor used by the dB helpers.
|
||||
pub const MIN_DB: f32 = -144.0;
|
||||
|
||||
|
||||
@@ -164,10 +164,12 @@ fn lufs(energy: f64) -> f32 {
|
||||
/// K-weighting pre-filter (high shelf) and RLB high-pass designed for any sample rate.
|
||||
fn k_weighting(sr: f32) -> [Coeffs; 2] {
|
||||
let sr = f64::from(sr);
|
||||
let pi = std::f64::consts::PI;
|
||||
// Bilinear prewarp. A corner at or past Nyquist wraps tan() negative (poles outside the unit
|
||||
// circle) or aliases it, so it is held just below Nyquist, as `Coeffs::design` does.
|
||||
let prewarp = |f0: f64| (std::f64::consts::PI * f0.min(sr * 0.49) / sr).tan();
|
||||
// Stage 1: shelving.
|
||||
let (f0, g, q) = (1_681.974_450_955_533, 3.999_843_853_973_347, 0.707_175_236_955_419_6);
|
||||
let k = (pi * f0 / sr).tan();
|
||||
let k = prewarp(f0);
|
||||
let vh = 10f64.powf(g / 20.0);
|
||||
let vb = vh.powf(0.499_666_774_154_541_6);
|
||||
let a0 = 1.0 + k / q + k * k;
|
||||
@@ -180,7 +182,7 @@ fn k_weighting(sr: f32) -> [Coeffs; 2] {
|
||||
};
|
||||
// Stage 2: high-pass.
|
||||
let (f0, q) = (38.135_470_876_024_44, 0.500_327_037_323_877_3);
|
||||
let k = (pi * f0 / sr).tan();
|
||||
let k = prewarp(f0);
|
||||
let a0 = 1.0 + k / q + k * k;
|
||||
let s2 = Coeffs { b0: 1.0, b1: -2.0, b2: 1.0, a1: (2.0 * (k * k - 1.0) / a0) as f32, a2: ((1.0 - k / q + k * k) / a0) as f32 };
|
||||
[s1, s2]
|
||||
|
||||
+200
-80
@@ -52,7 +52,8 @@ fn max_len(ch: &[Vec<f32>]) -> usize {
|
||||
ch.iter().map(Vec::len).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn peak(ch: &[Vec<f32>]) -> f32 {
|
||||
/// The largest finite sample magnitude in any channel.
|
||||
pub fn peak(ch: &[Vec<f32>]) -> f32 {
|
||||
ch.iter().flatten().filter(|v| v.is_finite()).fold(0.0f32, |m, v| m.max(v.abs()))
|
||||
}
|
||||
|
||||
@@ -64,19 +65,29 @@ pub fn normalize(ch: &mut [Vec<f32>], target_db: f32, per_channel: bool) {
|
||||
for c in ch.iter_mut() {
|
||||
let p = peak(std::slice::from_ref(c));
|
||||
if p > 0.0 {
|
||||
let g = target / p;
|
||||
c.iter_mut().for_each(|s| *s *= g);
|
||||
scale_to(c.iter_mut(), target, p);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let p = peak(ch);
|
||||
if p > 0.0 {
|
||||
let g = target / p;
|
||||
ch.iter_mut().flatten().for_each(|s| *s *= g);
|
||||
scale_to(ch.iter_mut().flatten(), target, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scales samples whose peak is `p` to `target`. A subnormal `p` makes the f32 gain overflow to
|
||||
/// infinity, so that case scales in f64.
|
||||
fn scale_to<'a>(samples: impl Iterator<Item = &'a mut f32>, target: f32, p: f32) {
|
||||
let g = target / p;
|
||||
if g.is_finite() {
|
||||
samples.for_each(|s| *s *= g);
|
||||
} else {
|
||||
let g = f64::from(target) / f64::from(p);
|
||||
samples.for_each(|s| *s = (f64::from(*s) * g) as f32);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a gain change in dB.
|
||||
pub fn gain(ch: &mut [Vec<f32>], db: f32) {
|
||||
let g = db_to_gain(if db.is_nan() { 0.0 } else { db.min(96.0) });
|
||||
@@ -341,7 +352,14 @@ pub fn pitch_shift(ch: &[Vec<f32>], semitones: f32, sample_rate: f32) -> Vec<Vec
|
||||
return ch.to_vec();
|
||||
}
|
||||
let factor = 2f64.powf(f64::from(st) / 12.0);
|
||||
let stretched = time_stretch(ch, factor, sample_rate);
|
||||
// time_stretch clamps to 0.1..=10, short of the 16x (or 1/16) that ±48 needs: stretch twice by
|
||||
// the square root instead.
|
||||
let stretched = if (0.1..=10.0).contains(&factor) {
|
||||
time_stretch(ch, factor, sample_rate)
|
||||
} else {
|
||||
let half = factor.sqrt();
|
||||
time_stretch(&time_stretch(ch, half, sample_rate), half, sample_rate)
|
||||
};
|
||||
let len = max_len(ch);
|
||||
let slen = max_len(&stretched);
|
||||
if slen == 0 || len == 0 {
|
||||
@@ -371,111 +389,213 @@ pub fn signal_generator(kind: Waveform, freq: f32, level_db: f32, sample_rate: f
|
||||
(0..channels).map(|_| mono.clone()).collect()
|
||||
}
|
||||
|
||||
/// Onset detection by half-wave-rectified spectral flux with an adaptive threshold.
|
||||
/// `sensitivity` 0..1 (higher finds more transients). Returns sample positions, ascending.
|
||||
/// Onset detection: how much a log-magnitude spectrum on 24 bands per octave rises against the frame
|
||||
/// before, each band against the loudest of its neighbours there so vibrato doesn't count
|
||||
/// (vibrato-suppressed spectral flux). A peak must stand out from the flux within 0.1 s by a fixed
|
||||
/// amount, not relative to the loudest hit, so quiet hits count and the result doesn't depend on where
|
||||
/// the analysed audio starts. Each is placed where its attack starts. `sensitivity` 0..1 (higher finds
|
||||
/// more). Returns sample positions, ascending.
|
||||
pub fn detect_transients(ch: &[Vec<f32>], sample_rate: f32, sensitivity: f32) -> Vec<usize> {
|
||||
let len = max_len(ch);
|
||||
let sr = sane_sr(sample_rate);
|
||||
let sens = if sensitivity.is_finite() { sensitivity.clamp(0.0, 1.0) } else { 0.5 };
|
||||
let n = ((sr * 0.023) as usize).clamp(64, 8192).next_power_of_two();
|
||||
let n = ((sr * 0.012) as usize).clamp(64, 8192).next_power_of_two();
|
||||
let hop = n / 4;
|
||||
if len < n || ch.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let ch: Vec<Vec<f32>> =
|
||||
ch.iter().take(crate::util::MAX_CHANNELS).map(|c| c.iter().map(|v| if v.is_finite() { *v } else { 0.0 }).collect()).collect();
|
||||
let nch = ch.len() as f32;
|
||||
let mono: Vec<f32> = (0..len).map(|i| ch.iter().map(|c| c.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0)).sum::<f32>() / nch).collect();
|
||||
let mut planner = FftPlanner::<f32>::new();
|
||||
let fft = planner.plan_fft_forward(n);
|
||||
let bands = log_bands(n, sr);
|
||||
let fft = FftPlanner::<f32>::new().plan_fft_forward(n);
|
||||
let window: Vec<f32> = (0..n).map(|i| (0.5 - 0.5 * (2.0 * PI * i as f64 / n as f64).cos()) as f32).collect();
|
||||
// Magnitudes relative to a full-scale sine; below about -80 dB a band's changes barely count.
|
||||
let gain = 1.0e4 / (n as f32 / 4.0);
|
||||
let frames = (len - n) / hop + 1;
|
||||
let mut prev = vec![0.0f32; n / 2 + 1];
|
||||
let mut buf = vec![Complex::new(0.0f32, 0.0); n];
|
||||
let mut power = vec![0.0f32; n / 2 + 1];
|
||||
let mut prev: Vec<f32> = Vec::new();
|
||||
let mut flux = Vec::with_capacity(frames);
|
||||
let mut energy: Vec<f32> = Vec::with_capacity(frames);
|
||||
for m in 0..frames {
|
||||
let start = m * hop;
|
||||
for (i, b) in buf.iter_mut().enumerate() {
|
||||
*b = Complex::new(mono.get(start + i).copied().unwrap_or(0.0) * window.get(i).copied().unwrap_or(0.0), 0.0);
|
||||
}
|
||||
fft.process(&mut buf);
|
||||
let mut f = 0.0;
|
||||
for (b, p) in buf.iter().zip(prev.iter_mut()) {
|
||||
let mag = (1.0 + 100.0 * b.norm()).ln();
|
||||
f += (mag - *p).max(0.0);
|
||||
*p = mag;
|
||||
}
|
||||
flux.push(if m == 0 { 0.0 } else { f });
|
||||
}
|
||||
let max_flux = flux.iter().copied().fold(0.0f32, f32::max);
|
||||
if max_flux <= 1e-6 {
|
||||
return Vec::new();
|
||||
}
|
||||
let w = ((0.1 * sr) as usize / hop).max(2);
|
||||
let mult = 1.0 + 2.5 * (1.0 - sens);
|
||||
let delta = max_flux * (0.02 + 0.25 * (1.0 - sens));
|
||||
let min_gap = ((0.05 * sr) as usize / hop).max(1);
|
||||
let mut out = Vec::new();
|
||||
let mut last: Option<usize> = None;
|
||||
for m in 1..flux.len() {
|
||||
let cur = flux.get(m).copied().unwrap_or(0.0);
|
||||
let lo = m.saturating_sub(w);
|
||||
let hi = (m + w + 1).min(flux.len());
|
||||
let local = flux.get(lo..hi).map(|s| s.iter().sum::<f32>() / s.len().max(1) as f32).unwrap_or(0.0);
|
||||
let prev_v = flux.get(m - 1).copied().unwrap_or(0.0);
|
||||
let next_v = flux.get(m + 1).copied().unwrap_or(0.0);
|
||||
if cur > local * mult + delta && cur >= prev_v && cur > next_v && last.is_none_or(|l| m - l >= min_gap) {
|
||||
last = Some(m);
|
||||
// Refine: first sample in the analysis frame reaching a quarter of the frame's peak.
|
||||
let start = m * hop;
|
||||
let seg = mono.get(start..(start + n).min(len)).unwrap_or(&[]);
|
||||
let pk = seg.iter().fold(0.0f32, |a, v| a.max(v.abs()));
|
||||
let off = seg.iter().position(|v| v.abs() >= 0.25 * pk).unwrap_or(n / 2);
|
||||
let pos = start + off;
|
||||
if out.last().is_none_or(|&p: &usize| pos > p) {
|
||||
out.push(pos);
|
||||
power.iter_mut().for_each(|p| *p = 0.0);
|
||||
for c in &ch {
|
||||
for (i, b) in buf.iter_mut().enumerate() {
|
||||
*b = Complex::new(c.get(m * hop + i).copied().unwrap_or(0.0) * window.get(i).copied().unwrap_or(0.0), 0.0);
|
||||
}
|
||||
fft.process(&mut buf);
|
||||
for (p, b) in power.iter_mut().zip(&buf) {
|
||||
*p += b.norm_sqr() / nch;
|
||||
}
|
||||
}
|
||||
energy.push(power.iter().sum());
|
||||
let cur: Vec<f32> = bands
|
||||
.iter()
|
||||
.map(|band| (1.0 + gain * band.iter().map(|&(k, w)| power.get(k).map_or(0.0, |p| p.sqrt()) * w).sum::<f32>()).log10())
|
||||
.collect();
|
||||
let rise = |b: usize, v: f32| {
|
||||
(v - prev.get(b.saturating_sub(1)..(b + 2).min(prev.len())).map_or(0.0, |s| s.iter().copied().fold(0.0, f32::max))).max(0.0)
|
||||
};
|
||||
flux.push(if prev.is_empty() { 0.0 } else { cur.iter().enumerate().map(|(b, v)| rise(b, *v)).sum() });
|
||||
prev = cur;
|
||||
}
|
||||
let frames_of = |secs: f32| ((secs * sr) as usize / hop).max(1);
|
||||
let (near, before, after, gap) = (frames_of(0.03), frames_of(0.1), frames_of(0.07), frames_of(0.03));
|
||||
let delta = 2.5 * 2f32.powf((0.5 - sens) * 4.0);
|
||||
let mut out: Vec<usize> = Vec::new();
|
||||
let mut last: Option<usize> = None;
|
||||
for (m, &cur) in flux.iter().enumerate() {
|
||||
let span = |a: usize, b: usize| flux.get(m.saturating_sub(a)..(m + b + 1).min(frames)).unwrap_or(&[]);
|
||||
let peak = span(near, near).iter().copied().fold(0.0, f32::max);
|
||||
let around = span(before, after);
|
||||
let mean = around.iter().sum::<f32>() / around.len().max(1) as f32;
|
||||
if cur <= 0.0 || cur < peak || cur < mean + delta || last.is_some_and(|l| m - l < gap) {
|
||||
continue;
|
||||
}
|
||||
let (at, contrast) = attack_start(&ch, m * hop, n, sr);
|
||||
// A fast release smears energy between a note's partials, which looks like a rise. Its
|
||||
// attack edge is weak and the energy doesn't grow, unlike a real onset's.
|
||||
let was = energy.get(m.saturating_sub(1)).copied().unwrap_or(0.0);
|
||||
let grows = energy.get(m..(m + 2).min(frames)).is_some_and(|e| e.iter().any(|v| *v > was));
|
||||
if contrast < std::f64::consts::LN_2 && !grows {
|
||||
continue;
|
||||
}
|
||||
last = Some(m);
|
||||
if out.last().is_none_or(|&p| at > p) {
|
||||
out.push(at);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Triangular filters 24 to the octave from 30 Hz to 20 kHz over the bins of an `n`-point FFT, without
|
||||
/// duplicates where bins are coarser than that; each filter's weights sum to 1.
|
||||
fn log_bands(n: usize, sr: f32) -> Vec<Vec<(usize, f32)>> {
|
||||
let bin_hz = sr / n as f32;
|
||||
let top = 20_000.0f32.min(sr * 0.45);
|
||||
let mut centres: Vec<usize> = Vec::new();
|
||||
let mut f = 30.0f32;
|
||||
while f <= top {
|
||||
let k = (f / bin_hz).round() as usize;
|
||||
if centres.last().is_none_or(|&c| k > c) {
|
||||
centres.push(k);
|
||||
}
|
||||
f *= 2f32.powf(1.0 / 24.0);
|
||||
}
|
||||
centres
|
||||
.windows(3)
|
||||
.filter_map(|w| {
|
||||
let (&lo, &mid, &hi) = (w.first()?, w.get(1)?, w.get(2)?);
|
||||
let tri: Vec<(usize, f32)> = (lo..=hi)
|
||||
.map(|k| (k, if k <= mid { (k - lo) as f32 / (mid - lo).max(1) as f32 } else { (hi - k) as f32 / (hi - mid).max(1) as f32 }))
|
||||
.filter(|(_, v)| *v > 0.0)
|
||||
.collect();
|
||||
let sum: f32 = tri.iter().map(|(_, v)| v).sum();
|
||||
(sum > 0.0).then(|| tri.into_iter().map(|(k, v)| (k, v / sum)).collect())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where the attack found in the frame starting at `frame` begins. The log spectrum jumps as soon as the
|
||||
/// window reaches an attack, so it can sit up to half a window past the frame. Find the step edge of the
|
||||
/// high-passed energy, the sample where the next 3 ms gain most over the 6 ms before, then go back to
|
||||
/// where the energy first crosses the midpoint (in dB) between the two.
|
||||
fn attack_start(ch: &[Vec<f32>], frame: usize, n: usize, sr: f32) -> (usize, f64) {
|
||||
let (before, after, short) = (((sr * 0.006) as usize).max(1), ((sr * 0.003) as usize).max(1), ((sr * 0.0005) as usize).max(1));
|
||||
let (from, to) = (frame.saturating_sub(before), frame + n + n / 2 + after);
|
||||
let mut cum = vec![0.0f64; to - from + 1];
|
||||
for (j, i) in (from..to).enumerate() {
|
||||
let e: f32 = ch
|
||||
.iter()
|
||||
.map(|x| {
|
||||
let d = x.get(i).copied().unwrap_or(0.0) - i.checked_sub(1).and_then(|p| x.get(p)).copied().unwrap_or(0.0);
|
||||
d * d
|
||||
})
|
||||
.sum();
|
||||
let sum = cum.get(j).copied().unwrap_or(0.0) + f64::from(e);
|
||||
if let Some(c) = cum.get_mut(j + 1) {
|
||||
*c = sum;
|
||||
}
|
||||
}
|
||||
let at = |i: usize| cum.get(i.saturating_sub(from)).copied().unwrap_or(0.0);
|
||||
let mean = |a: usize, b: usize| (at(b) - at(a)) / b.saturating_sub(a).max(1) as f64;
|
||||
let contrast = |k: usize| ((mean(k, k + after) + 1e-12) / (mean(k.saturating_sub(before), k) + 1e-12)).ln();
|
||||
let k = (frame..frame + n + n / 2).max_by(|&a, &b| contrast(a).total_cmp(&contrast(b))).unwrap_or(frame);
|
||||
let level = (mean(k.saturating_sub(before), k) * mean(k, k + after)).sqrt();
|
||||
((k.saturating_sub(after)..=k).find(|&i| mean(i, i + short) > level).unwrap_or(k), contrast(k))
|
||||
}
|
||||
|
||||
/// Strip Silence: ranges `(start, end)` (end exclusive) of material whose peak across channels
|
||||
/// exceeds `threshold_db`. Silent gaps shorter than `min_len` samples do not split a region.
|
||||
/// Each region is padded by `pad_before`/`pad_after` (clamped to the buffer) and overlapping
|
||||
/// regions are merged.
|
||||
pub fn non_silent_ranges(ch: &[Vec<f32>], threshold_db: f32, min_len: usize, pad_before: usize, pad_after: usize) -> Vec<(usize, usize)> {
|
||||
let len = max_len(ch);
|
||||
let thr = db_to_gain(if threshold_db.is_nan() { -48.0 } else { threshold_db });
|
||||
let mut raw: Vec<(usize, usize)> = Vec::new();
|
||||
let mut start: Option<usize> = None;
|
||||
let mut last_loud = 0usize;
|
||||
for i in 0..len {
|
||||
let loud = ch.iter().any(|c| c.get(i).is_some_and(|v| v.is_finite() && v.abs() > thr));
|
||||
if !loud {
|
||||
continue;
|
||||
}
|
||||
match start {
|
||||
Some(s) if i - last_loud > min_len.max(1) => {
|
||||
raw.push((s, last_loud + 1));
|
||||
start = Some(i);
|
||||
let mut scan = SilenceScan::new(threshold_db, min_len as u64);
|
||||
scan.push(ch);
|
||||
let at = |x: u64| usize::try_from(x).unwrap_or(usize::MAX);
|
||||
scan.finish(pad_before as u64, pad_after as u64).into_iter().map(|(s, e)| (at(s), at(e))).collect()
|
||||
}
|
||||
|
||||
/// [`non_silent_ranges`] over audio that arrives a block at a time, so material of any length can
|
||||
/// be scanned without holding all of it.
|
||||
pub struct SilenceScan {
|
||||
thr: f32,
|
||||
min_len: u64,
|
||||
len: u64,
|
||||
start: Option<u64>,
|
||||
last_loud: u64,
|
||||
raw: Vec<(u64, u64)>,
|
||||
}
|
||||
|
||||
impl SilenceScan {
|
||||
pub fn new(threshold_db: f32, min_len: u64) -> Self {
|
||||
let thr = db_to_gain(if threshold_db.is_nan() { -48.0 } else { threshold_db });
|
||||
SilenceScan { thr, min_len, len: 0, start: None, last_loud: 0, raw: Vec::new() }
|
||||
}
|
||||
|
||||
/// Scan the next block (planar), which follows the audio scanned so far.
|
||||
pub fn push(&mut self, ch: &[Vec<f32>]) {
|
||||
let n = max_len(ch);
|
||||
for k in 0..n {
|
||||
if !ch.iter().any(|c| c.get(k).is_some_and(|v| v.is_finite() && v.abs() > self.thr)) {
|
||||
continue;
|
||||
}
|
||||
None => start = Some(i),
|
||||
_ => {}
|
||||
let i = self.len.saturating_add(k as u64);
|
||||
match self.start {
|
||||
Some(s) if i - self.last_loud > self.min_len.max(1) => {
|
||||
self.raw.push((s, self.last_loud.saturating_add(1)));
|
||||
self.start = Some(i);
|
||||
}
|
||||
None => self.start = Some(i),
|
||||
_ => {}
|
||||
}
|
||||
self.last_loud = i;
|
||||
}
|
||||
last_loud = i;
|
||||
self.len = self.len.saturating_add(n as u64);
|
||||
}
|
||||
if let Some(s) = start {
|
||||
raw.push((s, last_loud + 1));
|
||||
|
||||
/// Pass over `n` samples known to be silent.
|
||||
pub fn skip(&mut self, n: u64) {
|
||||
self.len = self.len.saturating_add(n);
|
||||
}
|
||||
let mut out: Vec<(usize, usize)> = Vec::with_capacity(raw.len());
|
||||
for (s, e) in raw {
|
||||
let s = s.saturating_sub(pad_before);
|
||||
let e = e.saturating_add(pad_after).min(len);
|
||||
match out.last_mut() {
|
||||
Some(last) if s <= last.1 => last.1 = last.1.max(e),
|
||||
_ => out.push((s, e)),
|
||||
|
||||
/// The regions found, padded and merged (see [`non_silent_ranges`]).
|
||||
pub fn finish(mut self, pad_before: u64, pad_after: u64) -> Vec<(u64, u64)> {
|
||||
if let Some(s) = self.start {
|
||||
self.raw.push((s, self.last_loud.saturating_add(1)));
|
||||
}
|
||||
let mut out: Vec<(u64, u64)> = Vec::with_capacity(self.raw.len());
|
||||
for (s, e) in self.raw {
|
||||
let s = s.saturating_sub(pad_before);
|
||||
let e = e.saturating_add(pad_after).min(self.len);
|
||||
match out.last_mut() {
|
||||
Some(last) if s <= last.1 => last.1 = last.1.max(e),
|
||||
_ => out.push((s, e)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Runs a plugin over whole buffers in blocks, compensating its latency so the output lines up
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn yin(frame: &[f32], sample_rate: f32, min_hz: f32, max_hz: f32, threshold:
|
||||
}
|
||||
let max_tau = ((sample_rate / min_hz.max(20.0)) as usize).min(frame.len() / 2);
|
||||
let min_tau = ((sample_rate / max_hz.max(min_hz + 1.0)) as usize).max(2);
|
||||
if max_tau <= min_tau + 2 {
|
||||
if max_tau <= min_tau.saturating_add(2) {
|
||||
return None;
|
||||
}
|
||||
let w = frame.len() - max_tau;
|
||||
@@ -140,4 +140,16 @@ mod tests {
|
||||
assert!(audio_to_notes(&[], f32::NAN).is_empty());
|
||||
assert!(yin(&[0.0; 10], 48_000.0, 50.0, 1600.0, 0.15).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_finite_rate_is_unvoiced() {
|
||||
// #241: the shortest lag saturates to usize::MAX; that is "no pitch", not an overflow.
|
||||
assert!(yin(&[0.0; 64], 1.0e30, 50.0, 1600.0, 0.15).is_none());
|
||||
assert!(yin(&[0.5; 8192], f32::MAX, 50.0, 1600.0, 0.15).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_to_notes_survives_huge_finite_rate() {
|
||||
assert!(audio_to_notes(&[0.5; 8192], 1.0e30).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,7 +387,7 @@ impl DrumSynth {
|
||||
fn apply(&mut self, kind: EventKind) {
|
||||
match kind {
|
||||
EventKind::On(n, v) => self.trigger(n, v),
|
||||
EventKind::Off(_) => {}
|
||||
EventKind::Off(_) | EventKind::Sustain(_) | EventKind::Bend(_) | EventKind::ResetControllers => {}
|
||||
EventKind::AllOff => {
|
||||
let fast = decay60(30.0, self.sr);
|
||||
for v in &mut self.voices {
|
||||
|
||||
@@ -184,12 +184,18 @@ impl<const S: usize> Eq<S> {
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
let mut snapped = false;
|
||||
for (i, (s, info)) in self.sm.iter_mut().zip(self.info.params).enumerate() {
|
||||
s.set(self.p.v(i));
|
||||
if matches!(info.unit, Unit::Toggle | Unit::Choice) {
|
||||
s.snap();
|
||||
snapped |= self.vals.get(i) != Some(&s.value());
|
||||
}
|
||||
}
|
||||
// A snapped smoother never settles, so `process` would not redesign for it.
|
||||
if snapped {
|
||||
self.redesign();
|
||||
}
|
||||
let g = db_to_gain(self.total_gain_db(|i| self.p.v(i)));
|
||||
self.gain.set(g);
|
||||
}
|
||||
|
||||
@@ -316,7 +316,15 @@ impl Plugin for ModFx {
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
ms_to_samples(200.0, self.sr) as usize
|
||||
// Time for feedback to fall by 60 dB at the longest delay of the sweep, as for the delay
|
||||
// above; never shorter than the 200 ms this used to report.
|
||||
let longest = match self.kind {
|
||||
ModKind::Chorus => self.p.v(2) + self.p.v(1) / 100.0 * 6.0,
|
||||
ModKind::Flanger => self.p.v(2),
|
||||
};
|
||||
let fb = (self.p.v(3).abs() / 100.0).clamp(0.0, 0.99);
|
||||
let repeats = if fb < 1e-3 { 1.0 } else { (-60.0 / (20.0 * fb.log10())).min(500.0) + 1.0 };
|
||||
ms_to_samples((longest * repeats).max(200.0), self.sr) as usize
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -299,6 +299,7 @@ impl Sampler {
|
||||
v.env.gate_off();
|
||||
}
|
||||
}
|
||||
EventKind::Sustain(_) | EventKind::Bend(_) | EventKind::ResetControllers => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,15 +331,26 @@ impl Sampler {
|
||||
/// Moves a voice's read position one output frame on, looping or ending it.
|
||||
#[inline]
|
||||
fn advance(v: &mut Voice, len: f64, region: Option<(f64, f64)>, mode: LoopMode) {
|
||||
v.pos = if v.forward { v.pos + v.step } else { v.pos - v.step };
|
||||
let was_forward = v.forward;
|
||||
v.pos = if was_forward { v.pos + v.step } else { v.pos - v.step };
|
||||
match region {
|
||||
Some((ls, le)) if mode == LoopMode::PingPong => {
|
||||
if v.forward && v.pos >= le {
|
||||
v.pos = (le - (v.pos - le)).max(ls);
|
||||
v.forward = false;
|
||||
} else if !v.forward && v.pos <= ls {
|
||||
v.pos = (ls + (ls - v.pos)).min(le);
|
||||
v.forward = true;
|
||||
// How far past the edge it was heading for, measured from the edge it left. A step of
|
||||
// more than the loop's width crosses it more than once: fold the distance into the
|
||||
// loop (even crossings keep the direction, odd ones turn it round) instead of
|
||||
// reflecting once and dropping the rest of the travel.
|
||||
let width = le - ls;
|
||||
let dist = if was_forward { v.pos - ls } else { le - v.pos };
|
||||
if width > 0.0 && dist >= width && dist.is_finite() {
|
||||
let crossings = (dist / width).floor();
|
||||
let into = dist - crossings * width;
|
||||
let odd = crossings % 2.0 == 1.0;
|
||||
(v.pos, v.forward) = match (was_forward, odd) {
|
||||
(true, false) => (ls + into, true),
|
||||
(true, true) => (le - into, false),
|
||||
(false, false) => (le - into, false),
|
||||
(false, true) => (ls + into, true),
|
||||
};
|
||||
}
|
||||
}
|
||||
Some((ls, le)) => {
|
||||
@@ -702,6 +714,52 @@ mod tests {
|
||||
assert!(out[0].iter().all(|x| x.is_finite() && *x <= 1.0));
|
||||
}
|
||||
|
||||
/// Where a ping-pong voice is after `n` steps, by walking a triangle wave over the loop.
|
||||
fn triangle(start: f64, forward: bool, step: f64, n: u32, ls: f64, le: f64) -> (f64, bool) {
|
||||
let width = le - ls;
|
||||
// Unfold the walk onto one line, then fold it back: phase 0..width rising, width..2 width falling.
|
||||
let signed = if forward { start - ls } else { (le - start) + width };
|
||||
let t = (signed + f64::from(n) * step).rem_euclid(2.0 * width);
|
||||
if t < width { (ls + t, true) } else { (le - (t - width), false) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ping_pong_step_wider_than_the_loop_keeps_the_phase() {
|
||||
let (ls, le) = (100.0, 110.0);
|
||||
// Steps below, equal to and above the loop's width, with fractions, both ways round.
|
||||
for step in [0.37, 3.0, 9.99, 10.0, 10.5, 23.7, 25.0, 31.3, 99.9] {
|
||||
for forward in [true, false] {
|
||||
let mut v = Voice::new();
|
||||
v.pos = if forward { 102.5 } else { 107.25 };
|
||||
v.forward = forward;
|
||||
v.step = step;
|
||||
let start = v.pos;
|
||||
for n in 1..=40u32 {
|
||||
advance(&mut v, 1000.0, Some((ls, le)), LoopMode::PingPong);
|
||||
let (want, want_forward) = triangle(start, forward, step, n, ls, le);
|
||||
assert!(v.pos >= ls - 1e-9 && v.pos <= le + 1e-9, "step {step} n {n}: {} left the loop", v.pos);
|
||||
// At an exact end the direction is where the position is heading next.
|
||||
let at_end = (want - ls).abs() < 1e-6 || (want - le).abs() < 1e-6;
|
||||
assert!((v.pos - want).abs() < 1e-6, "step {step} {forward} n {n}: at {} want {want}", v.pos);
|
||||
assert!(at_end || v.forward == want_forward, "step {step} {forward} n {n}: direction");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ping_pong_step_below_the_loop_width_moves_as_before() {
|
||||
let mut v = Voice::new();
|
||||
v.pos = 108.0;
|
||||
v.step = 5.0;
|
||||
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
|
||||
assert_eq!((v.pos, v.forward), (107.0, false), "one reflection off the end");
|
||||
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
|
||||
assert_eq!((v.pos, v.forward), (102.0, false));
|
||||
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
|
||||
assert_eq!((v.pos, v.forward), (103.0, true), "and one off the start");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_too_short_to_loop_plays_once() {
|
||||
let mut s = sampler(mono(SR, vec![0.5; 1000]));
|
||||
|
||||
@@ -13,8 +13,15 @@ pub(crate) enum EventKind {
|
||||
On(u8, u8),
|
||||
Off(u8),
|
||||
AllOff,
|
||||
Sustain(bool),
|
||||
/// Pitch bend as a frequency ratio.
|
||||
Bend(f32),
|
||||
ResetControllers,
|
||||
}
|
||||
|
||||
/// Pitch bend range, semitones each way.
|
||||
const BEND_RANGE: f32 = 2.0;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct Event {
|
||||
pub(crate) offset: usize,
|
||||
@@ -212,6 +219,8 @@ struct Voice {
|
||||
svf: Svf,
|
||||
age: u64,
|
||||
held: bool,
|
||||
/// Released while the sustain pedal was down.
|
||||
sustained: bool,
|
||||
}
|
||||
|
||||
impl Voice {
|
||||
@@ -228,6 +237,7 @@ impl Voice {
|
||||
svf: Svf::default(),
|
||||
age: 0,
|
||||
held: false,
|
||||
sustained: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +260,8 @@ pub struct SubtractiveSynth {
|
||||
counter: u64,
|
||||
level: Smoother,
|
||||
cutoff: Smoother,
|
||||
sustain: bool,
|
||||
bend: f32,
|
||||
}
|
||||
|
||||
impl Default for SubtractiveSynth {
|
||||
@@ -277,6 +289,8 @@ impl SubtractiveSynth {
|
||||
counter: 0,
|
||||
level: Smoother::new(0.5),
|
||||
cutoff: Smoother::new(2000.0),
|
||||
sustain: false,
|
||||
bend: 1.0,
|
||||
};
|
||||
s.update();
|
||||
s
|
||||
@@ -337,15 +351,32 @@ impl SubtractiveSynth {
|
||||
v.filt.gate_on();
|
||||
v.age = counter;
|
||||
v.held = true;
|
||||
v.sustained = false;
|
||||
}
|
||||
self.last_freq = Some(freq);
|
||||
}
|
||||
|
||||
fn stop_note(&mut self, note: u8) {
|
||||
let sustain = self.sustain;
|
||||
for v in self.voices.iter_mut().filter(|v| v.held && v.note == note) {
|
||||
v.held = false;
|
||||
v.amp.gate_off();
|
||||
v.filt.gate_off();
|
||||
if sustain {
|
||||
v.sustained = true;
|
||||
} else {
|
||||
v.amp.gate_off();
|
||||
v.filt.gate_off();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_sustain(&mut self, on: bool) {
|
||||
self.sustain = on;
|
||||
if !on {
|
||||
for v in self.voices.iter_mut().filter(|v| v.sustained) {
|
||||
v.sustained = false;
|
||||
v.amp.gate_off();
|
||||
v.filt.gate_off();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,10 +387,17 @@ impl SubtractiveSynth {
|
||||
EventKind::AllOff => {
|
||||
for v in &mut self.voices {
|
||||
v.held = false;
|
||||
v.sustained = false;
|
||||
v.amp.gate_off();
|
||||
v.filt.gate_off();
|
||||
}
|
||||
}
|
||||
EventKind::Sustain(on) => self.set_sustain(on),
|
||||
EventKind::Bend(ratio) => self.bend = ratio,
|
||||
EventKind::ResetControllers => {
|
||||
self.set_sustain(false);
|
||||
self.bend = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,6 +412,7 @@ impl SubtractiveSynth {
|
||||
let env_oct = self.p.v(7) / 100.0 * 6.0;
|
||||
let cutoff = self.cutoff.next();
|
||||
let nyq = sr * 0.45;
|
||||
let bend = self.bend;
|
||||
let mut out = 0.0;
|
||||
for v in &mut self.voices {
|
||||
if !v.active() {
|
||||
@@ -386,8 +425,8 @@ impl SubtractiveSynth {
|
||||
}
|
||||
let a = v.amp.next(&self.amp_rates);
|
||||
let fe = v.filt.next(&self.filt_rates);
|
||||
let s1 = v.osc1.next(w1, v.freq / sr);
|
||||
let s2 = v.osc2.next(w2, v.freq * ratio2 / sr);
|
||||
let s1 = v.osc1.next(w1, v.freq * bend / sr);
|
||||
let s2 = v.osc2.next(w2, v.freq * bend * ratio2 / sr);
|
||||
let raw = s1 * (1.0 - mix) + s2 * mix;
|
||||
let fc = (cutoff * 2f32.powf(env_oct * fe)).clamp(20.0, nyq);
|
||||
let g = (std::f32::consts::PI * fc / sr).tan();
|
||||
@@ -418,6 +457,8 @@ impl Plugin for SubtractiveSynth {
|
||||
self.last_freq = None;
|
||||
self.level.snap();
|
||||
self.cutoff.snap();
|
||||
self.sustain = false;
|
||||
self.bend = 1.0;
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
@@ -467,6 +508,21 @@ impl Plugin for SubtractiveSynth {
|
||||
self.events.push(offset, EventKind::Off(note));
|
||||
}
|
||||
|
||||
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
|
||||
let [status, d1, d2] = msg;
|
||||
let kind = match (status & 0xF0, d1) {
|
||||
(0xB0, 64) => EventKind::Sustain(d2 >= 64),
|
||||
(0xB0, 120 | 123) => EventKind::AllOff,
|
||||
(0xB0, 121) => EventKind::ResetControllers,
|
||||
(0xE0, _) => {
|
||||
let v = (i32::from(d2 & 0x7F) << 7 | i32::from(d1 & 0x7F)) - 8192;
|
||||
EventKind::Bend(2f32.powf(v as f32 / 8192.0 * BEND_RANGE / 12.0))
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
self.events.push(offset, kind);
|
||||
}
|
||||
|
||||
fn all_notes_off(&mut self) {
|
||||
self.events.push(0, EventKind::AllOff);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
mod fuzz;
|
||||
mod offline_meters;
|
||||
mod offline_normalize;
|
||||
mod plugins;
|
||||
mod transients;
|
||||
@@ -65,6 +65,21 @@ fn loudness_full_scale_mono_and_44k() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loudness_stays_bounded_at_low_sample_rates() {
|
||||
// The K-weighting shelf corner (~1.7 kHz) sits at or above Nyquist below ~3.4 kHz; the
|
||||
// filter must stay stable there instead of reading hundreds of LUFS (issue #240).
|
||||
for sr in [1000.0f32, 1200.0, 1600.0, 2000.0, 3000.0, 3400.0, 8000.0, 48_000.0] {
|
||||
let len = sr as usize;
|
||||
let mut m = LoudnessMeter::new(sr, 1);
|
||||
assert_eq!(m.sample_rate(), sr);
|
||||
m.process(&[sine(100.0, 0.5, sr, len)], len);
|
||||
for level in [m.integrated_lufs(), m.momentary_lufs(), m.short_term_lufs()] {
|
||||
assert!(level.is_finite() && (-30.0..=0.0).contains(&level), "{sr}: {level}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loudness_gating_ignores_silence() {
|
||||
let sr = 48_000.0;
|
||||
@@ -155,6 +170,19 @@ fn pitch_shift_preserves_length_and_shifts() {
|
||||
assert!((f - want).abs() < 4.0, "{f} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pitch_shift_reaches_the_ends_of_its_range() {
|
||||
let sr = 48_000.0;
|
||||
let x = sine(440.0, 0.5, sr, 48_000);
|
||||
for st in [40.0f32, 48.0, -40.0, -48.0] {
|
||||
let out = offline::pitch_shift(std::slice::from_ref(&x), st, sr);
|
||||
assert_eq!(out[0].len(), x.len());
|
||||
let f = dominant_hz(&out[0], sr);
|
||||
let want = 440.0 * 2f32.powf(st / 12.0);
|
||||
assert!((f / want - 1.0).abs() < 0.002, "{st} semitones: {f} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_hits_target_peak() {
|
||||
let mut ch = vec![sine(100.0, 0.2, 48_000.0, 4800), sine(100.0, 0.05, 48_000.0, 4800)];
|
||||
@@ -200,13 +228,33 @@ fn non_silent_ranges_finds_bursts() {
|
||||
assert!(offline::non_silent_ranges(&[vec![0.0; 100]], -40.0, 10, 5, 5).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_scan_in_blocks_finds_what_one_pass_finds() {
|
||||
let mut x = vec![0.0f32; 48_000];
|
||||
for (i, v) in sine(500.0, 0.5, 48_000.0, 4800).into_iter().enumerate() {
|
||||
x[10_000 + i] = v;
|
||||
x[30_000 + i] = v;
|
||||
}
|
||||
let whole: Vec<(u64, u64)> =
|
||||
offline::non_silent_ranges(std::slice::from_ref(&x), -40.0, 480, 100, 200).into_iter().map(|(a, b)| (a as u64, b as u64)).collect();
|
||||
let mut blocks = offline::SilenceScan::new(-40.0, 480);
|
||||
x.chunks(1000).for_each(|b| blocks.push(&[b.to_vec()]));
|
||||
assert_eq!(blocks.finish(100, 200), whole);
|
||||
// Skipping the silence between the bursts counts its length like scanning it.
|
||||
let mut skipped = offline::SilenceScan::new(-40.0, 480);
|
||||
skipped.push(&[x[..20_000].to_vec()]);
|
||||
skipped.skip(10_000);
|
||||
skipped.push(&[x[30_000..].to_vec()]);
|
||||
assert_eq!(skipped.finish(100, 200), whole);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transients_detected_near_onsets() {
|
||||
let sr = 48_000.0;
|
||||
let mut x = vec![0.0f32; 48_000 * 2];
|
||||
let onsets = [9_600usize, 33_600, 60_000, 81_000];
|
||||
for &o in &onsets {
|
||||
for i in 0..6000 {
|
||||
for i in 0..15000 {
|
||||
x[o + i] += 0.8 * (-(i as f32) / 1500.0).exp() * (TAU * 180.0 * i as f32 / sr).sin();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use soundcraft_dsp::offline::{normalize, peak};
|
||||
|
||||
#[test]
|
||||
fn normalize_subnormal_peak_stays_finite() {
|
||||
for per_channel in [false, true] {
|
||||
let mut c = vec![vec![1e-44f32, -1e-44]];
|
||||
normalize(&mut c, 0.0, per_channel);
|
||||
assert!(c[0].iter().all(|s| s.is_finite()), "{c:?}");
|
||||
assert!((peak(&c) - 1.0).abs() < 1e-3, "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_silent_and_normal_channels() {
|
||||
let mut c = vec![vec![0.0f32; 4], vec![0.25, -0.5]];
|
||||
normalize(&mut c, 0.0, true);
|
||||
assert_eq!(c[0], vec![0.0; 4]);
|
||||
assert!((peak(&c[1..]) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
use soundcraft_dsp::{Plugin, Unit, create, db_to_gain, gain_to_db, plugin_info, plugins};
|
||||
use soundcraft_dsp::{Plugin, Unit, create, db_to_gain, gain_to_db, is_generator, plugin_info, plugins};
|
||||
|
||||
const SR: f32 = 48_000.0;
|
||||
const BLOCK: usize = 512;
|
||||
@@ -45,7 +45,7 @@ fn prepared(id: &str) -> Box<dyn Plugin> {
|
||||
}
|
||||
|
||||
fn is_source(id: &str) -> bool {
|
||||
plugin_info(id).unwrap().is_instrument || id == "signal_generator"
|
||||
plugin_info(id).unwrap().is_instrument || is_generator(id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -199,6 +199,44 @@ fn eq_magnitude_at_center_matches_gain() {
|
||||
assert!(r[0] < -40.0 && (r[1] + 3.0).abs() < 0.2 && r[2].abs() < 0.1, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eq_switches_and_choices_apply_to_a_running_eq() {
|
||||
// (plugin, settings, the one switch or choice changed while running, test tone)
|
||||
let cases: [(&str, &[(&str, f32)], (&str, f32), f32); 7] = [
|
||||
("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 3.0)], ("hpf_on", 1.0), 1000.0),
|
||||
("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 3.0), ("hpf_on", 1.0)], ("hpf_on", 0.0), 1000.0),
|
||||
("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 0.0), ("hpf_on", 1.0)], ("hpf_slope", 3.0), 1000.0),
|
||||
("eq_7band", &[("lpf_freq", 2000.0), ("lpf_slope", 3.0)], ("lpf_on", 1.0), 4000.0),
|
||||
("eq_7band", &[("lpf_freq", 2000.0), ("lpf_slope", 0.0), ("lpf_on", 1.0)], ("lpf_slope", 3.0), 4000.0),
|
||||
("eq_1band", &[("freq", 1000.0)], ("type", 4.0), 10_000.0),
|
||||
("channel_strip", &[("hpf_freq", 2000.0)], ("hpf_on", 1.0), 1000.0),
|
||||
];
|
||||
let mut stale = Vec::new();
|
||||
for (id, setup, (key, value), tone) in cases {
|
||||
let x = [sine(tone, 0.5, 24_000)];
|
||||
let level = |p: &mut dyn Plugin| gain_to_db(rms(&run(p, &x)[0][12_000..]));
|
||||
let build = |settings: &[(&str, f32)]| {
|
||||
let mut p = create(id).unwrap();
|
||||
p.prepare(SR, BLOCK, 1);
|
||||
for &(k, v) in settings {
|
||||
assert!(p.set_param(k, v), "{id} {k}");
|
||||
}
|
||||
p.reset();
|
||||
p
|
||||
};
|
||||
let mut live = build(setup);
|
||||
let before = level(live.as_mut());
|
||||
assert!(live.set_param(key, value));
|
||||
let after = level(live.as_mut());
|
||||
let want = level(build(&[setup, &[(key, value)]].concat()).as_mut());
|
||||
assert!((before - want).abs() > 3.0, "{id} {key}={value}: the tone must tell the settings apart");
|
||||
if (after - want).abs() >= 0.1 {
|
||||
stale.push(format!("{id} {key}={value}: live {after:.2} dB, fresh {want:.2} dB, before {before:.2} dB"));
|
||||
}
|
||||
}
|
||||
assert!(stale.is_empty(), "{stale:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressor_steady_state_gain_reduction() {
|
||||
let mut p = prepared("compressor");
|
||||
@@ -329,6 +367,23 @@ fn delay_repeats_at_time() {
|
||||
assert!((pos as i64 - 4800).abs() <= 2, "echo at {pos}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modulation_tail_covers_feedback_echoes() {
|
||||
for (id, delay, feedback) in [("chorus", 30.0, 90.0), ("flanger", 10.0, 95.0), ("flanger", 10.0, -95.0)] {
|
||||
let mut p = prepared(id);
|
||||
for (param, v) in [("delay", delay), ("feedback", feedback), ("depth", 0.0), ("mix", 100.0)] {
|
||||
assert!(p.set_param(param, v));
|
||||
}
|
||||
p.reset();
|
||||
let mut x = vec![0.0f32; 48_000 * 5];
|
||||
x[0] = 0.5;
|
||||
let out = run(p.as_mut(), &[x]);
|
||||
let floor = peak(&out[0]) * 1e-3;
|
||||
let last = out[0].iter().rposition(|v| v.abs() > floor).unwrap();
|
||||
assert!(last < p.tail_samples(), "{id} feedback {feedback}: echo at {last}, tail {}", p.tail_samples());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_and_invert() {
|
||||
let mut p = prepared("invert");
|
||||
@@ -394,6 +449,34 @@ fn synth_note_on_sounds_and_note_off_decays() {
|
||||
assert!(peak(&out[0][72_000..]) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synth_follows_the_sustain_pedal_and_pitch_bend() {
|
||||
let mut p = prepared("subtractive_synth");
|
||||
p.midi(0, [0xB0, 64, 127]);
|
||||
p.note_on(0, 60, 100);
|
||||
run(p.as_mut(), &vec![vec![0.0; 4800]; 2]);
|
||||
p.note_off(0, 60);
|
||||
let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]);
|
||||
assert!(rms(&out[0][72_000..]) > 0.01, "the pedal holds the note");
|
||||
p.midi(0, [0xB0, 64, 0]);
|
||||
let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]);
|
||||
assert!(peak(&out[0][72_000..]) < 1e-6, "lifting the pedal releases it");
|
||||
|
||||
let rises = |x: &[f32]| x.windows(2).filter(|w| w[0] < 0.0 && w[1] >= 0.0).count() as f64;
|
||||
let mut p = prepared("subtractive_synth");
|
||||
p.set_param("osc1_wave", 2.0);
|
||||
p.set_param("osc_mix", 0.0);
|
||||
p.note_on(0, 69, 100);
|
||||
let plain = run(p.as_mut(), &[vec![0.0; 48_000]]);
|
||||
p.midi(0, [0xE0, 0x7F, 0x7F]);
|
||||
let bent = run(p.as_mut(), &[vec![0.0; 48_000]]);
|
||||
let ratio = rises(&bent[0][4800..]) / rises(&plain[0][4800..]);
|
||||
assert!((ratio - 2f64.powf(2.0 / 12.0)).abs() < 0.01, "{ratio}");
|
||||
p.midi(0, [0xB0, 121, 0]);
|
||||
let reset = run(p.as_mut(), &[vec![0.0; 48_000]]);
|
||||
assert!((rises(&reset[0][4800..]) / rises(&plain[0][4800..]) - 1.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synth_event_offsets_are_sample_accurate() {
|
||||
let mut p = prepared("subtractive_synth");
|
||||
@@ -0,0 +1,149 @@
|
||||
use soundcraft_dsp::offline::detect_transients;
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
const SR: f32 = 48_000.0;
|
||||
|
||||
fn at(secs: f32) -> usize {
|
||||
(secs * SR) as usize
|
||||
}
|
||||
|
||||
fn mix(len: f32, parts: &[(f32, Vec<f32>)]) -> Vec<f32> {
|
||||
let mut x = vec![0.0; at(len)];
|
||||
for (start, sig) in parts {
|
||||
let fade = at(0.03).min(sig.len());
|
||||
for (i, v) in sig.iter().enumerate() {
|
||||
let left = sig.len() - i;
|
||||
let g = if left < fade { 0.5 - 0.5 * (std::f32::consts::PI * left as f32 / fade as f32).cos() } else { 1.0 };
|
||||
if let Some(o) = x.get_mut(at(*start) + i) {
|
||||
*o += v * g;
|
||||
}
|
||||
}
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
fn noise(seed: &mut u32) -> f32 {
|
||||
*seed ^= *seed << 13;
|
||||
*seed ^= *seed >> 17;
|
||||
*seed ^= *seed << 5;
|
||||
*seed as f32 / u32::MAX as f32 * 2.0 - 1.0
|
||||
}
|
||||
|
||||
fn hit(level: f32, decay: f32, seed: &mut u32) -> Vec<f32> {
|
||||
let mut prev = 0.0;
|
||||
(0..at(decay * 6.0))
|
||||
.map(|i| {
|
||||
let w = noise(seed);
|
||||
let hp = w - prev;
|
||||
prev = w;
|
||||
level * hp * (-(i as f32) / SR / decay).exp()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn note(f: f32, level: f32, secs: f32) -> Vec<f32> {
|
||||
(0..at(secs))
|
||||
.map(|i| {
|
||||
let t = i as f32 / SR;
|
||||
let s: f32 = (1..=8).map(|k| (TAU * f * k as f32 * t).sin() * (-t * (0.6 + 0.4 * k as f32)).exp() / k as f32).sum();
|
||||
level * s * (t / 0.002).min(1.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn drums(len: f32) -> (Vec<f32>, Vec<usize>) {
|
||||
let mut seed = 7;
|
||||
let mut parts = Vec::new();
|
||||
let mut onsets = Vec::new();
|
||||
let mut t = 0.3;
|
||||
while t < len - 0.5 {
|
||||
parts.push((t, hit(0.9, 0.04, &mut seed)));
|
||||
onsets.push(at(t));
|
||||
for k in 1..4 {
|
||||
parts.push((t + 0.14 * k as f32, hit(0.09, 0.03, &mut seed)));
|
||||
onsets.push(at(t + 0.14 * k as f32));
|
||||
}
|
||||
t += 0.56;
|
||||
}
|
||||
(mix(len, &parts), onsets)
|
||||
}
|
||||
|
||||
fn near(found: &[usize], onsets: &[usize], tolerance: usize) -> bool {
|
||||
found.len() == onsets.len() && found.iter().zip(onsets).all(|(f, o)| f.abs_diff(*o) <= tolerance)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_starting_over_a_ringing_note_are_placed_on_their_attacks() {
|
||||
let starts = [0.3, 0.72, 1.14, 1.56, 1.98, 2.4];
|
||||
let freqs = [196.0, 261.6, 196.0, 329.6, 246.9, 392.0];
|
||||
let parts: Vec<(f32, Vec<f32>)> =
|
||||
starts.iter().zip(freqs).enumerate().map(|(k, (s, f))| (*s, note(f, 0.2 + 0.1 * (k % 3) as f32, 1.1))).collect();
|
||||
let found = detect_transients(&[mix(4.0, &parts)], SR, 0.5);
|
||||
let onsets: Vec<usize> = starts.iter().map(|s| at(*s)).collect();
|
||||
assert!(near(&found, &onsets, at(0.003)), "{found:?} vs {onsets:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quieter_hits_between_loud_ones_count() {
|
||||
let (x, onsets) = drums(6.0);
|
||||
let found = detect_transients(&[x], SR, 0.5);
|
||||
assert!(near(&found, &onsets, at(0.001)), "{found:?} vs {onsets:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_chord_with_vibrato_and_chorus_has_no_transients() {
|
||||
let mut ph = [0.0f32; 6];
|
||||
let x: Vec<f32> = (0..at(6.0))
|
||||
.map(|i| {
|
||||
let t = i as f32 / SR;
|
||||
let mut s = 0.0;
|
||||
for (j, f) in [220.0f32, 277.2, 329.6].iter().enumerate() {
|
||||
for (d, sign) in [(0, 1.0f32), (1, -1.0)] {
|
||||
let p = &mut ph[j * 2 + d];
|
||||
*p += TAU * f * (1.0 + sign * 0.004 * (TAU * (0.3 + 0.07 * j as f32) * t).sin()) / SR;
|
||||
s += (1..=6).map(|k| (*p * k as f32).sin() / k as f32).sum::<f32>();
|
||||
}
|
||||
}
|
||||
0.04 * s * (t / 0.4).min(1.0) * (1.0 + 0.3 * (TAU * 4.0 * t).sin())
|
||||
})
|
||||
.collect();
|
||||
let found: Vec<usize> = detect_transients(&[x], SR, 0.5).into_iter().filter(|p| *p > at(0.5)).collect();
|
||||
assert!(found.is_empty(), "{found:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transients_do_not_depend_on_where_the_analysed_audio_starts() {
|
||||
let (mut x, _) = drums(8.0);
|
||||
let from = at(3.3);
|
||||
x.iter_mut().skip(from - at(0.4)).for_each(|v| *v *= 0.05);
|
||||
let whole = detect_transients(&[x.clone()], SR, 0.5);
|
||||
let part: Vec<usize> = detect_transients(&[x.get(from..).unwrap_or_default().to_vec()], SR, 0.5).into_iter().map(|p| p + from).collect();
|
||||
let settled = |v: &[usize]| v.iter().copied().filter(|p| *p > from + at(0.25)).collect::<Vec<_>>();
|
||||
assert!(!settled(&whole).is_empty());
|
||||
assert_eq!(settled(&whole), settled(&part), "{whole:?} vs {part:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fast_release_is_not_a_transient() {
|
||||
let starts: Vec<f32> = (0..8).map(|k| 0.3 + k as f32 * 0.5).collect();
|
||||
let note: Vec<f32> = (0..at(0.4))
|
||||
.map(|i| {
|
||||
let t = i as f32 / SR;
|
||||
let level = if t < 0.15 { (t / 0.01).min(1.0) } else { 0.001f32.powf((t - 0.15) / 0.04) };
|
||||
let saw: f32 = (1..=20).map(|k| (TAU * 659.0 * k as f32 * t).sin() / k as f32).sum();
|
||||
0.2 * level * saw
|
||||
})
|
||||
.collect();
|
||||
let parts: Vec<(f32, Vec<f32>)> = starts.iter().map(|s| (*s, note.clone())).collect();
|
||||
let found = detect_transients(&[mix(4.5, &parts)], SR, 0.5);
|
||||
let onsets: Vec<usize> = starts.iter().map(|s| at(*s)).collect();
|
||||
assert!(near(&found, &onsets, at(0.002)), "{found:?} vs {onsets:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channels_that_cancel_when_mixed_still_show_their_transients() {
|
||||
let (left, onsets) = drums(4.0);
|
||||
let right: Vec<f32> = left.iter().map(|v| -v).collect();
|
||||
let found = detect_transients(&[left, right], SR, 0.5);
|
||||
assert!(near(&found, &onsets, at(0.001)), "{found:?} vs {onsets:?}");
|
||||
}
|
||||
@@ -23,6 +23,7 @@ serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
zip.workspace = true
|
||||
craft-docpath.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
//! Crash-recovery copies of open sessions, one folder each, written in the background, and the
|
||||
//! recovery of the ones left behind with unsaved work.
|
||||
|
||||
use crate::io::{Recording, open_session_linked, sanitize_name};
|
||||
use crate::{Engine, EngineError, Result};
|
||||
use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat};
|
||||
use soundcraft_model::{Session, SourceAudio, SourceId};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
||||
use std::sync::{Arc, Mutex, PoisonError, Weak};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
const SESSION: &str = "session.scraft";
|
||||
const ORIGIN: &str = "origin";
|
||||
const LOCK: &str = "lock";
|
||||
/// The plan of a recording still running when the folder was written.
|
||||
const RECORDING: &str = "recording.json";
|
||||
/// While changes keep coming, the newest is written at most this often.
|
||||
const INTERVAL: Duration = Duration::from_secs(1);
|
||||
const STOPPED: &str = "the autosave thread stopped";
|
||||
|
||||
/// Writes autosaves on a thread of its own, so the UI never waits on the disk.
|
||||
pub struct Autosaver {
|
||||
jobs: Option<Sender<Job>>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
failure: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
enum Job {
|
||||
Write(Arc<Session>, Option<String>, Option<Recording>),
|
||||
/// Done with the session's folder: keep it for the next launch, or delete it.
|
||||
Close(bool),
|
||||
Flush(Sender<std::result::Result<(), String>>),
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
/// A writer for autosave folders under `root`.
|
||||
pub fn new(root: PathBuf) -> Autosaver {
|
||||
let (tx, rx) = channel();
|
||||
let failure = Arc::new(Mutex::new(None));
|
||||
let writer = Writer { root, failure: Arc::clone(&failure), folder: None, pending: None, last: None, written: Ok(()) };
|
||||
let thread = std::thread::Builder::new().name("soundcraft-autosave".into()).spawn(move || writer.run(&rx));
|
||||
if let Err(e) = &thread {
|
||||
report(&failure, format!("{STOPPED}: {e}"));
|
||||
}
|
||||
Autosaver { jobs: Some(tx), thread: thread.ok(), failure }
|
||||
}
|
||||
|
||||
/// Autosave `session`, opened from the session file `origin`, soon, with the plan of the
|
||||
/// recording running in it.
|
||||
pub fn write(&self, session: Arc<Session>, origin: Option<String>, recording: Option<Recording>) {
|
||||
self.send(Job::Write(session, origin, recording));
|
||||
}
|
||||
|
||||
/// Done with the open session's folder: keep it for the next launch, or delete it.
|
||||
pub fn close(&self, keep: bool) {
|
||||
self.send(Job::Close(keep));
|
||||
}
|
||||
|
||||
/// Wait until everything handed over so far is done. A failure returned here isn't reported
|
||||
/// again by `failure`.
|
||||
pub fn flush(&self) -> Result<()> {
|
||||
let (tx, rx) = channel();
|
||||
self.send(Job::Flush(tx));
|
||||
let done = rx.recv().unwrap_or_else(|_| Err(STOPPED.into()));
|
||||
if done.is_err() {
|
||||
self.failure();
|
||||
}
|
||||
done.map_err(EngineError::Io)
|
||||
}
|
||||
|
||||
/// The newest failure since the last call.
|
||||
pub fn failure(&self) -> Option<String> {
|
||||
self.failure.lock().unwrap_or_else(PoisonError::into_inner).take()
|
||||
}
|
||||
|
||||
fn send(&self, job: Job) {
|
||||
if self.jobs.as_ref().is_none_or(|tx| tx.send(job).is_err()) {
|
||||
report(&self.failure, STOPPED.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Autosaver {
|
||||
/// What was handed over is written, and an open folder stays for the next launch.
|
||||
fn drop(&mut self) {
|
||||
drop(self.jobs.take());
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The writer thread.
|
||||
struct Writer {
|
||||
root: PathBuf,
|
||||
failure: Arc<Mutex<Option<String>>>,
|
||||
/// The open session's folder.
|
||||
folder: Option<Autosave>,
|
||||
/// The newest session not written yet.
|
||||
pending: Option<(Arc<Session>, Option<String>, Option<Recording>)>,
|
||||
last: Option<Instant>,
|
||||
/// How the newest write into the open folder went, for `Flush`.
|
||||
written: std::result::Result<(), String>,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
fn run(mut self, jobs: &Receiver<Job>) {
|
||||
loop {
|
||||
let wait = match (&self.pending, self.last) {
|
||||
(None, _) => Duration::MAX,
|
||||
(Some(_), last) => last.map_or(Duration::ZERO, |t| INTERVAL.saturating_sub(t.elapsed())),
|
||||
};
|
||||
match jobs.recv_timeout(wait) {
|
||||
Ok(Job::Write(session, origin, recording)) => self.pending = Some((session, origin, recording)),
|
||||
Ok(Job::Flush(reply)) => {
|
||||
self.write_pending();
|
||||
let _ = reply.send(self.written.clone());
|
||||
}
|
||||
Ok(Job::Close(keep)) => {
|
||||
if keep {
|
||||
self.write_pending();
|
||||
}
|
||||
self.pending = None;
|
||||
self.written = Ok(());
|
||||
if let Some(a) = self.folder.take()
|
||||
&& !keep
|
||||
{
|
||||
a.discard();
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => self.write_pending(),
|
||||
Err(RecvTimeoutError::Disconnected) => {
|
||||
self.write_pending();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_pending(&mut self) {
|
||||
let Some((session, origin, recording)) = self.pending.take() else { return };
|
||||
self.last = Some(Instant::now());
|
||||
let written = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<()> {
|
||||
if self.folder.is_none() {
|
||||
self.folder = Some(Autosave::create(&self.root, &session.name)?);
|
||||
}
|
||||
self.folder.as_mut().map_or(Ok(()), |a| a.write(&session, origin.as_deref(), recording.as_ref()))
|
||||
}));
|
||||
self.written = match written {
|
||||
Ok(written) => written.map_err(|e| e.to_string()),
|
||||
Err(_) => Err("internal error (see the log)".into()),
|
||||
};
|
||||
if let Err(e) = &self.written {
|
||||
report(&self.failure, e.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn report(failure: &Mutex<Option<String>>, e: String) {
|
||||
log::warn!("autosave failed: {e}");
|
||||
*failure.lock().unwrap_or_else(PoisonError::into_inner) = Some(e);
|
||||
}
|
||||
|
||||
/// An open session's autosave folder, locked while the session is open so a later launch can tell
|
||||
/// it from one a crash left behind.
|
||||
struct Autosave {
|
||||
dir: PathBuf,
|
||||
lock: Option<std::fs::File>,
|
||||
copies: BTreeMap<SourceId, (Weak<SourceAudio>, String)>,
|
||||
next: u64,
|
||||
/// A recording's plan is in the folder.
|
||||
recording: bool,
|
||||
}
|
||||
|
||||
impl Autosave {
|
||||
/// A new folder under `root` for the session called `name`. Two sessions started within the
|
||||
/// same millisecond get folders of their own, never a shared one.
|
||||
fn create(root: &Path, name: &str) -> Result<Autosave> {
|
||||
let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis();
|
||||
let base = format!("{} {stamp}-{}", sanitize_name(name), std::process::id());
|
||||
std::fs::create_dir_all(root).map_err(|err| io_error(root, err))?;
|
||||
let mut dir = root.join(&base);
|
||||
let mut n = 1;
|
||||
loop {
|
||||
match std::fs::create_dir(&dir) {
|
||||
Ok(()) => break,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
n += 1;
|
||||
dir = root.join(format!("{base}-{n}"));
|
||||
}
|
||||
Err(err) => return Err(io_error(&dir, err)),
|
||||
}
|
||||
}
|
||||
let lock = std::fs::File::create(dir.join(LOCK)).map_err(|err| io_error(&dir, err))?;
|
||||
lock.try_lock().map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?;
|
||||
Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1, recording: false })
|
||||
}
|
||||
|
||||
/// Write `session`, opened from the session file `origin`, and the plan of the `recording`
|
||||
/// running in it. Audio saved with it is referenced where it is; audio that exists only in
|
||||
/// memory is copied, once per recording or render.
|
||||
fn write(&mut self, session: &Session, origin: Option<&str>, recording: Option<&Recording>) -> Result<()> {
|
||||
let base = origin.and_then(|p| Path::new(p).parent()).and_then(|p| std::path::absolute(p).ok());
|
||||
let audio_dir = self.dir.join("Audio Files");
|
||||
let mut s = session.clone();
|
||||
let mut copies = BTreeMap::new();
|
||||
for src in &mut s.sources {
|
||||
let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) };
|
||||
let audio = s.pool.get(src.id);
|
||||
if let Some(f) = &file
|
||||
&& (audio.is_none() || !src.unsaved && f.is_file())
|
||||
{
|
||||
src.path = f.to_string_lossy().into_owned();
|
||||
continue;
|
||||
}
|
||||
let Some(audio) = audio else { continue };
|
||||
let name = match self.copies.remove(&src.id) {
|
||||
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name,
|
||||
_ => {
|
||||
let name = format!("{} {}.wav", sanitize_name(&src.name), self.next);
|
||||
self.next += 1;
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
std::fs::create_dir_all(&audio_dir)
|
||||
.and_then(|()| write_synced(&audio_dir.join(&name), &bytes))
|
||||
.map_err(|err| io_error(&audio_dir, err))?;
|
||||
name
|
||||
}
|
||||
};
|
||||
src.path = audio_dir.join(&name).to_string_lossy().into_owned();
|
||||
src.unsaved = true;
|
||||
copies.insert(src.id, (Arc::downgrade(audio), name));
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
write_atomically(&self.dir.join(ORIGIN), origin.unwrap_or_default().as_bytes())?;
|
||||
write_atomically(&self.dir.join(SESSION), text.as_bytes())?;
|
||||
for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() {
|
||||
let _ = std::fs::remove_file(audio_dir.join(name));
|
||||
}
|
||||
if let Some(r) = recording {
|
||||
let plan = serde_json::to_string(r).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
write_atomically(&self.dir.join(RECORDING), plan.as_bytes())?;
|
||||
} else if self.recording {
|
||||
let _ = std::fs::remove_file(self.dir.join(RECORDING));
|
||||
}
|
||||
self.recording = recording.is_some();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete the folder: the session closed with nothing left to recover.
|
||||
fn discard(mut self) {
|
||||
drop(self.lock.take());
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsaved work left behind in an autosave folder.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Leftover {
|
||||
pub dir: PathBuf,
|
||||
/// The session's name.
|
||||
pub name: String,
|
||||
/// The session file it was autosaved from, if it had one.
|
||||
pub origin: Option<String>,
|
||||
pub saved: SystemTime,
|
||||
/// A recording was still running in it.
|
||||
pub recording: bool,
|
||||
}
|
||||
|
||||
/// Leftover autosaves under `root`, newest first: folders no open session holds, written after the
|
||||
/// session file they came from or holding a recording. Empty and outdated ones are deleted.
|
||||
pub fn leftovers(root: &Path) -> Vec<Leftover> {
|
||||
let mut out = Vec::new();
|
||||
for dir in std::fs::read_dir(root).into_iter().flatten().flatten().map(|d| d.path()) {
|
||||
let Ok(lock) = std::fs::OpenOptions::new().write(true).open(dir.join(LOCK)) else { continue };
|
||||
if lock.try_lock().is_err() {
|
||||
continue;
|
||||
}
|
||||
let origin = std::fs::read_to_string(dir.join(ORIGIN)).ok().filter(|o| !o.is_empty());
|
||||
let origin_saved = origin.as_ref().and_then(|o| std::fs::metadata(o).and_then(|m| m.modified()).ok());
|
||||
let saved = std::fs::metadata(dir.join(SESSION)).and_then(|m| m.modified());
|
||||
let recording = dir.join(RECORDING).is_file();
|
||||
drop(lock);
|
||||
match saved {
|
||||
Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => {
|
||||
let text = std::fs::read_to_string(dir.join(SESSION)).unwrap_or_default();
|
||||
let named = serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|v| v.get("name")?.as_str().map(str::to_string));
|
||||
let name = named.unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned());
|
||||
out.push(Leftover { dir, name, origin, saved, recording });
|
||||
}
|
||||
_ => {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|l| std::cmp::Reverse(l.saved));
|
||||
out
|
||||
}
|
||||
|
||||
/// Open `left` as an unsaved version of the session it came from, so Save writes it back there,
|
||||
/// with the takes of a recording that never finished placed as Stop would have. Returns the
|
||||
/// media that couldn't be loaded.
|
||||
pub fn recover(e: &mut Engine, left: &Leftover) -> Result<Vec<String>> {
|
||||
let mut r = Engine::default();
|
||||
let session = left.dir.join(SESSION);
|
||||
let links = left.origin.as_deref().map_or(session.as_path(), Path::new);
|
||||
let mut missing = open_session_linked(&mut r, &session.to_string_lossy(), links)?;
|
||||
let mut s = r.session().clone();
|
||||
let unfinished = unfinished_takes(left, &s, &mut missing);
|
||||
if let Some(home) = left.origin.as_deref().and_then(|o| Path::new(o).parent()) {
|
||||
for src in s.sources.iter_mut().filter(|src| !src.unsaved) {
|
||||
if let Ok(rel) = Path::new(&src.path).strip_prefix(home) {
|
||||
src.path = rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
e.replace_session(Session::default());
|
||||
*e.session_mut() = s;
|
||||
e.path = left.origin.clone();
|
||||
if let Some((plan, takes)) = unfinished {
|
||||
crate::io::place_recording(e, &plan, takes);
|
||||
}
|
||||
Ok(missing)
|
||||
}
|
||||
|
||||
/// The takes of the recording `left` holds the plan of, repaired, minus those already in `s`.
|
||||
/// A take the plan names that isn't a take file SoundCraft recorded, in a folder it records into,
|
||||
/// is left untouched and added to `skipped`.
|
||||
fn unfinished_takes(left: &Leftover, s: &Session, skipped: &mut Vec<String>) -> Option<(Recording, Vec<(soundcraft_model::TrackId, PathBuf)>)> {
|
||||
let plan: Recording = serde_json::from_str(&std::fs::read_to_string(left.dir.join(RECORDING)).ok()?).ok()?;
|
||||
let known: Vec<PathBuf> =
|
||||
s.sources.iter().filter(|src| s.pool.get(src.id).is_some()).filter_map(|src| std::fs::canonicalize(&src.path).ok()).collect();
|
||||
let folders = take_folders(left);
|
||||
let mut takes = Vec::new();
|
||||
for t in &plan.takes {
|
||||
if std::fs::canonicalize(&t.path).is_ok_and(|p| known.contains(&p)) {
|
||||
continue;
|
||||
}
|
||||
match repair_take(&t.path, &folders, t.channels, plan.rate) {
|
||||
Ok(frames) if frames > 0 => takes.push((t.track, t.path.clone())),
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
log::warn!("recovery skipped the take {}: {err}", t.path.display());
|
||||
skipped.push(format!("{}: {err}", t.path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((plan, takes))
|
||||
}
|
||||
|
||||
/// The folders a recording in `left`'s session writes its takes into: the `Audio Files` next to
|
||||
/// the session file, or, never saved, the one in its folder under the autosave root.
|
||||
fn take_folders(left: &Leftover) -> Vec<PathBuf> {
|
||||
let saved = left.origin.as_deref().and_then(|o| Path::new(o).parent()).map(|d| d.join("Audio Files"));
|
||||
let unsaved = left.dir.parent().map(|root| root.join(sanitize_name(&left.name)).join("Audio Files"));
|
||||
saved.into_iter().chain(unsaved).filter_map(|d| std::fs::canonicalize(d).ok()).collect()
|
||||
}
|
||||
|
||||
/// Make a take file's header cover all the audio in it; returns its frames. The recorder
|
||||
/// rewrites the header only every second, so a crash leaves up to a second uncovered. Only a
|
||||
/// regular file in one of `folders` that starts with the header the recorder writes is touched.
|
||||
fn repair_take(path: &Path, folders: &[PathBuf], channels: usize, rate: u32) -> std::io::Result<u64> {
|
||||
let refuse = |why: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, why.to_string());
|
||||
if !std::fs::symlink_metadata(path)?.file_type().is_file() {
|
||||
return Err(refuse("not a regular file"));
|
||||
}
|
||||
let folder = path.parent().and_then(|d| std::fs::canonicalize(d).ok());
|
||||
if !folder.is_some_and(|d| folders.contains(&d)) {
|
||||
return Err(refuse("not in a folder SoundCraft records into"));
|
||||
}
|
||||
let header = |frames| soundcraft_audio_io::wav_float_header(channels, rate, frames).map_err(std::io::Error::other);
|
||||
let expected = header(0)?;
|
||||
let mut f = std::fs::OpenOptions::new().read(true).write(true).open(path)?;
|
||||
let mut head = vec![0u8; expected.len()];
|
||||
if std::io::Read::read_exact(&mut f, &mut head).is_err() || !is_take_header(&head, &expected) {
|
||||
return Err(refuse("not a take SoundCraft recorded"));
|
||||
}
|
||||
let audio = f.metadata()?.len().saturating_sub(expected.len() as u64);
|
||||
let frames = audio / (channels.max(1) as u64 * 4);
|
||||
std::io::Seek::rewind(&mut f)?;
|
||||
f.write_all(&header(frames)?)?;
|
||||
f.sync_all()?;
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
/// `head` is a take header like `expected` (the recorder's header for no frames yet): RIFF or
|
||||
/// RF64 WAVE, the same format chunk at the same place, and the data chunk right after it all.
|
||||
/// Only the sizes and frame counts may differ.
|
||||
fn is_take_header(head: &[u8], expected: &[u8]) -> bool {
|
||||
let magic = matches!(head.get(..4), Some(b"RIFF" | b"RF64")) && head.get(8..12) == Some(b"WAVE");
|
||||
let fmt = expected.windows(4).position(|w| w == b"fmt ");
|
||||
let fmt_len = fmt.and_then(|at| expected.get(at + 4..at + 8)).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
|
||||
let same_fmt = match (fmt, fmt_len) {
|
||||
(Some(at), Some(n)) => {
|
||||
let end = at.saturating_add(8).saturating_add(n as usize);
|
||||
expected.get(at..end).is_some_and(|chunk| head.get(at..end) == Some(chunk))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let data = expected.len().checked_sub(8).and_then(|at| head.get(at..at + 4)) == Some(b"data");
|
||||
magic && same_fmt && data
|
||||
}
|
||||
|
||||
/// Delete a leftover autosave.
|
||||
pub fn discard(left: &Leftover) {
|
||||
let _ = std::fs::remove_dir_all(&left.dir);
|
||||
}
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let mut f = std::fs::File::create(path)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
fn write_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
let tmp = path.with_extension("tmp");
|
||||
write_synced(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|err| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
io_error(path, err)
|
||||
})
|
||||
}
|
||||
|
||||
fn io_error(path: &Path, err: std::io::Error) -> EngineError {
|
||||
EngineError::Io(format!("{}: {err}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::io::open_session;
|
||||
use serde_json::json;
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
|
||||
|
||||
fn folder(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
/// Moves a saved session file's mtime a second into the past. Filesystems with coarse
|
||||
/// timestamps (tmpfs) can give it and an autosave written right after it the same mtime,
|
||||
/// and `leftovers` only offers autosaves strictly newer than the file they came from.
|
||||
fn backdate(path: &Path) {
|
||||
let then = SystemTime::now() - Duration::from_secs(1);
|
||||
std::fs::File::options().write(true).open(path).unwrap().set_modified(then).unwrap();
|
||||
}
|
||||
|
||||
fn record(e: &mut Engine, level: f32) {
|
||||
let before = e.session_arc();
|
||||
let s = e.session_mut();
|
||||
let t = match s.tracks.first() {
|
||||
Some(t) => t.id,
|
||||
None => s.add_track(TrackKind::Audio, ChannelFormat::Mono, None),
|
||||
};
|
||||
let src =
|
||||
crate::io::add_source(s, "Audio 1_01", AudioBuffer { sample_rate: 48_000, channels: vec![vec![level; 4_800]] }, None, FileFormat::Wav);
|
||||
let id = s.new_clip_id();
|
||||
crate::edit::place_clip(s, t, Clip::audio(id, "Audio 1_01", src, 0, 0, 4_800));
|
||||
e.push_undo("Record", before);
|
||||
}
|
||||
|
||||
fn levels(e: &Engine) -> Vec<f32> {
|
||||
let s = e.session();
|
||||
s.sources.iter().map(|src| s.pool.get(src.id).map_or(f32::NAN, |a| a.buffer.channels[0][0])).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_audio_is_referenced_and_takes_are_copied_once() {
|
||||
let dir = folder("autosave-copies");
|
||||
let path = dir.join("Song/Song.scraft");
|
||||
let mut e = Engine::default();
|
||||
record(&mut e, 0.5);
|
||||
crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap();
|
||||
backdate(&path);
|
||||
record(&mut e, -0.25);
|
||||
let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap();
|
||||
a.write(e.session(), e.path.as_deref(), None).unwrap();
|
||||
a.write(e.session(), e.path.as_deref(), None).unwrap();
|
||||
assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1);
|
||||
drop(a);
|
||||
let left = leftovers(&dir.join("Autosave"));
|
||||
let mut r = Engine::default();
|
||||
assert_eq!(recover(&mut r, &left[0]).unwrap(), Vec::<String>::new());
|
||||
let paths: Vec<_> = r.session().sources.iter().map(|s| (s.path.starts_with("Audio Files/"), s.unsaved)).collect();
|
||||
assert_eq!(paths, [(true, false), (false, true)]);
|
||||
assert_eq!(levels(&r), [0.5, -0.25]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_take_recorded_after_undo_gets_its_own_copy() {
|
||||
let dir = folder("autosave-undo");
|
||||
let mut e = Engine::default();
|
||||
record(&mut e, 0.5);
|
||||
let mut a = Autosave::create(&dir, "Untitled").unwrap();
|
||||
a.write(e.session(), None, None).unwrap();
|
||||
e.execute("edit.undo", &json!({})).unwrap();
|
||||
record(&mut e, -0.25);
|
||||
a.write(e.session(), None, None).unwrap();
|
||||
drop(a);
|
||||
let mut r = Engine::default();
|
||||
recover(&mut r, &leftovers(&dir)[0]).unwrap();
|
||||
assert_eq!(levels(&r), [-0.25]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_writer_saves_the_newest_change_and_drops_the_folder_when_done() {
|
||||
let root = folder("autosave-writer");
|
||||
let w = Autosaver::new(root.clone());
|
||||
let mut e = Engine::default();
|
||||
for _ in 0..5 {
|
||||
record(&mut e, 0.5);
|
||||
w.write(e.session_arc(), None, None);
|
||||
}
|
||||
w.flush().unwrap();
|
||||
let file = std::fs::read_dir(&root).unwrap().flatten().map(|d| d.path().join(SESSION)).collect::<Vec<_>>();
|
||||
let mut r = Engine::default();
|
||||
open_session(&mut r, &file[0].to_string_lossy()).unwrap();
|
||||
assert_eq!((file.len(), r.session().sources.len()), (1, 5));
|
||||
w.close(false);
|
||||
w.flush().unwrap();
|
||||
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0);
|
||||
record(&mut e, 0.5);
|
||||
w.write(e.session_arc(), None, None);
|
||||
drop(w);
|
||||
let mut r = Engine::default();
|
||||
recover(&mut r, &leftovers(&root)[0]).unwrap();
|
||||
assert_eq!(r.session().sources.len(), 6);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_take_still_recording_at_a_crash_is_placed_on_recovery() {
|
||||
use crate::io::Take;
|
||||
let dir = folder("autosave-take");
|
||||
let root = dir.join("Autosave");
|
||||
let mut e = Engine::default();
|
||||
let track =
|
||||
soundcraft_model::TrackId(e.execute("track.new", &json!({"name": "Vox", "format": "mono"})).unwrap()["tracks"][0].as_u64().unwrap());
|
||||
let take = root.join(sanitize_name(&e.session().name)).join("Audio Files/Vox_01.wav");
|
||||
std::fs::create_dir_all(take.parent().unwrap()).unwrap();
|
||||
let audio: Vec<u8> = (0..480).flat_map(|i| (i as f32 / 1000.0).to_le_bytes()).collect();
|
||||
std::fs::write(&take, [soundcraft_audio_io::wav_float_header(1, 48_000, 100).unwrap(), audio].concat()).unwrap();
|
||||
let plan = Recording { start: 48_000, pass: None, punch: None, rate: 48_000, takes: vec![Take { track, path: take, channels: 1 }] };
|
||||
let clips = |e: &Engine| e.session().track(track).unwrap().clips().iter().map(|c| (c.start, c.length)).collect::<Vec<_>>();
|
||||
let w = Autosaver::new(root.clone());
|
||||
w.write(e.session_arc(), None, Some(plan.clone()));
|
||||
drop(w);
|
||||
let left = leftovers(&root);
|
||||
assert!(left[0].recording);
|
||||
let mut r = Engine::default();
|
||||
recover(&mut r, &left[0]).unwrap();
|
||||
assert_eq!(clips(&r), [(48_000, 480)], "all of the take, its last second included");
|
||||
discard(&left[0]);
|
||||
let w = Autosaver::new(root.clone());
|
||||
w.write(r.session_arc(), None, Some(plan));
|
||||
drop(w);
|
||||
let mut again = Engine::default();
|
||||
recover(&mut again, &leftovers(&root)[0]).unwrap();
|
||||
assert_eq!((clips(&again), again.session().sources.len()), (vec![(48_000, 480)], 1), "placed once");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_never_writes_a_file_that_isnt_a_take_in_a_take_folder() {
|
||||
use crate::io::Take;
|
||||
let dir = folder("autosave-hostile-take");
|
||||
let root = dir.join("Autosave");
|
||||
let mut e = Engine::default();
|
||||
let track =
|
||||
soundcraft_model::TrackId(e.execute("track.new", &json!({"name": "Vox", "format": "mono"})).unwrap()["tracks"][0].as_u64().unwrap());
|
||||
let takes = root.join(sanitize_name(&e.session().name)).join("Audio Files");
|
||||
std::fs::create_dir_all(&takes).unwrap();
|
||||
let wav = [soundcraft_audio_io::wav_float_header(1, 48_000, 0).unwrap(), vec![0; 4_000]].concat();
|
||||
let text = b"not audio at all, but long enough to be mistaken for a header if nobody looked at it ......".repeat(4);
|
||||
let files =
|
||||
[(takes.join("notes.wav"), text.clone()), (dir.join("Elsewhere/Vox_01.wav"), wav.clone()), (takes.join("../Vox_02.wav"), wav.clone())];
|
||||
for (path, bytes) in &files {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
// Only the symlink case below adds to it, and symlinks are a Unix-only part of the test.
|
||||
#[cfg_attr(not(unix), allow(unused_mut))]
|
||||
let mut paths: Vec<PathBuf> = files.iter().map(|(p, _)| p.clone()).collect();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = takes.join("Vox_03.wav");
|
||||
std::os::unix::fs::symlink(&files[1].0, &link).unwrap();
|
||||
paths.push(link);
|
||||
}
|
||||
let plan = Recording {
|
||||
start: 0,
|
||||
pass: None,
|
||||
punch: None,
|
||||
rate: 48_000,
|
||||
takes: paths.iter().map(|p| Take { track, path: p.clone(), channels: 1 }).collect(),
|
||||
};
|
||||
let w = Autosaver::new(root.clone());
|
||||
w.write(e.session_arc(), None, Some(plan));
|
||||
drop(w);
|
||||
let mut r = Engine::default();
|
||||
let skipped = recover(&mut r, &leftovers(&root)[0]).unwrap();
|
||||
assert_eq!(skipped.len(), paths.len(), "{skipped:?}");
|
||||
assert!(r.session().sources.is_empty());
|
||||
for (path, bytes) in &files {
|
||||
assert_eq!(&std::fs::read(path).unwrap(), bytes, "{} untouched", path.display());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_started_together_get_folders_of_their_own() {
|
||||
// #579: the folder name has millisecond resolution, so two sessions created back to back
|
||||
// shared one folder and the second overwrote the first's unsaved work.
|
||||
let dir = folder("autosave-unique");
|
||||
let root = dir.join("Autosave");
|
||||
let made: Vec<Autosave> = (0..20).map(|_| Autosave::create(&root, "Song").unwrap()).collect();
|
||||
let mut dirs: Vec<&PathBuf> = made.iter().map(|a| &a.dir).collect();
|
||||
dirs.sort();
|
||||
dirs.dedup();
|
||||
assert_eq!(dirs.len(), made.len());
|
||||
drop(made);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsaved_work_is_offered_once_its_session_closes() {
|
||||
let dir = folder("autosave-leftovers");
|
||||
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
|
||||
let root = dir.join("Autosave");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut e = Engine::default();
|
||||
crate::io::save_session(&mut e, &path).unwrap();
|
||||
backdate(Path::new(&path));
|
||||
record(&mut e, 0.5);
|
||||
let mut a = Autosave::create(&root, "Song").unwrap();
|
||||
a.write(e.session(), e.path.as_deref(), None).unwrap();
|
||||
assert!(leftovers(&root).is_empty());
|
||||
drop(a);
|
||||
let left = leftovers(&root);
|
||||
assert_eq!((left.len(), left[0].name.as_str(), left[0].origin.as_deref()), (1, "Song", Some(path.as_str())));
|
||||
let mut r = Engine::default();
|
||||
recover(&mut r, &left[0]).unwrap();
|
||||
assert!(r.is_dirty());
|
||||
r.execute("session.save", &json!({})).unwrap();
|
||||
assert!(leftovers(&root).is_empty());
|
||||
assert!(!left[0].dir.exists());
|
||||
let mut reopened = Engine::default();
|
||||
open_session(&mut reopened, &path).unwrap();
|
||||
assert_eq!(levels(&reopened), [0.5]);
|
||||
Autosave::create(&root, "Song").unwrap().discard();
|
||||
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
pub const EXTENSION: &str = "scgrp";
|
||||
const FORMAT: &str = "soundcraft.clipgroup";
|
||||
/// Refuse files that would place absurd numbers of clips.
|
||||
/// Refuse files that would place absurd numbers of clips, tracks or audio files.
|
||||
const MAX_CLIPS: usize = 10_000;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -31,6 +31,9 @@ struct GroupFile {
|
||||
|
||||
/// Write the clips `ids` (grouped by track) to `path`. Returns the number of clips written.
|
||||
pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
|
||||
let p = with_extension(path);
|
||||
// First, so a refused group file leaves nothing behind and is refused whatever else is wrong.
|
||||
crate::file_access::check_write(&p).map_err(EngineError::Io)?;
|
||||
let s = e.session();
|
||||
let mut lanes: Vec<(usize, Vec<Clip>)> = Vec::new();
|
||||
for id in ids {
|
||||
@@ -47,8 +50,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
|
||||
};
|
||||
let n = clips.len();
|
||||
lanes.sort_by_key(|(i, _)| *i);
|
||||
let p = with_extension(path);
|
||||
let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("Clip Group").to_string();
|
||||
let stem = p.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Clip Group").to_string();
|
||||
let audio_dir_name = format!("{} Audio", crate::io::sanitize_name(&stem));
|
||||
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let mut audio = BTreeMap::new();
|
||||
@@ -62,10 +64,10 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
|
||||
&& let (Some(buf), Some(src)) = (s.pool.get(source), s.sources.iter().find(|x| x.id == source))
|
||||
{
|
||||
let rel = format!("{audio_dir_name}/{}_{}.wav", crate::io::sanitize_name(&src.name), source.0);
|
||||
std::fs::create_dir_all(dir.join(&audio_dir_name)).map_err(|err| EngineError::Io(format!("{audio_dir_name}: {err}")))?;
|
||||
crate::io::make_dir(dir.join(&audio_dir_name))?;
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&buf.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
std::fs::write(dir.join(&rel), bytes).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
|
||||
crate::io::write_path(dir.join(&rel), bytes)?;
|
||||
slot.insert(rel);
|
||||
}
|
||||
lane.push(c);
|
||||
@@ -74,43 +76,68 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
|
||||
}
|
||||
let file = GroupFile { format: FORMAT.into(), version: 1, sample_rate: s.sample_rate.hz(), length: end - start, lanes: out_lanes, audio };
|
||||
let text = serde_json::to_string_pretty(&file).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
std::fs::write(&p, text).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?;
|
||||
crate::io::write_path(&p, text)?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn decode_group_audio(
|
||||
audio_entries: &BTreeMap<u64, String>,
|
||||
dir: &Path,
|
||||
rate: u32,
|
||||
cmd_id: &str,
|
||||
) -> Result<(Vec<(String, soundcraft_audio_io::AudioBuffer)>, BTreeMap<u64, usize>)> {
|
||||
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
|
||||
for (old, rel) in audio_entries {
|
||||
let full = dir.join(rel);
|
||||
if !full.starts_with(dir) || rel.contains("..") {
|
||||
return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
|
||||
}
|
||||
let key = crate::io::file_key(&full);
|
||||
let n = match index.get(&key) {
|
||||
Some(n) => *n,
|
||||
None => {
|
||||
let bytes = crate::io::read_file(&full)?;
|
||||
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
|
||||
let buf = crate::io::at_rate(buf, rate, crate::io::MAX_IMPORT_SAMPLES)
|
||||
.ok_or_else(|| EngineError::Io(format!("{rel}: too large to import")))?;
|
||||
decoded.push((rel.clone(), buf));
|
||||
index.insert(key, decoded.len() - 1);
|
||||
decoded.len() - 1
|
||||
}
|
||||
};
|
||||
file_of.insert(*old, n);
|
||||
}
|
||||
Ok((decoded, file_of))
|
||||
}
|
||||
|
||||
/// Import a clip group file at `at`, one lane per track starting at `first` (new audio tracks
|
||||
/// are made when the session runs out). Returns the ids of the placed clips.
|
||||
pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -> Result<Vec<ClipId>> {
|
||||
let id = "file.import_clip_groups";
|
||||
let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let text = crate::io::read_text(path)?;
|
||||
let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?;
|
||||
if file.format != FORMAT {
|
||||
return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into()));
|
||||
}
|
||||
if file.lanes.iter().map(Vec::len).sum::<usize>() > MAX_CLIPS {
|
||||
if file.lanes.iter().map(|l| l.len().max(1)).sum::<usize>() > MAX_CLIPS || file.audio.len() > MAX_CLIPS {
|
||||
return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into()));
|
||||
}
|
||||
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
// Decode audio first so a bad file changes nothing.
|
||||
let mut decoded = BTreeMap::new();
|
||||
for (old, rel) in &file.audio {
|
||||
let full = dir.join(rel);
|
||||
if !full.starts_with(&dir) || rel.contains("..") {
|
||||
return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}")));
|
||||
}
|
||||
let bytes = std::fs::read(&full).map_err(|err| EngineError::Io(format!("{}: {err}", full.display())))?;
|
||||
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
|
||||
decoded.insert(*old, (rel.clone(), buf));
|
||||
}
|
||||
// Decode audio first so a bad file changes nothing, and each file once however many ids name it.
|
||||
let rate = e.session().sample_rate.hz();
|
||||
let (decoded, file_of) = decode_group_audio(&file.audio, &dir, rate, id)?;
|
||||
let src_rate = f64::from(file.sample_rate.max(1));
|
||||
let s = e.session_mut();
|
||||
let ratio = f64::from(s.sample_rate.hz()) / src_rate;
|
||||
let scale = |v: Samples| -> Samples { (v as f64 * ratio).round() as Samples };
|
||||
let mut map: BTreeMap<u64, SourceId> = BTreeMap::new();
|
||||
for (old, (rel, buf)) in decoded {
|
||||
let name = Path::new(&rel).file_name().and_then(|x| x.to_str()).unwrap_or("clip group").to_string();
|
||||
map.insert(old, crate::io::add_source(s, &name, buf, None, FileFormat::Wav));
|
||||
}
|
||||
let sources: Vec<SourceId> = decoded
|
||||
.into_iter()
|
||||
.map(|(rel, buf)| {
|
||||
let name = Path::new(&rel).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("clip group").to_string();
|
||||
crate::io::add_source(s, &name, buf, None, FileFormat::Wav)
|
||||
})
|
||||
.collect();
|
||||
let map: BTreeMap<u64, SourceId> = file_of.into_iter().filter_map(|(old, n)| Some((old, *sources.get(n)?))).collect();
|
||||
let start_idx = first.and_then(|t| s.tracks.iter().position(|x| x.id == t));
|
||||
let mut placed = Vec::new();
|
||||
let group_id = s.alloc();
|
||||
@@ -132,6 +159,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
|
||||
s.add_track(kind, fmt, Some("Clip Group"))
|
||||
}
|
||||
};
|
||||
let mut prepared = Vec::new();
|
||||
for mut c in lane {
|
||||
if let ClipContent::Audio { source, offset } = &mut c.content {
|
||||
let Some(n) = map.get(&source.0) else { continue };
|
||||
@@ -142,11 +170,24 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
|
||||
}
|
||||
c.id = s.new_clip_id();
|
||||
c.start = at.saturating_add(scale(c.start.max(0)));
|
||||
c.length = scale(c.length).max(1);
|
||||
c.length = scale(c.length);
|
||||
c.fade_in.len = scale(c.fade_in.len);
|
||||
c.fade_out.len = scale(c.fade_out.len);
|
||||
c.sync_point = scale(c.sync_point);
|
||||
c.gain_env.iter_mut().for_each(|(at, _)| *at = scale(*at));
|
||||
c.group = Some(group_id);
|
||||
c.clamp_fades();
|
||||
c.sanitize();
|
||||
placed.push(c.id);
|
||||
crate::edit::place_clip(s, target, c);
|
||||
prepared.push(c);
|
||||
}
|
||||
// Clear what was already on the track first, then add the whole lane at once, so clips of
|
||||
// the group that overlap each other (crossfades) keep their overlap.
|
||||
for c in &prepared {
|
||||
crate::edit::clear_range(s, target, c.range(), false);
|
||||
}
|
||||
if let Some(pl) = s.track_mut(target).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips.extend(prepared);
|
||||
pl.sort();
|
||||
}
|
||||
}
|
||||
Ok(placed)
|
||||
@@ -190,6 +231,91 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_in_a_group_file_are_kept_within_reach() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-far-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let p = dir.join("far.scgrp");
|
||||
let note = json!({"pitch": 60, "velocity": 100, "release_velocity": 64, "channel": 0, "start": i64::MAX, "length": i64::MAX});
|
||||
let clip =
|
||||
json!({"id": 1, "name": "far", "start": i64::MAX, "length": i64::MAX, "content": {"Midi": {"sequence": {"notes": [note], "ctrls": []}}}});
|
||||
let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": 48000, "length": 1, "lanes": [[clip]], "audio": {}});
|
||||
std::fs::write(&p, file.to_string()).unwrap();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap();
|
||||
let id = soundcraft_model::ClipId(r["clips"][0].as_u64().unwrap());
|
||||
let c = e.session().find_clip(id).unwrap().1;
|
||||
assert!(c.start <= crate::cmd::MAX_POSITION && c.length <= crate::cmd::MAX_POSITION);
|
||||
e.execute("edit.select_all", &json!({})).unwrap();
|
||||
for cmd in ["edit.duplicate", "event.extract_chords"] {
|
||||
assert!(!matches!(e.execute(cmd, &json!({})), Err(crate::EngineError::Internal(..))), "{cmd}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_clips_of_a_group_keep_their_overlap_on_import() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-overlap-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let p = dir.join("overlap.scgrp");
|
||||
let note = json!({"pitch": 60, "velocity": 100, "release_velocity": 64, "channel": 0, "start": 0, "length": 100});
|
||||
let content = json!({"Midi": {"sequence": {"notes": [note], "ctrls": []}}});
|
||||
let a = json!({"id": 1, "name": "a", "start": 0, "length": 1000, "content": content});
|
||||
let b = json!({"id": 2, "name": "b", "start": 600, "length": 1000, "content": content});
|
||||
let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": 48000, "length": 1600, "lanes": [[a, b]], "audio": {}});
|
||||
std::fs::write(&p, file.to_string()).unwrap();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap();
|
||||
let ids: Vec<_> = r["clips"].as_array().unwrap().iter().filter_map(serde_json::Value::as_u64).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
let spans: Vec<_> =
|
||||
ids.iter().filter_map(|id| e.session().find_clip(soundcraft_model::ClipId(*id)).map(|(_, c)| (c.start, c.length))).collect();
|
||||
// The group lands at the insertion point; its clips keep their relative positions and overlap.
|
||||
let at = spans.first().map_or(0, |s| s.0);
|
||||
assert_eq!(spans, vec![(at, 1000), (at + 600, 1000)]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fades_sync_point_and_envelope_follow_the_sample_rate() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-rate-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let p = dir.join("rate.scgrp");
|
||||
let mut e = crate::demo::demo_engine();
|
||||
// The file was saved at half the session rate, so every position doubles.
|
||||
let src_rate = e.session().sample_rate.hz() / 2;
|
||||
let fade = |len: i64| json!({"len": len, "shape": "Linear"});
|
||||
let clip = json!({
|
||||
"id": 1, "name": "m", "start": 0, "length": 10_000, "content": {"Midi": {"sequence": {"notes": [], "ctrls": []}}},
|
||||
"fade_in": fade(1000), "fade_out": fade(2000), "sync_point": 500, "gain_env": [[100, -6.0], [4000, 0.0]]
|
||||
});
|
||||
let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": src_rate, "length": 10_000, "lanes": [[clip]], "audio": {}});
|
||||
std::fs::write(&p, file.to_string()).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap();
|
||||
let c = e.session().find_clip(soundcraft_model::ClipId(r["clips"][0].as_u64().unwrap())).unwrap().1;
|
||||
assert_eq!((c.length, c.fade_in.len, c.fade_out.len, c.sync_point), (20_000, 2000, 4000, 1000));
|
||||
assert_eq!(c.gain_env.iter().map(|&(at, _)| at).collect::<Vec<_>>(), vec![200, 8000]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_named_by_many_ids_becomes_one_source() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-shared-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let audio = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] };
|
||||
std::fs::write(dir.join("k.wav"), soundcraft_audio_io::encode(&audio, &soundcraft_audio_io::EncodeOptions::default()).unwrap()).unwrap();
|
||||
let clip = |id: u64| json!({"id": id, "name": "k", "start": id * 1000, "length": 480, "content": {"Audio": {"source": id, "offset": 0}}});
|
||||
let audio_paths = json!({"1": "k.wav", "2": "./k.wav", "3": "k.wav"});
|
||||
let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": 48000, "length": 4000, "lanes": [[clip(1), clip(2), clip(3)]], "audio": audio_paths});
|
||||
let p = dir.join("k.scgrp");
|
||||
std::fs::write(&p, file.to_string()).unwrap();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let before = e.session().sources.len();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap();
|
||||
assert_eq!((r["clips"].as_array().unwrap().len(), e.session().sources.len()), (3, before + 1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_foreign_files_and_escaping_paths() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-bad-{}", std::process::id()));
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Edit › Copy Special › Audio as MIDI: pitch-track the selected audio into MIDI notes on the
|
||||
//! clipboard (paste them onto a MIDI or instrument track).
|
||||
use super::{CommandSpec, bad, has_range, range_param, tracks_required};
|
||||
|
||||
use super::*;
|
||||
use crate::{Clipboard, cmd};
|
||||
use serde_json::json;
|
||||
use crate::{Clipboard, Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_midi::{Note, Sequence};
|
||||
use soundcraft_model::{Clip, ClipId};
|
||||
|
||||
@@ -40,7 +40,7 @@ fn audio_as_midi(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
seq.sort();
|
||||
let count = seq.notes.len();
|
||||
let clip = Clip::midi(ClipId(0), "Audio as MIDI", 0, r.len(), seq);
|
||||
e.clipboard = Clipboard { length: r.len(), tracks: vec![vec![clip]], automation: vec![Vec::new()], ..Clipboard::default() };
|
||||
e.clipboard = Clipboard { tracks: vec![vec![clip]], automation: vec![Vec::new()], ..Clipboard::of_range(e.session(), r) };
|
||||
Ok(json!({"notes": count, "length": r.len()}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! AudioSuite-style offline processing of selected clips.
|
||||
use super::{CommandSpec, bad, clip_ids_param, has_selection, range_param, str_param, tracks_param};
|
||||
use soundcraft_time::Range;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||
use soundcraft_model::{Clip, ClipContent};
|
||||
use soundcraft_model::{Clip, ClipContent, TrackKind};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
@@ -12,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"AudioSuite Process",
|
||||
[],
|
||||
None,
|
||||
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | signal_generator, params?: {..}, clips?}",
|
||||
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks)",
|
||||
has_selection,
|
||||
process
|
||||
)]
|
||||
@@ -35,17 +36,36 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if !known {
|
||||
return Err(bad("audiosuite.process", format!("unknown process `{name}`")));
|
||||
}
|
||||
if soundcraft_dsp::is_generator(&name) && p.get("clips").is_none() && p.get("clip").is_none() {
|
||||
let r = range_param(e, "audiosuite.process", p)?;
|
||||
if !r.is_empty() {
|
||||
return generate(e, p, &name, ¶ms, r);
|
||||
}
|
||||
}
|
||||
let ids = clip_ids_param(e, p);
|
||||
if ids.is_empty() {
|
||||
return Err(bad("audiosuite.process", "select audio clips first"));
|
||||
}
|
||||
let sr = e.session().sample_rate;
|
||||
let ratio = f64::from(get("ratio", 1.0).clamp(0.25, 4.0));
|
||||
let semitones = get("semitones", 0.0).clamp(-24.0, 24.0);
|
||||
let to = (sr.as_f64() / f64::from(get("speed", 1.0).clamp(0.25, 4.0))).round().max(1.0) as u32;
|
||||
// These processes cut their output at MAX_OFFLINE_SAMPLES frames.
|
||||
let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize;
|
||||
let most = match name.as_str() {
|
||||
"time_stretch" => longest(ratio),
|
||||
"pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)),
|
||||
"varispeed" => longest(f64::from(to) / sr.as_f64()),
|
||||
_ => usize::MAX,
|
||||
};
|
||||
let mut done = 0;
|
||||
for id in ids {
|
||||
let Some((track, clip)) = e.session().find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
|
||||
if !clip.is_audio() {
|
||||
continue;
|
||||
}
|
||||
let channels = e.session().track(track).map_or(1, |t| t.channels());
|
||||
crate::io::refuse_longer(e.session(), "audiosuite.process", &clip.name, "process", clip.length, most.min(crate::io::render_limit(channels)))?;
|
||||
let mut audio = soundcraft_mix::render_clips(e.session(), track, clip.range());
|
||||
// Render without the clip's own fades: re-render a copy without fades so they stay editable.
|
||||
let mut tmp = e.session().clone();
|
||||
@@ -78,20 +98,11 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
audio
|
||||
}
|
||||
"duplicate" => audio,
|
||||
"time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, f64::from(get("ratio", 1.0).clamp(0.25, 4.0)), srf),
|
||||
"pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, get("semitones", 0.0).clamp(-24.0, 24.0), srf),
|
||||
"varispeed" => {
|
||||
let speed = get("speed", 1.0).clamp(0.25, 4.0);
|
||||
let to = (sr.as_f64() / f64::from(speed)).round().max(1.0) as u32;
|
||||
soundcraft_dsp::offline::resample(&audio, sr.hz(), to)
|
||||
}
|
||||
"time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, ratio, srf),
|
||||
"pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, semitones, srf),
|
||||
"varispeed" => soundcraft_dsp::offline::resample(&audio, sr.hz(), to),
|
||||
plugin => {
|
||||
let Some(mut pl) = soundcraft_dsp::create(plugin) else { return Err(bad("audiosuite.process", format!("cannot create `{plugin}`"))) };
|
||||
pl.prepare(srf, 1024, audio.len().max(1));
|
||||
for (k, v) in ¶ms {
|
||||
pl.set_param(k, *v);
|
||||
}
|
||||
soundcraft_dsp::offline::apply_plugin(&mut audio, pl.as_mut(), srf);
|
||||
apply(plugin, ¶ms, &mut audio, srf)?;
|
||||
audio
|
||||
}
|
||||
};
|
||||
@@ -107,6 +118,8 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
nc.name = new_name;
|
||||
nc.content = ClipContent::Audio { source: src, offset: 0 };
|
||||
nc.length = i64::try_from(frames).unwrap_or(nc.length);
|
||||
// The render already has the clip's stretch applied; keeping it would stretch the new file again.
|
||||
nc.stretch = 1.0;
|
||||
// Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain,
|
||||
// the same way its fades stay editable. Other processes bake a new file at unity gain.
|
||||
if !matches!(name.as_str(), "invert" | "duplicate") {
|
||||
@@ -122,6 +135,46 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"processed": done, "process": name}))
|
||||
}
|
||||
|
||||
/// Fill `r` on each selected audio track with a new clip from the generator `name`, over
|
||||
/// whatever was there, like Pro Tools' Signal Generator.
|
||||
fn generate(e: &mut Engine, p: &Value, name: &str, params: &[(String, f32)], r: Range) -> Result<Value> {
|
||||
let sr = e.session().sample_rate;
|
||||
let tracks = tracks_param(e, "audiosuite.process", p)?;
|
||||
// Refuse before generating anything, rather than cut the clip short.
|
||||
for t in tracks.iter().filter_map(|t| e.session().track(*t)).filter(|t| t.kind == TrackKind::Audio) {
|
||||
crate::io::refuse_longer(e.session(), "audiosuite.process", &t.name, "generate on", r.len(), crate::io::render_limit(t.channels()))?;
|
||||
}
|
||||
let mut clips = Vec::new();
|
||||
for track in tracks {
|
||||
let Some(t) = e.session().track(track).filter(|t| t.kind == TrackKind::Audio) else { continue };
|
||||
let channels = t.channels().max(1);
|
||||
let frames = usize::try_from(r.len()).unwrap_or(0).min(crate::io::render_limit(channels));
|
||||
let clip_name = format!("{}-{}", t.name, short(name));
|
||||
let mut audio = vec![vec![0.0; frames]; channels];
|
||||
apply(name, params, &mut audio, sr.as_f64() as f32)?;
|
||||
let s = e.session_mut();
|
||||
let src = crate::io::add_source(s, &clip_name, AudioBuffer { sample_rate: sr.hz(), channels: audio }, None, FileFormat::Wav);
|
||||
let id = s.new_clip_id();
|
||||
crate::edit::place_clip(s, track, Clip::audio(id, &clip_name, src, 0, r.start, i64::try_from(frames).unwrap_or(0)));
|
||||
clips.push(id.0);
|
||||
}
|
||||
if clips.is_empty() {
|
||||
return Err(bad("audiosuite.process", "select an audio track to generate on"));
|
||||
}
|
||||
Ok(json!({"processed": clips.len(), "process": name, "clips": clips}))
|
||||
}
|
||||
|
||||
/// Run plugin `id` with `params` over `audio`, in place.
|
||||
fn apply(id: &str, params: &[(String, f32)], audio: &mut [Vec<f32>], srf: f32) -> Result<()> {
|
||||
let Some(mut pl) = soundcraft_dsp::create(id) else { return Err(bad("audiosuite.process", format!("cannot create `{id}`"))) };
|
||||
pl.prepare(srf, 1024, audio.len().max(1));
|
||||
for (k, v) in params {
|
||||
pl.set_param(k, *v);
|
||||
}
|
||||
soundcraft_dsp::offline::apply_plugin(audio, pl.as_mut(), srf);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn short(n: &str) -> String {
|
||||
let s: String = n.split('_').map(|w| w.chars().take(4).collect::<String>()).collect::<Vec<_>>().join("");
|
||||
s.chars().take(8).collect()
|
||||
@@ -165,4 +218,43 @@ mod tests {
|
||||
let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0];
|
||||
assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processed_clip_does_not_keep_the_stretch_it_baked() {
|
||||
for stretch in [0.5, 2.0] {
|
||||
for process in ["invert", "duplicate"] {
|
||||
let (mut e, id) = gained_clip();
|
||||
e.session_mut().find_clip_mut(id).unwrap().stretch = stretch;
|
||||
e.execute("audiosuite.process", &json!({"process": process, "clips": [id.0]})).unwrap();
|
||||
let c = e.session().tracks[0].clips()[0].clone();
|
||||
let frames = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0].len();
|
||||
assert_eq!(c.stretch, 1.0, "{process} at {stretch}");
|
||||
assert_eq!(c.length, i64::try_from(frames).unwrap(), "{process} at {stretch}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_generator_fills_the_selection_with_a_clip() {
|
||||
let (mut e, _) = gained_clip();
|
||||
let tone = e.session().tracks[0].id;
|
||||
let t = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Empty"));
|
||||
e.execute("edit.select", &json!({"tracks": [t.0], "start": 48_000, "end": 96_000})).unwrap();
|
||||
assert!(matches!(e.execute("audiosuite.process", &json!({"process": "normalize"})), Err(crate::EngineError::BadParams(..))));
|
||||
|
||||
let generator = json!({"process": "signal_generator", "params": {"freq": 440.0, "level": -6.0}});
|
||||
assert_eq!(e.execute("audiosuite.process", &generator).unwrap()["processed"], 1);
|
||||
let c = e.session().tracks[1].clips()[0].clone();
|
||||
assert_eq!((c.start, c.length), (48_000, 48_000));
|
||||
let buf = &e.session().pool.get(c.source().unwrap()).unwrap().buffer;
|
||||
assert_eq!(buf.channels.len(), 2);
|
||||
let peak = buf.channels[1].iter().fold(0.0f32, |m, x| m.max(x.abs()));
|
||||
assert!((peak - 0.5).abs() < 0.01, "-6 dB sine peaks near 0.5, got {peak}");
|
||||
|
||||
// Over existing audio it replaces just the selection.
|
||||
e.execute("edit.select", &json!({"tracks": [tone.0], "start": 32, "end": 96})).unwrap();
|
||||
e.execute("audiosuite.process", &generator).unwrap();
|
||||
let ranges: Vec<_> = e.session().tracks[0].clips().iter().map(|c| (c.start, c.length)).collect();
|
||||
assert_eq!(ranges, vec![(0, 32), (32, 64)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Third-party CLAP plugins: discovery (`engine.clap_plugins`) and id lookup shared by the mixer
|
||||
//! commands. Hosting itself lives in `soundcraft-clap-host`.
|
||||
use super::{CommandSpec, always};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_dsp::PluginInfo;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
+158
-16
@@ -1,8 +1,10 @@
|
||||
//! Clip menu commands.
|
||||
use super::{CommandSpec, always, bad, clip_ids_param, f64_or, has_range, has_selection, i64_or, position_param, str_param, track_param};
|
||||
use soundcraft_model::ClipId;
|
||||
use soundcraft_time::Samples;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -74,7 +76,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let mut n = 0;
|
||||
for id in ids {
|
||||
if crate::io::render_clip_gain(e, id)? {
|
||||
if crate::io::render_clip_gain(e, "clip.gain_render", id)? {
|
||||
n += 1
|
||||
}
|
||||
}
|
||||
@@ -102,6 +104,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("clip.remove_sync_point", "Remove Sync Point", ["Clip"], None, "{clips?}", has_sync_point, remove_sync_point),
|
||||
cmd!("clip.quantize_to_grid", "Quantize to Grid", ["Clip"], Some("Cmd+0"), "{clips?}", has_selection, quantize_to_grid),
|
||||
cmd!("clip.color", "Clip Color", [], None, "{clips?, color: [r,g,b] | null}", has_selection, |e, p| {
|
||||
let ids = clip_ids_param(e, p);
|
||||
@@ -153,8 +156,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Some(_) => return Err(bad("clip.place_source", "audio files go on audio tracks")),
|
||||
None => s.add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::for_channels(chans.min(2)), Some(&name)),
|
||||
};
|
||||
let (sh, len) = (s.edit.edit_mode == soundcraft_model::EditMode::Shuffle, frames.max(1));
|
||||
let at = if sh { crate::edit::shuffle_slot(s, t, at, &[]) } else { at };
|
||||
if sh {
|
||||
crate::edit::make_room(s, t, at, len).map_err(|why| bad("clip.place_source", why))?;
|
||||
}
|
||||
let id = s.new_clip_id();
|
||||
crate::edit::place_clip(s, t, soundcraft_model::Clip::audio(id, name, src, 0, at, frames.max(1)));
|
||||
crate::edit::place_clip(s, t, soundcraft_model::Clip::audio(id, name, src, 0, at, len));
|
||||
Ok(json!({"clip": id, "track": t}))
|
||||
}
|
||||
),
|
||||
@@ -199,9 +207,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let out_len = soundcraft_time::to_samples(base.length as f64 * ratio).max(1);
|
||||
let mut warp = base.clone();
|
||||
warp.stretch = ratio;
|
||||
let Some(src) = super::more_util::render_stretched(s, "clip.elastic_properties", &warp, out_len)? else { continue };
|
||||
let Some(src) = super::more_util::render_stretched(s, "clip.elastic_properties", &base, out_len)? else { continue };
|
||||
if orig.is_none()
|
||||
&& let soundcraft_model::ClipContent::Audio { source, offset } = base.content
|
||||
{
|
||||
@@ -288,8 +294,15 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let mut made = 0;
|
||||
for id in ids {
|
||||
let Some((tid, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
|
||||
let Some((tid, mut c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
|
||||
let reps = total.map_or(count - 1, |l| (l / c.length.max(1)).max(1) - 1).clamp(0, 1000);
|
||||
if reps > 0 && c.loop_id.is_none() {
|
||||
let lid = s.alloc();
|
||||
if let Some(src) = s.find_clip_mut(id) {
|
||||
src.loop_id = Some(lid);
|
||||
}
|
||||
c.loop_id = Some(lid);
|
||||
}
|
||||
let mut copies = Vec::new();
|
||||
for k in 1..=reps {
|
||||
let mut nc = c.clone();
|
||||
@@ -297,7 +310,7 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
copies.push((c.start + c.length * k, nc));
|
||||
}
|
||||
for (at, nc) in copies {
|
||||
crate::edit::paste(s, tid, at, &[nc], c.length, false);
|
||||
let _ = crate::edit::paste(s, tid, at, &[nc], c.length, false);
|
||||
made += 1;
|
||||
}
|
||||
}
|
||||
@@ -338,17 +351,39 @@ fn nudge_gain(e: &mut Engine, p: &Value, db: f64) -> Result<Value> {
|
||||
gain(e, &json!({"clips": clips, "delta_db": db}))
|
||||
}
|
||||
|
||||
/// Sync point 0 is "none" (edit_window only draws one when > 0).
|
||||
fn has_sync_point(e: &Engine) -> std::result::Result<(), String> {
|
||||
let s = e.session();
|
||||
if clip_ids_param(e, &json!({})).iter().any(|id| s.find_clip(*id).is_some_and(|(_, c)| c.sync_point != 0)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("no selected clip has a sync point".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_sync_point(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session();
|
||||
// Only clips that have a point are touched, so a call that changes nothing leaves no undo step.
|
||||
let ids: Vec<ClipId> = clip_ids_param(e, p).into_iter().filter(|id| s.find_clip(*id).is_some_and(|(_, c)| c.sync_point != 0)).collect();
|
||||
if !ids.is_empty() {
|
||||
let s = e.session_mut();
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.sync_point = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"clips": ids.len()}))
|
||||
}
|
||||
|
||||
fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session();
|
||||
let moves: Vec<(soundcraft_model::ClipId, i64)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, c.start + c.sync_point)))
|
||||
.map(|(id, anchor)| (id, s.edit.grid.snap(anchor, s.sample_rate, &s.tempo, s.frame_rate) - anchor))
|
||||
.collect();
|
||||
let moves: Vec<(soundcraft_model::ClipId, i64)> =
|
||||
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false)))).collect();
|
||||
let s = e.session_mut();
|
||||
for (id, d) in &moves {
|
||||
crate::edit::move_clips(s, &[*id], *d, None);
|
||||
crate::edit::move_clips(s, &[*id], *d, &[]);
|
||||
}
|
||||
Ok(json!({"clips": moves.len()}))
|
||||
}
|
||||
@@ -378,6 +413,45 @@ mod tests {
|
||||
assert_eq!(e.session().find_clip(ClipId(cid)).unwrap().1.length, len0);
|
||||
}
|
||||
|
||||
/// A clip on an Elastic track playing `clip_len` samples of a source that is silent before `tone_at`.
|
||||
fn elastic_clip(source_len: usize, tone_at: usize, clip_len: i64) -> (crate::Engine, TrackId, ClipId) {
|
||||
use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind};
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Elastic"));
|
||||
let samples = (0..source_len).map(|i| if i >= tone_at { (i as f32 * 0.2).sin() * 0.4 } else { 0.0 }).collect();
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![samples] };
|
||||
let src = crate::io::add_source(&mut s, "Tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, t, Clip::audio(cid, "Clip", src, 0, 0, clip_len));
|
||||
let mut e = crate::Engine::new(s);
|
||||
e.execute("track.elastic", &json!({"track": t.0, "algorithm": "polyphonic"})).unwrap();
|
||||
(e, t, cid)
|
||||
}
|
||||
|
||||
fn rendered_peak(e: &crate::Engine, t: TrackId, cid: ClipId) -> f32 {
|
||||
let range = e.session().find_clip(cid).unwrap().1.range();
|
||||
soundcraft_mix::render_clips(e.session(), t, range).iter().flatten().fold(0.0, |m, x| m.max(x.abs()))
|
||||
}
|
||||
|
||||
/// Elastic Properties stretches the material the clip plays, by the requested ratio (#227).
|
||||
#[test]
|
||||
fn elastic_properties_stretches_the_clip_material() {
|
||||
let (mut e, t, cid) = elastic_clip(20_000, 10_000, 20_000);
|
||||
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 2.0})).unwrap();
|
||||
assert_eq!(e.session().find_clip(cid).unwrap().1.length, 40_000);
|
||||
assert!(rendered_peak(&e, t, cid) > 0.3, "the tone in the second half was lost");
|
||||
|
||||
let (mut e, t, cid) = elastic_clip(60_000, 20_000, 20_000);
|
||||
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 0.5})).unwrap();
|
||||
assert_eq!(rendered_peak(&e, t, cid), 0.0, "audio from past the clip end leaked in");
|
||||
|
||||
for (ratio, len) in [(4.0, 80_000), (0.2, 4_000)] {
|
||||
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
|
||||
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": ratio})).unwrap();
|
||||
assert_eq!(e.session().find_clip(cid).unwrap().1.length, len, "ratio {ratio}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
@@ -406,4 +480,72 @@ mod tests {
|
||||
e.execute("clip.gain_nudge_up", &json!({})).unwrap();
|
||||
assert_eq!(gain(&e), 36.0);
|
||||
}
|
||||
|
||||
/// Three 10 000-sample clips side by side on one track; sync points are set by each test.
|
||||
fn three_clips() -> (crate::Engine, [ClipId; 3]) {
|
||||
use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind};
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Dialog"));
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.1; 10_000]] };
|
||||
let src = crate::io::add_source(&mut s, "Line", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let ids = [0, 1, 2].map(|k| {
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, t, Clip::audio(cid, "Line", src, 0, k * 12_000, 10_000));
|
||||
cid
|
||||
});
|
||||
(crate::Engine::new(s), ids)
|
||||
}
|
||||
|
||||
fn sync_of(e: &crate::Engine, id: ClipId) -> i64 {
|
||||
e.session().find_clip(id).unwrap().1.sync_point
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_sync_point_clears_the_point_identify_set() {
|
||||
let (mut e, [a, ..]) = three_clips();
|
||||
e.execute("edit.select", &json!({"clips": [a.0]})).unwrap();
|
||||
e.execute("clip.identify_sync_point", &json!({"at": 4_000})).unwrap();
|
||||
assert_eq!(sync_of(&e, a), 4_000);
|
||||
e.execute("clip.remove_sync_point", &json!({})).unwrap();
|
||||
assert_eq!(sync_of(&e, a), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_sync_point_clears_every_selected_clip_and_only_those() {
|
||||
let (mut e, [a, b, c]) = three_clips();
|
||||
for (id, at) in [(a, 1_000), (b, 2_000), (c, 3_000)] {
|
||||
e.session_mut().find_clip_mut(id).unwrap().sync_point = at;
|
||||
}
|
||||
e.execute("edit.select", &json!({"clips": [a.0, b.0]})).unwrap();
|
||||
let r = e.execute("clip.remove_sync_point", &json!({})).unwrap();
|
||||
assert_eq!(r["clips"], 2);
|
||||
assert_eq!([sync_of(&e, a), sync_of(&e, b), sync_of(&e, c)], [0, 0, 3_000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_sync_point_is_disabled_unless_a_selected_clip_has_one() {
|
||||
let (mut e, [a, b, c]) = three_clips();
|
||||
e.session_mut().find_clip_mut(c).unwrap().sync_point = 3_000;
|
||||
let enabled = |e: &crate::Engine| crate::find_command("clip.remove_sync_point").unwrap().info(e).enabled;
|
||||
e.execute("edit.select", &json!({"clips": [a.0, b.0]})).unwrap();
|
||||
assert!(!enabled(&e), "neither selected clip has a sync point (the unselected one does)");
|
||||
e.execute("edit.select", &json!({"clips": [b.0, c.0]})).unwrap();
|
||||
assert!(enabled(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_sync_point_is_one_undo_step_that_puts_the_points_back() {
|
||||
let (mut e, [a, b, _]) = three_clips();
|
||||
for (id, at) in [(a, 1_000), (b, 2_000)] {
|
||||
e.session_mut().find_clip_mut(id).unwrap().sync_point = at;
|
||||
}
|
||||
e.execute("edit.select", &json!({"clips": [a.0, b.0]})).unwrap();
|
||||
let steps = e.undo_history().len();
|
||||
e.execute("clip.remove_sync_point", &json!({})).unwrap();
|
||||
assert_eq!(e.undo_history().len(), steps + 1);
|
||||
assert_eq!(e.undo_label(), Some("Remove Sync Point"));
|
||||
assert_eq!([sync_of(&e, a), sync_of(&e, b)], [0, 0]);
|
||||
assert!(e.undo());
|
||||
assert_eq!([sync_of(&e, a), sync_of(&e, b)], [1_000, 2_000]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Clip List commands.
|
||||
use super::{CommandSpec, bad, bool_or};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::SourceId;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{ClipId, SourceId};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const CLEAR: &str = "clip.clear";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! More Clip menu commands: Regroup, Unloop, Clip Effects bypass/render, Conform to Tempo and
|
||||
//! Remove Pitch Shift.
|
||||
use super::{CommandSpec, bad, clip_ids_param, has_selection, str_param};
|
||||
|
||||
use super::more_util::{render_stretched, replace_clip};
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{Clip, ClipContent, ClipId, Session, TrackId};
|
||||
use soundcraft_time::Samples;
|
||||
|
||||
@@ -101,12 +101,13 @@ fn regroup(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
|
||||
fn same_material(a: &Clip, b: &Clip) -> bool {
|
||||
a.length == b.length
|
||||
&& match (&a.content, &b.content) {
|
||||
(ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
|
||||
(ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2,
|
||||
(ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
|
||||
_ => false,
|
||||
}
|
||||
&& (a.loop_id.is_some() && a.loop_id == b.loop_id
|
||||
|| match (&a.content, &b.content) {
|
||||
(ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
|
||||
(ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2,
|
||||
(ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The loop a clip belongs to: back-to-back copies of the same material. Origin first.
|
||||
@@ -160,6 +161,7 @@ fn unloop(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
for c in pl.clips.iter_mut().filter(|c| chain.contains(&c.id)) {
|
||||
c.group = None;
|
||||
c.loop_id = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,7 +206,7 @@ fn effects_render(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
if crate::io::render_clip_gain(e, *id)? {
|
||||
if crate::io::render_clip_gain(e, "clip.effects_render", *id)? {
|
||||
n += 1;
|
||||
}
|
||||
let s = e.session_mut();
|
||||
@@ -304,6 +306,42 @@ mod tests {
|
||||
assert!(e.execute("clip.unloop", &json!({"clip": ids[3].0})).is_err());
|
||||
}
|
||||
|
||||
/// A 100-sample MIDI clip with one note, on a fresh instrument-less MIDI track.
|
||||
fn midi_engine() -> (Engine, TrackId, ClipId) {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
|
||||
let id = s.new_clip_id();
|
||||
let mut seq = soundcraft_midi::Sequence::default();
|
||||
seq.notes.push(soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 240 });
|
||||
crate::edit::place_clip(&mut s, t, Clip::midi(id, "m", 0, 100, seq));
|
||||
s.edit.selected_tracks = vec![t];
|
||||
(Engine::new(s), t, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloop_recognises_loops_after_source_edit() {
|
||||
let (mut e, t, id) = midi_engine();
|
||||
assert_eq!(e.execute("clip.loop", &json!({"clip": id.0, "count": 4})).unwrap()["loops"], 3);
|
||||
// Editing the source note makes the copies' content differ from it.
|
||||
if let Some(c) = e.session_mut().find_clip_mut(id)
|
||||
&& let ClipContent::Midi { sequence } = &mut c.content
|
||||
{
|
||||
sequence.notes[0].length = 1100;
|
||||
}
|
||||
e.execute("clip.unloop", &json!({"clip": id.0})).unwrap();
|
||||
let left: Vec<ClipId> = e.session().track(t).unwrap().clips().iter().map(|c| c.id).collect();
|
||||
assert_eq!(left, vec![id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloop_flatten_turns_loop_into_independent_clips() {
|
||||
let (mut e, t, id) = midi_engine();
|
||||
e.execute("clip.loop", &json!({"clip": id.0, "count": 3})).unwrap();
|
||||
e.execute("clip.unloop", &json!({"clip": id.0, "mode": "flatten"})).unwrap();
|
||||
assert_eq!(e.session().track(t).unwrap().clips().len(), 3);
|
||||
assert!(e.session().track(t).unwrap().clips().iter().all(|c| c.loop_id.is_none() && c.group.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regroup_reuses_group() {
|
||||
let (mut e, _, ids) = engine_with(&[(0, 100, 0), (500, 100, 0), (900, 100, 0)]);
|
||||
|
||||
+510
-79
@@ -1,25 +1,29 @@
|
||||
//! Edit menu commands.
|
||||
use super::{
|
||||
CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, f64_or, has_clipboard, has_range, has_selection,
|
||||
has_tracks, i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required,
|
||||
};
|
||||
use soundcraft_time::Samples;
|
||||
|
||||
use super::*;
|
||||
use super::more_util::toggle_flag;
|
||||
use crate::edit as ops;
|
||||
use crate::{Clipboard, cmd};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{EditMode, FadeShape, Session};
|
||||
use crate::{Clipboard, Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{ClipId, EditMode, Fade, FadeShape, Session, TrackId};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |e, _| Ok(json!({"undone": e.undo()}))),
|
||||
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |e, _| Ok(json!({"redone": e.redo()}))),
|
||||
cmd!("edit.restore_last_selection", "Restore Last Selection", ["Edit"], Some("Cmd+Alt+Z"), "{}", always, restore_selection),
|
||||
cmd!(noundo "edit.restore_last_selection", "Restore Last Selection", ["Edit"], Some("Cmd+Alt+Z"), "{}", has_last_selection, restore_selection),
|
||||
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, true, "edit.cut")),
|
||||
cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, false, "edit.copy")),
|
||||
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{tracks?, at?}", has_clipboard, paste),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Cmd+B"), "{tracks?, start?, end?}", has_range, clear),
|
||||
cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| reset_clip_gain(
|
||||
e,
|
||||
p,
|
||||
"edit.cut_clip_gain"
|
||||
)),
|
||||
cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| {
|
||||
super::edit_more::copy_clip_gain(e, p)?;
|
||||
reset_clip_gain(e, p, "edit.cut_clip_gain")
|
||||
}),
|
||||
cmd!("edit.clear_clip_gain", "Clip Gain", ["Edit", "Clear Special"], Some("Cmd+Ctrl+B"), "{}", has_range, |e, p| reset_clip_gain(
|
||||
e,
|
||||
p,
|
||||
@@ -72,8 +76,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "edit.move_selection_right", "Move Edit Right", ["Edit", "Selection"], Some("Cmd+Alt+'"), "{}", always, |e, _| shift_selection(e, 1)),
|
||||
cmd!(noundo "edit.halve_selection", "Halve Edit", ["Edit", "Selection"], None, "{}", always, |e, _| scale_selection(e, 0.5)),
|
||||
cmd!(noundo "edit.double_selection", "Double Edit", ["Edit", "Selection"], None, "{}", always, |e, _| scale_selection(e, 2.0)),
|
||||
cmd!(noundo "edit.extend_selection_up", "Extend Edit Up", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| extend_tracks(e, -1)),
|
||||
cmd!(noundo "edit.extend_selection_down", "Extend Edit Down", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| extend_tracks(e, 1)),
|
||||
cmd!(noundo "edit.extend_selection_up", "Extend Edit Up", ["Edit", "Selection"], Some("Ctrl+Shift+P"), "{}", has_tracks, |e, _| extend_tracks(e, -1)),
|
||||
cmd!(noundo "edit.extend_selection_down", "Extend Edit Down", ["Edit", "Selection"], Some("Ctrl+Shift+;"), "{}", has_tracks, |e, _| extend_tracks(e, 1)),
|
||||
cmd!(noundo "edit.remove_selection_top", "Remove Edit from Top", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| {
|
||||
let s = e.session_mut();
|
||||
if !s.edit.selected_tracks.is_empty() {
|
||||
@@ -103,7 +107,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("edit.trim_to_file_boundaries", "To File Boundaries", ["Edit", "Trim"], None, "{clips?}", has_selection, trim_to_file),
|
||||
cmd!("edit.trim_to_fill_selection", "To Fill Selection", ["Edit", "Trim"], None, "{}", has_range, trim_to_fill),
|
||||
cmd!("edit.mute_clips", "Mute", ["Edit"], Some("Cmd+M"), "{clips?}", has_selection, mute_clips),
|
||||
cmd!("edit.copy_to_new_playlist", "New Playlist", ["Edit", "Copy Selection to..."], None, "{}", has_range, copy_to_new_playlist),
|
||||
cmd!(
|
||||
"edit.copy_to_new_playlist",
|
||||
"New Playlist",
|
||||
["Edit", "Copy Selection to..."],
|
||||
Some("Alt+Shift+Down"),
|
||||
"{}",
|
||||
has_range,
|
||||
copy_to_new_playlist
|
||||
),
|
||||
cmd!(
|
||||
"edit.strip_silence",
|
||||
"Strip Silence",
|
||||
@@ -118,19 +130,41 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create...",
|
||||
["Edit", "Fades"],
|
||||
Some("Cmd+F"),
|
||||
"{shape?: linear|equal power|s-curve|exponential|logarithmic, tracks?, start?, end?}",
|
||||
"{shape?: linear|equal power|s-curve|exponential|logarithmic, tracks?, start?, end?} or {clip: id, side: in|out, length: samples, shape?, curvature?: -1..1}",
|
||||
has_range,
|
||||
fades_create
|
||||
),
|
||||
cmd!("edit.fades_delete", "Delete", ["Edit", "Fades"], None, "{tracks?, start?, end?}", has_selection, fades_delete),
|
||||
cmd!("edit.fade_to_start", "Fade to Start", ["Edit", "Fades"], Some("Cmd+Ctrl+D"), "{}", has_selection, |e, p| fade_to(e, p, true)),
|
||||
cmd!("edit.fade_to_end", "Fade to End", ["Edit", "Fades"], Some("Cmd+Ctrl+G"), "{}", has_selection, |e, p| fade_to(e, p, false)),
|
||||
cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n}", has_selection, nudge),
|
||||
cmd!(noundo "edit.mode", "Edit Mode", [], Some("F1-F4"), "{mode: shuffle|slip|spot|grid|relative_grid}", always, |e, p| {
|
||||
let m = str_param(p, "mode").and_then(EditMode::from_id).ok_or_else(|| bad("edit.mode", "mode must be shuffle|slip|spot|grid|relative_grid"))?;
|
||||
e.session_mut().edit.edit_mode = m;
|
||||
Ok(json!({"mode": m}))
|
||||
}),
|
||||
cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n, next?: bool (next larger value)}", has_selection, nudge),
|
||||
cmd!(
|
||||
noundo "edit.mode",
|
||||
"Edit Mode",
|
||||
[],
|
||||
Some("F1-F4, Alt+1-4, `"),
|
||||
"{mode: shuffle|slip|spot|grid|relative_grid, snap_to_grid?: bool (Shuffle, Slip and Spot)}",
|
||||
always,
|
||||
set_mode
|
||||
),
|
||||
cmd!(
|
||||
noundo "edit.snap_to_grid",
|
||||
"Snap to Grid",
|
||||
[],
|
||||
Some("Shift+F4"),
|
||||
"{value?: bool} (toggles when omitted) — in Shuffle, Slip and Spot the Edit cursor and selections snap to the grid",
|
||||
always,
|
||||
snap_to_grid
|
||||
),
|
||||
cmd!(
|
||||
noundo "edit.shuffle_lock",
|
||||
"Shuffle Lock",
|
||||
[],
|
||||
None,
|
||||
"{value?: bool} (toggles when omitted) — keeps Shuffle mode from being chosen",
|
||||
always,
|
||||
shuffle_lock
|
||||
),
|
||||
cmd!(noundo "edit.tool", "Edit Tool", [], Some("F5-F10"), "{tool: zoom|trim|selector|grabber|scrubber|pencil|smart}", always, |e, p| {
|
||||
let t = str_param(p, "tool").and_then(soundcraft_model::Tool::from_id).ok_or_else(|| bad("edit.tool", "unknown tool"))?;
|
||||
e.session_mut().edit.tool = t;
|
||||
@@ -141,10 +175,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Clips",
|
||||
[],
|
||||
None,
|
||||
"{clips?: [id], by?: samples, to?: position (first clip start), track?: target}",
|
||||
"{clips?: [id], by?: samples, to?: position for the first clip that can move (Grid modes snap its sync point), track?: where the first clip's track goes; the others keep their distance}",
|
||||
has_selection,
|
||||
move_clips
|
||||
),
|
||||
cmd!(
|
||||
"edit.trim_clip",
|
||||
"Trim Clip",
|
||||
[],
|
||||
None,
|
||||
"{clip: id, start?: position, end?: position} — moves the clip's edges like the Trim tool, in the current Edit mode",
|
||||
always,
|
||||
trim_clip
|
||||
),
|
||||
cmd!("edit.consolidate", "Consolidate", ["Edit"], Some("Alt+Shift+3"), "{tracks?, start?, end?}", has_range, consolidate),
|
||||
]
|
||||
}
|
||||
@@ -153,8 +196,57 @@ fn shuffle(s: &Session) -> bool {
|
||||
s.edit.edit_mode == EditMode::Shuffle
|
||||
}
|
||||
|
||||
fn set_mode(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let m = str_param(p, "mode").and_then(EditMode::from_id).ok_or_else(|| bad("edit.mode", "mode must be shuffle|slip|spot|grid|relative_grid"))?;
|
||||
if m == EditMode::Shuffle && e.session().edit.flag(ops::SHUFFLE_LOCK) {
|
||||
return Err(bad("edit.mode", "Shuffle is locked"));
|
||||
}
|
||||
let snap = bool_or(p, "snap_to_grid", false) && !m.is_grid();
|
||||
let ed = &mut e.session_mut().edit;
|
||||
ed.edit_mode = m;
|
||||
ed.set_flag(ops::SNAP_TO_GRID, snap);
|
||||
if m.is_grid() {
|
||||
ed.set_flag(ops::RELATIVE_GRID, m == EditMode::GridRelative);
|
||||
}
|
||||
Ok(json!({"mode": m, "snap_to_grid": snap}))
|
||||
}
|
||||
|
||||
fn snap_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if e.session().edit.edit_mode.is_grid() {
|
||||
return Err(bad("edit.snap_to_grid", "Grid mode always snaps"));
|
||||
}
|
||||
toggle_flag(e, p, ops::SNAP_TO_GRID)
|
||||
}
|
||||
|
||||
fn shuffle_lock(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if shuffle(e.session()) && bool_or(p, "value", true) {
|
||||
return Err(bad("edit.shuffle_lock", "leave Shuffle mode before locking it"));
|
||||
}
|
||||
toggle_flag(e, p, ops::SHUFFLE_LOCK)
|
||||
}
|
||||
|
||||
fn has_last_selection(e: &Engine) -> std::result::Result<(), String> {
|
||||
if e.last_selection.is_some() { Ok(()) } else { Err("no previous selection to restore".into()) }
|
||||
}
|
||||
|
||||
/// Remember the selection `before` had when a command changed it, for Restore Last Selection.
|
||||
pub(crate) fn remember_selection(e: &mut Engine, before: &Session) {
|
||||
let (old, new) = (&before.edit, &e.session().edit);
|
||||
if old.selection == new.selection && old.selected_tracks == new.selected_tracks {
|
||||
return;
|
||||
}
|
||||
e.last_selection = Some((old.selection, old.selected_tracks.clone()));
|
||||
}
|
||||
|
||||
/// Swaps in the previous selection; `remember_selection` then keeps the one replaced, so
|
||||
/// invoking it again goes back.
|
||||
fn restore_selection(e: &mut Engine, _: &Value) -> Result<Value> {
|
||||
Ok(json!({"selection": e.session().edit.selection}))
|
||||
let (range, mut tracks) = e.last_selection.clone().ok_or_else(|| bad("edit.restore_last_selection", "no previous selection to restore"))?;
|
||||
tracks.retain(|t| e.session().track(*t).is_some());
|
||||
let ed = &mut e.session_mut().edit;
|
||||
ed.selection = range;
|
||||
ed.selected_tracks = tracks;
|
||||
Ok(json!({"selection": range}))
|
||||
}
|
||||
|
||||
fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
|
||||
@@ -163,7 +255,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
|
||||
if range.is_empty() {
|
||||
return Err(bad(id, "selection is empty"));
|
||||
}
|
||||
let mut cb = Clipboard { length: range.len(), ..Clipboard::default() };
|
||||
let mut cb = Clipboard::of_range(e.session(), range);
|
||||
let follow = e.session().edit.automation_follows_edit;
|
||||
for t in &tracks {
|
||||
cb.tracks.push(ops::copy_range(e.session(), *t, range));
|
||||
@@ -205,15 +297,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
|
||||
let sh = shuffle(e.session());
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
ops::clear_range(s, *t, range, sh);
|
||||
if follow && let Some(tr) = s.track_mut(*t) {
|
||||
for l in &mut tr.automation {
|
||||
l.clear_range(range.start, range.end);
|
||||
if sh {
|
||||
l.shift_from(range.end, -range.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
cut_track(s, *t, range, sh, follow);
|
||||
}
|
||||
s.edit.selection = soundcraft_time::Range::point(range.start);
|
||||
}
|
||||
@@ -222,17 +306,39 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
|
||||
Ok(json!({"clips": n, "length": range.len()}))
|
||||
}
|
||||
|
||||
/// Remove `r` from a track, closing the gap in Shuffle mode; automation follows when `follow`.
|
||||
fn cut_track(s: &mut Session, t: TrackId, r: soundcraft_time::Range, sh: bool, follow: bool) {
|
||||
if follow && let Some(tr) = s.track_mut(t) {
|
||||
for l in &mut tr.automation {
|
||||
l.clear_range(r.start, r.end);
|
||||
}
|
||||
}
|
||||
ops::clear_range(s, t, r, sh);
|
||||
}
|
||||
|
||||
fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.paste", p)?;
|
||||
let at = position_param(e, "edit.paste", p, "at")?.unwrap_or(e.session().edit.selection.start);
|
||||
let given = position_param(e, "edit.paste", p, "at")?;
|
||||
let cb = e.clipboard.clone();
|
||||
let sh = shuffle(e.session());
|
||||
let follow = e.session().edit.automation_follows_edit;
|
||||
let s = e.session();
|
||||
let at = match given {
|
||||
Some(at) => at,
|
||||
None if s.edit.edit_mode == EditMode::GridRelative => {
|
||||
s.edit.grid.line_at_or_before(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate).saturating_add(cb.grid_offset)
|
||||
}
|
||||
None => s.edit.selection.start,
|
||||
};
|
||||
let sh = shuffle(s) && !cb.automation_only;
|
||||
let follow = s.edit.automation_follows_edit;
|
||||
let replace = Some(s.edit.selection).filter(|r| sh && given.is_none() && !r.is_empty());
|
||||
let s = e.session_mut();
|
||||
let mut pasted = 0;
|
||||
for (i, t) in tracks.iter().enumerate() {
|
||||
let Some(clips) = cb.tracks.get(i % cb.tracks.len().max(1)) else { continue };
|
||||
pasted += ops::paste(s, *t, at, clips, cb.length, sh).len();
|
||||
if let Some(r) = replace {
|
||||
cut_track(s, *t, r, true, follow);
|
||||
}
|
||||
pasted += ops::paste(s, *t, at, clips, cb.length, sh).map_err(|why| bad("edit.paste", why))?.len();
|
||||
if follow && let (Some(lanes), Some(tr)) = (cb.automation.get(i % cb.automation.len().max(1)), s.track_mut(*t)) {
|
||||
for l in lanes {
|
||||
let dst = tr.lane_mut(&l.param);
|
||||
@@ -329,7 +435,7 @@ fn repeat_to_fill(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
ops::paste(s, *t, at, &trimmed, (range.end - at).min(len), false);
|
||||
let _ = ops::paste(s, *t, at, &trimmed, (range.end - at).min(len), false);
|
||||
}
|
||||
}
|
||||
at += len;
|
||||
@@ -429,20 +535,25 @@ fn scale_selection(e: &mut Engine, f: f64) -> Result<Value> {
|
||||
Ok(json!({"selection": s.edit.selection}))
|
||||
}
|
||||
|
||||
/// Adds the nearest track above (`dir` < 0) or below the selected ones that the Edit window shows.
|
||||
fn extend_tracks(e: &mut Engine, dir: i64) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let s = e.session();
|
||||
let shown = |t: &&soundcraft_model::Track| !t.hidden && t.folder.and_then(|f| s.track(f)).is_none_or(|f| f.folder_open);
|
||||
let idxs: Vec<usize> = s.edit.selected_tracks.iter().filter_map(|t| s.track_index(*t)).collect();
|
||||
let next = if dir < 0 { idxs.iter().min().and_then(|i| i.checked_sub(1)) } else { idxs.iter().max().map(|i| i + 1) };
|
||||
if let Some(t) = next.and_then(|i| s.tracks.get(i)).map(|t| t.id) {
|
||||
let next = match (idxs.iter().min(), idxs.iter().max()) {
|
||||
(Some(&top), _) if dir < 0 => s.tracks.iter().take(top).rev().find(shown),
|
||||
(_, Some(&bottom)) if dir > 0 => s.tracks.iter().skip(bottom + 1).find(shown),
|
||||
(None, _) => s.tracks.iter().find(shown),
|
||||
_ => None,
|
||||
}
|
||||
.map(|t| t.id);
|
||||
let s = e.session_mut();
|
||||
if let Some(t) = next {
|
||||
if dir < 0 {
|
||||
s.edit.selected_tracks.insert(0, t);
|
||||
} else {
|
||||
s.edit.selected_tracks.push(t);
|
||||
}
|
||||
} else if idxs.is_empty()
|
||||
&& let Some(t) = s.tracks.first()
|
||||
{
|
||||
s.edit.selected_tracks.push(t.id);
|
||||
}
|
||||
Ok(json!({"tracks": s.edit.selected_tracks}))
|
||||
}
|
||||
@@ -453,12 +564,14 @@ fn duplicate(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if range.is_empty() {
|
||||
return Err(bad("edit.duplicate", "selection is empty"));
|
||||
}
|
||||
let sh = shuffle(e.session());
|
||||
let follow = e.session().edit.automation_follows_edit;
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for t in &tracks {
|
||||
let clips = ops::copy_range(s, *t, range);
|
||||
n += ops::paste(s, *t, range.end, &clips, range.len(), false).len();
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
n += ops::paste(s, *t, range.end, &clips, range.len(), sh).map_err(|why| bad("edit.duplicate", why))?.len();
|
||||
if follow && let Some(tr) = s.track_mut(*t) {
|
||||
for l in &mut tr.automation {
|
||||
let pts: Vec<_> = l.points.iter().filter(|p| range.contains(p.at)).copied().collect();
|
||||
for pt in pts {
|
||||
@@ -478,11 +591,13 @@ fn repeat(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if range.is_empty() {
|
||||
return Err(bad("edit.repeat", "selection is empty"));
|
||||
}
|
||||
let sh = shuffle(e.session());
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
let clips = ops::copy_range(s, *t, range);
|
||||
for k in 1..=count {
|
||||
ops::paste(s, *t, range.start + range.len() * k, &clips, range.len(), false);
|
||||
let at = range.start.saturating_add(range.len().saturating_mul(k));
|
||||
ops::paste(s, *t, at, &clips, range.len(), sh).map_err(|why| bad("edit.repeat", why))?;
|
||||
}
|
||||
}
|
||||
Ok(json!({"repeats": count}))
|
||||
@@ -493,19 +608,25 @@ fn shift(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let later = bool_or(p, "later", true);
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
let n = ops::move_clips(s, &ids, if later { by } else { -by }, None);
|
||||
let n = ops::move_clips(s, &ids, if later { by } else { -by }, &[]);
|
||||
Ok(json!({"moved": n}))
|
||||
}
|
||||
|
||||
fn insert_silence(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.insert_silence", p)?;
|
||||
let range = range_param(e, "edit.insert_silence", p)?;
|
||||
let sh = shuffle(e.session());
|
||||
let follow = e.session().edit.automation_follows_edit;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
ops::insert_silence(s, *t, range);
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
for l in &mut tr.automation {
|
||||
l.shift_from(range.start, range.len());
|
||||
if sh {
|
||||
ops::make_room(s, *t, range.start, range.len()).map_err(|why| bad("edit.insert_silence", why))?;
|
||||
} else {
|
||||
ops::clear_range(s, *t, range, false);
|
||||
if follow && let Some(tr) = s.track_mut(*t) {
|
||||
for l in &mut tr.automation {
|
||||
l.clear_range(range.start, range.end);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -591,13 +712,13 @@ fn trim_to_file(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for id in ids {
|
||||
let frames =
|
||||
s.find_clip(id).and_then(|(_, c)| c.source()).and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX));
|
||||
if let (Some(frames), Some(c)) = (frames, s.find_clip_mut(id)) {
|
||||
if let Some((frames, lo, hi)) = trim_limits(s, id)
|
||||
&& let Some(c) = s.find_clip_mut(id)
|
||||
{
|
||||
let off = c.source_offset();
|
||||
let new_start = c.start - off;
|
||||
c.trim_start_to(new_start);
|
||||
c.trim_end_to(new_start + frames);
|
||||
c.trim_start_to(new_start.max(lo));
|
||||
c.trim_end_to((new_start + frames).min(hi));
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
@@ -610,18 +731,31 @@ fn trim_to_fill(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for id in ids {
|
||||
let frames =
|
||||
s.find_clip(id).and_then(|(_, c)| c.source()).and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX));
|
||||
if let (Some(frames), Some(c)) = (frames, s.find_clip_mut(id)) {
|
||||
if let Some((frames, lo, hi)) = trim_limits(s, id)
|
||||
&& let Some(c) = s.find_clip_mut(id)
|
||||
{
|
||||
let file_start = c.start - c.source_offset();
|
||||
c.trim_start_to(r.start.max(file_start));
|
||||
c.trim_end_to(r.end.min(file_start + frames));
|
||||
c.trim_start_to(r.start.max(file_start).max(lo));
|
||||
c.trim_end_to(r.end.min(file_start + frames).min(hi));
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"trimmed": n}))
|
||||
}
|
||||
|
||||
/// How far a trim may extend an audio clip: its source length in frames, and the earliest
|
||||
/// start and latest end before it would run over the clip before or after it on the playlist
|
||||
/// (an extension never overwrites, like the trim tool). An overlap the clip already has with
|
||||
/// a neighbour (a crossfade) is kept rather than trimmed away.
|
||||
fn trim_limits(s: &Session, id: ClipId) -> Option<(Samples, Samples, Samples)> {
|
||||
let (track, c) = s.find_clip(id)?;
|
||||
let frames = c.source().and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX))?;
|
||||
let others = || s.track(track).into_iter().flat_map(|t| t.clips()).filter(|x| x.id != id);
|
||||
let lo = others().filter(|x| x.start < c.start).map(|x| x.end()).max().map_or(Samples::MIN, |end| end.min(c.start));
|
||||
let hi = others().filter(|x| x.start > c.start).map(|x| x.start).min().map_or(Samples::MAX, |start| start.max(c.end()));
|
||||
Some((frames, lo, hi))
|
||||
}
|
||||
|
||||
fn mute_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
@@ -665,17 +799,59 @@ fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let mut kept = 0;
|
||||
for t in &tracks {
|
||||
kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post);
|
||||
kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post)?;
|
||||
}
|
||||
Ok(json!({"clips": kept}))
|
||||
}
|
||||
|
||||
fn fades_create(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.fades_create", p)?;
|
||||
let r = range_param(e, "edit.fades_create", p)?;
|
||||
let shape = str_param(p, "shape")
|
||||
.map_or(Some(FadeShape::EqualPower), FadeShape::from_id)
|
||||
.ok_or_else(|| bad("edit.fades_create", "unknown fade shape"))?;
|
||||
if p.get("clip").is_some() || p.get("side").is_some() {
|
||||
let curvature = match p.get("curvature") {
|
||||
None => 0.0,
|
||||
Some(value) => value
|
||||
.as_f64()
|
||||
.filter(|v| v.is_finite() && (-1.0..=1.0).contains(v))
|
||||
.ok_or_else(|| bad("edit.fades_create", "curvature must be a finite number from -1 to 1"))? as f32,
|
||||
};
|
||||
if ["track", "tracks", "clips", "start", "end"].iter().any(|key| p.get(key).is_some()) {
|
||||
return Err(bad("edit.fades_create", "clip fades cannot also specify tracks or an edit range"));
|
||||
}
|
||||
let id = p.get("clip").and_then(Value::as_u64).map(soundcraft_model::ClipId).ok_or_else(|| bad("edit.fades_create", "clip must be an id"))?;
|
||||
let fade_in = match str_param(p, "side") {
|
||||
Some("in") => true,
|
||||
Some("out") => false,
|
||||
_ => return Err(bad("edit.fades_create", "side must be in or out")),
|
||||
};
|
||||
let length = p
|
||||
.get("length")
|
||||
.and_then(Value::as_i64)
|
||||
.filter(|len| *len > 0)
|
||||
.ok_or_else(|| bad("edit.fades_create", "length must be a positive sample count"))?;
|
||||
let (_, clip) = e.session().find_clip(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?;
|
||||
let available = clip.length.min(clip.end().saturating_sub(clip.start));
|
||||
if available <= 0 {
|
||||
return Err(bad("edit.fades_create", "clip has no length"));
|
||||
}
|
||||
if p.get("shape").is_some_and(|v| !v.is_string()) {
|
||||
return Err(bad("edit.fades_create", "shape must be a fade shape name"));
|
||||
}
|
||||
let fade = Fade { len: length.min(available), shape, curvature };
|
||||
let clip = e.session_mut().find_clip_mut(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?;
|
||||
if fade_in {
|
||||
clip.fade_in = fade;
|
||||
} else {
|
||||
clip.fade_out = fade;
|
||||
}
|
||||
return Ok(json!({"fades": 1}));
|
||||
}
|
||||
if p.get("curvature").is_some() {
|
||||
return Err(bad("edit.fades_create", "curvature requires a clip and side"));
|
||||
}
|
||||
let tracks = tracks_required(e, "edit.fades_create", p)?;
|
||||
let r = range_param(e, "edit.fades_create", p)?;
|
||||
let s = e.session_mut();
|
||||
let n: usize = tracks.iter().map(|t| ops::create_fades(s, *t, r, shape)).sum();
|
||||
if n == 0 {
|
||||
@@ -715,10 +891,11 @@ fn nudge(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let dir = i64_or(p, "direction", 1).signum();
|
||||
let count = i64_or(p, "count", 1).clamp(1, 1000);
|
||||
let s = e.session();
|
||||
let step = s.edit.nudge.step_samples(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate) * count * if dir == 0 { 1 } else { dir };
|
||||
let value = if bool_or(p, "next", false) { s.edit.nudge.stepped(true) } else { s.edit.nudge };
|
||||
let step = value.step_samples(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate) * count * if dir == 0 { 1 } else { dir };
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
let n = if ids.is_empty() { 0 } else { ops::move_clips(s, &ids, step, None) };
|
||||
let n = if ids.is_empty() { 0 } else { ops::move_clips(s, &ids, step, &[]) };
|
||||
s.edit.selection = s.edit.selection.shifted(step);
|
||||
if s.edit.selection.start < 0 {
|
||||
s.edit.selection = s.edit.selection.shifted(-s.edit.selection.start);
|
||||
@@ -731,44 +908,183 @@ fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad("edit.move_clips", "no clips"));
|
||||
}
|
||||
let to_track = track_param(e, "edit.move_clips", p, "track")?;
|
||||
let first = ids.iter().filter_map(|id| e.session().find_clip(*id).map(|(_, c)| c.start)).min().unwrap_or(0);
|
||||
let s = e.session();
|
||||
let dest = match track_param(e, "edit.move_clips", p, "track")? {
|
||||
Some(to) => ids
|
||||
.iter()
|
||||
.find_map(|c| s.find_clip(*c))
|
||||
.and_then(|(from, _)| ops::destinations(s, &ids, from, to))
|
||||
.ok_or_else(|| bad("edit.move_clips", "the clips can't land there: past the last track, or on a track that can't hold them"))?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
let first = ops::first_movable(s, &ids);
|
||||
let delta = match (position_param(e, "edit.move_clips", p, "by")?, position_param(e, "edit.move_clips", p, "to")?) {
|
||||
(Some(by), _) => by,
|
||||
(None, Some(to)) => to - first,
|
||||
(None, Some(to)) => to.saturating_sub(first.map_or(0, |c| c.start)),
|
||||
_ => 0,
|
||||
};
|
||||
// Grid mode snaps the destination.
|
||||
let s = e.session();
|
||||
let delta = if s.edit.edit_mode.is_grid() && p.get("to").is_some() {
|
||||
s.edit.grid.snap(first + delta, s.sample_rate, &s.tempo, s.frame_rate) - first
|
||||
} else {
|
||||
delta
|
||||
let mode = s.edit.edit_mode;
|
||||
let delta = match first {
|
||||
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative),
|
||||
_ => delta,
|
||||
};
|
||||
let s = e.session_mut();
|
||||
let n = ops::move_clips(s, &ids, delta, to_track);
|
||||
let n = if mode == EditMode::Shuffle {
|
||||
ops::shuffle_clips(s, &ids, delta, &dest).map_err(|why| bad("edit.move_clips", why))?
|
||||
} else {
|
||||
ops::move_clips(s, &ids, delta, &dest)
|
||||
};
|
||||
s.edit.selected_clips = ids;
|
||||
Ok(json!({"moved": n, "by": delta}))
|
||||
}
|
||||
|
||||
fn trim_clip(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = clip_id(p, "edit.trim_clip")?;
|
||||
let start = position_param(e, "edit.trim_clip", p, "start")?;
|
||||
let end = position_param(e, "edit.trim_clip", p, "end")?;
|
||||
if start.is_none() && end.is_none() {
|
||||
return Err(bad("edit.trim_clip", "pass `start` and/or `end`"));
|
||||
}
|
||||
let sh = shuffle(e.session());
|
||||
let c = ops::trim_clip(e.session_mut(), id, start, end, sh).ok_or_else(|| bad("edit.trim_clip", "no such clip, or it is edit-locked"))?;
|
||||
Ok(json!({"start": c.start, "end": c.end()}))
|
||||
}
|
||||
|
||||
fn consolidate(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.consolidate", p)?;
|
||||
let r = range_param(e, "edit.consolidate", p)?;
|
||||
if r.is_empty() {
|
||||
return Err(bad("edit.consolidate", "selection is empty"));
|
||||
}
|
||||
let midi = tracks.iter().filter(|&&t| crate::edit::consolidate_midi(e.session_mut(), t, r)).count();
|
||||
let mut n = 0;
|
||||
for t in tracks {
|
||||
if crate::io::consolidate(e, t, r)? {
|
||||
if crate::io::consolidate(e, "edit.consolidate", t, r)? {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"consolidated": n}))
|
||||
Ok(json!({"consolidated": n + midi}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use soundcraft_model::{ChannelFormat, Clip, ClipContent, TrackId, TrackKind};
|
||||
|
||||
#[test]
|
||||
fn targeted_fade_curvature_saves_reopens_and_undoes_without_changing_lengths() {
|
||||
for side in ["in", "out"] {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone();
|
||||
e.execute("edit.fades_create", &json!({"clip": clip.id.0, "side": side, "length": clip.length / 2, "shape": "s-curve"})).unwrap();
|
||||
let before = e.session().clone();
|
||||
let undo_len = e.undo_history().len();
|
||||
e.execute(
|
||||
"edit.fades_create",
|
||||
&json!({"clip": clip.id.0, "side": side, "length": clip.length / 2, "shape": "s-curve", "curvature": -0.65}),
|
||||
)
|
||||
.unwrap();
|
||||
let mut expected = before.clone();
|
||||
let c = expected.find_clip_mut(clip.id).unwrap();
|
||||
if side == "in" {
|
||||
c.fade_in.curvature = -0.65;
|
||||
} else {
|
||||
c.fade_out.curvature = -0.65;
|
||||
}
|
||||
assert_eq!(e.session(), &expected);
|
||||
let reopened = Session::from_json(&e.session().to_json().unwrap()).unwrap();
|
||||
let reopened = reopened.find_clip(clip.id).unwrap().1;
|
||||
let stored = expected.find_clip(clip.id).unwrap().1;
|
||||
assert_eq!(reopened.fade_in, stored.fade_in);
|
||||
assert_eq!(reopened.fade_out, stored.fade_out);
|
||||
assert_eq!(e.undo_history().len(), undo_len + 1);
|
||||
assert!(e.undo());
|
||||
assert_eq!(e.session(), &before);
|
||||
assert!(e.redo());
|
||||
assert_eq!(e.session(), &expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeted_fades_fill_only_the_requested_side_and_undo_once() {
|
||||
for side in ["in", "out"] {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone();
|
||||
e.execute("edit.select_none", &json!({})).unwrap();
|
||||
let before = e.session().clone();
|
||||
let undo_len = e.undo_history().len();
|
||||
assert_eq!(
|
||||
e.execute("edit.fades_create", &json!({"clip": clip.id.0, "side": side, "length": i64::MAX, "shape": "s-curve"})).unwrap(),
|
||||
json!({"fades": 1})
|
||||
);
|
||||
let mut expected = before.clone();
|
||||
let c = expected.find_clip_mut(clip.id).unwrap();
|
||||
let fade = Fade { len: clip.length, shape: FadeShape::SCurve, curvature: 0.0 };
|
||||
if side == "in" {
|
||||
c.fade_in = fade;
|
||||
} else {
|
||||
c.fade_out = fade;
|
||||
}
|
||||
assert_eq!(e.session(), &expected);
|
||||
assert_eq!(e.undo_history().len(), undo_len + 1);
|
||||
assert!(e.undo());
|
||||
assert_eq!(e.session(), &before);
|
||||
assert!(e.redo());
|
||||
assert_eq!(e.session(), &expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeted_fades_reject_invalid_parameters_without_an_edit() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let id = e.session().track_by_name("Kick").unwrap().clips()[0].id.0;
|
||||
for params in [
|
||||
json!({"clip": id, "side": "in"}),
|
||||
json!({"clip": id, "length": 10}),
|
||||
json!({"side": "out", "length": 10}),
|
||||
json!({"clip": "bad", "side": "in", "length": 10}),
|
||||
json!({"clip": u64::MAX, "side": "in", "length": 10}),
|
||||
json!({"clip": id, "side": "bad", "length": 10}),
|
||||
json!({"clip": id, "side": "in", "length": 0}),
|
||||
json!({"clip": id, "side": "in", "length": -1}),
|
||||
json!({"clip": id, "side": "in", "length": 1.5}),
|
||||
json!({"clip": id, "side": "in", "length": "10"}),
|
||||
json!({"clip": id, "side": "in", "length": u64::MAX}),
|
||||
json!({"clip": id, "side": "in", "length": 10, "shape": "bad"}),
|
||||
json!({"clip": id, "side": "in", "length": 10, "shape": null}),
|
||||
json!({"clip": id, "side": "in", "length": 10, "start": 0}),
|
||||
json!({"clip": id, "side": "in", "length": 10, "end": 100}),
|
||||
json!({"clip": id, "side": "in", "length": 10, "tracks": []}),
|
||||
json!({"clip": id, "side": "in", "length": 10, "curvature": 2}),
|
||||
json!({"clip": id, "side": "in", "length": 10, "curvature": -2}),
|
||||
json!({"clip": id, "side": "in", "length": 10, "curvature": 1e100}),
|
||||
json!({"clip": id, "side": "in", "length": 10, "curvature": null}),
|
||||
json!({"clip": id, "side": "in", "length": 10, "curvature": "0.5"}),
|
||||
json!({"start": 0, "end": 100, "curvature": 0.5}),
|
||||
] {
|
||||
let before = e.session().clone();
|
||||
let history = e.undo_history();
|
||||
assert!(e.execute("edit.fades_create", ¶ms).is_err(), "{params}");
|
||||
assert_eq!(e.session(), &before, "{params}");
|
||||
assert_eq!(e.undo_history(), history, "{params}");
|
||||
}
|
||||
e.session_mut().find_clip_mut(soundcraft_model::ClipId(id)).unwrap().length = 0;
|
||||
let before = e.session().clone();
|
||||
assert!(e.execute("edit.fades_create", &json!({"clip": id, "side": "in", "length": 1})).is_err());
|
||||
assert_eq!(e.session(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeted_fades_support_one_sample_clips() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let id = e.session().track_by_name("Kick").unwrap().clips()[0].id;
|
||||
e.session_mut().find_clip_mut(id).unwrap().length = 1;
|
||||
for side in ["in", "out"] {
|
||||
e.execute("edit.fades_create", &json!({"clip": id.0, "side": side, "length": 1})).unwrap();
|
||||
let clip = e.session().find_clip(id).unwrap().1;
|
||||
assert_eq!(if side == "in" { clip.fade_in.len } else { clip.fade_out.len }, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_what_is_already_selected_leaves_the_document_alone() {
|
||||
@@ -782,4 +1098,119 @@ mod tests {
|
||||
e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 100, "end": 300})).unwrap();
|
||||
assert_ne!(e.revision, rev);
|
||||
}
|
||||
|
||||
fn keys() -> (Engine, TrackId, impl Fn(i64) -> i64) {
|
||||
let mut e = Engine::default();
|
||||
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
|
||||
let (tempo, sr) = (e.session().tempo.clone(), e.session().sample_rate);
|
||||
(e, t, move |q| tempo.tick_to_samples(q * soundcraft_time::TICKS_PER_QUARTER, sr))
|
||||
}
|
||||
|
||||
fn note(start: i64, length: i64, pitch: u8) -> soundcraft_midi::Note {
|
||||
let q = soundcraft_time::TICKS_PER_QUARTER;
|
||||
soundcraft_midi::Note { pitch, velocity: 100, release_velocity: 64, channel: 0, start: start * q, length: length * q }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidating_an_empty_midi_selection_makes_an_empty_clip() {
|
||||
let (mut e, t, at) = keys();
|
||||
e.execute("edit.select", &json!({"tracks": [t.0], "start": at(4), "end": at(12)})).unwrap();
|
||||
assert_eq!(e.execute("edit.consolidate", &json!({})).unwrap()["consolidated"], 1);
|
||||
let clips = e.session().track(t).unwrap().clips().to_vec();
|
||||
assert_eq!(clips.iter().map(|c| (c.start, c.end())).collect::<Vec<_>>(), vec![(at(4), at(12))]);
|
||||
assert!(matches!(&clips[0].content, ClipContent::Midi { sequence } if sequence.notes.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidating_midi_merges_the_clips_as_they_sound() {
|
||||
let (mut e, t, at) = keys();
|
||||
let s = e.session_mut();
|
||||
let cc = soundcraft_midi::CtrlEvent { tick: 0, channel: 0, kind: soundcraft_midi::CtrlKind::Cc(1), value: 64 };
|
||||
let a = soundcraft_midi::Sequence { notes: vec![note(0, 1, 60), note(3, 2, 62), note(5, 1, 63)], ctrls: vec![] };
|
||||
let b = soundcraft_midi::Sequence { notes: vec![note(0, 1, 64)], ctrls: vec![] };
|
||||
let c = soundcraft_midi::Sequence { notes: vec![note(1, 1, 65)], ctrls: vec![cc] };
|
||||
for (start, end, seq, muted) in [(0, 4, a, false), (6, 8, b, true), (8, 12, c, false)] {
|
||||
let id = s.new_clip_id();
|
||||
let mut clip = Clip::midi(id, "Keys", at(start), at(end) - at(start), seq);
|
||||
clip.muted = muted;
|
||||
ops::place_clip(s, t, clip);
|
||||
}
|
||||
e.execute("edit.select", &json!({"tracks": [t.0], "start": at(2), "end": at(10)})).unwrap();
|
||||
e.execute("edit.consolidate", &json!({})).unwrap();
|
||||
let clips = e.session().track(t).unwrap().clips().to_vec();
|
||||
assert_eq!(clips.iter().map(|c| (c.start, c.end())).collect::<Vec<_>>(), vec![(0, at(2)), (at(2), at(10)), (at(10), at(12))]);
|
||||
let ClipContent::Midi { sequence } = &clips[1].content else { panic!("not MIDI") };
|
||||
assert_eq!(sequence.notes, vec![note(1, 1, 62), note(7, 1, 65)]);
|
||||
assert_eq!(sequence.ctrls, vec![soundcraft_midi::CtrlEvent { tick: 6 * soundcraft_time::TICKS_PER_QUARTER, ..cc }]);
|
||||
}
|
||||
|
||||
/// A 60,000-frame source with `clips` placed on one track as (start, length, source offset).
|
||||
fn trim_session(clips: &[(Samples, Samples, Samples)]) -> (Engine, TrackId, Vec<ClipId>) {
|
||||
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![vec![0.25; 60_000]] };
|
||||
let src = crate::io::add_source(&mut s, "src", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let mut ids = Vec::new();
|
||||
for &(start, len, off) in clips {
|
||||
let id = s.new_clip_id();
|
||||
ops::place_clip(&mut s, t, Clip::audio(id, "c", src, off, start, len));
|
||||
ids.push(id);
|
||||
}
|
||||
(Engine::new(s), t, ids)
|
||||
}
|
||||
|
||||
fn bounds(e: &Engine, t: TrackId) -> Vec<(Samples, Samples, Samples)> {
|
||||
e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end(), c.source_offset())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_to_file_boundaries_stops_at_the_neighbours() {
|
||||
let (mut e, t, ids) = trim_session(&[(0, 10_000, 0), (20_000, 10_000, 20_000), (40_000, 5_000, 0)]);
|
||||
e.execute("edit.trim_to_file_boundaries", &json!({"clips": [ids[1].0]})).unwrap();
|
||||
assert_eq!(bounds(&e, t), vec![(0, 10_000, 0), (10_000, 40_000, 10_000), (40_000, 45_000, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_to_fill_selection_stops_at_the_neighbours() {
|
||||
let (mut e, t, ids) = trim_session(&[(0, 10_000, 0), (20_000, 10_000, 20_000), (40_000, 5_000, 0)]);
|
||||
e.execute("edit.trim_to_fill_selection", &json!({"clips": [ids[1].0], "start": 5_000, "end": 45_000})).unwrap();
|
||||
assert_eq!(bounds(&e, t), vec![(0, 10_000, 0), (10_000, 40_000, 10_000), (40_000, 45_000, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_without_neighbours_expand_fully() {
|
||||
let (mut e, t, ids) = trim_session(&[(20_000, 10_000, 20_000)]);
|
||||
e.execute("edit.trim_to_file_boundaries", &json!({"clips": [ids[0].0]})).unwrap();
|
||||
assert_eq!(bounds(&e, t), vec![(0, 60_000, 0)]);
|
||||
let (mut e, t, ids) = trim_session(&[(20_000, 10_000, 20_000)]);
|
||||
e.execute("edit.trim_to_fill_selection", &json!({"clips": [ids[0].0], "start": 5_000, "end": 45_000})).unwrap();
|
||||
assert_eq!(bounds(&e, t), vec![(5_000, 45_000, 5_000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_to_file_boundaries_keeps_an_existing_crossfade() {
|
||||
let (mut e, t, ids) = trim_session(&[(0, 10_000, 0), (10_000, 10_000, 10_000)]);
|
||||
e.execute("edit.fades_create", &json!({"tracks": [t], "start": 9_500, "end": 10_500})).unwrap();
|
||||
let faded = bounds(&e, t);
|
||||
assert_eq!((faded[0].1, faded[1].0), (10_500, 9_500), "the fixture needs an overlapping crossfade");
|
||||
e.execute("edit.trim_to_file_boundaries", &json!({"clips": [ids[1].0]})).unwrap();
|
||||
assert_eq!(bounds(&e, t), vec![faded[0], (9_500, 60_000, 9_500)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_last_selection_swaps_back_to_the_previous_range_and_tracks() {
|
||||
let mut e = Engine::default();
|
||||
assert!(e.execute("edit.restore_last_selection", &json!({})).is_err(), "nothing to restore yet");
|
||||
let ids = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone();
|
||||
e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 1_000, "end": 4_000})).unwrap();
|
||||
e.execute("edit.select", &json!({"tracks": [ids[1]], "start": 7_000, "end": 9_000})).unwrap();
|
||||
let tracks = |e: &Engine| e.session().edit.selected_tracks.iter().map(|t| t.0).collect::<Vec<_>>();
|
||||
e.execute("edit.restore_last_selection", &json!({})).unwrap();
|
||||
assert_eq!(e.session().edit.selection, soundcraft_time::Range::new(1_000, 4_000));
|
||||
assert_eq!(json!(tracks(&e)), json!([ids[0]]));
|
||||
e.execute("edit.restore_last_selection", &json!({})).unwrap();
|
||||
assert_eq!(e.session().edit.selection, soundcraft_time::Range::new(7_000, 9_000));
|
||||
assert_eq!(json!(tracks(&e)), json!([ids[1]]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! More Edit menu commands: Cut/Copy/Clear Special variants, Paste Special, selection helpers,
|
||||
//! Space Clips, Snap to, Copy/Move Selection to playlists, TCE, and automation coalesce/trim/glide.
|
||||
use super::{
|
||||
CommandSpec, always, bad, clip_ids_param, has_clipboard, has_range, has_selection, position_param, range_param, str_param, tracks_required,
|
||||
};
|
||||
|
||||
use super::more_util::{clip_db, render_stretched, replace_clip, set_clip_env, target_playlist};
|
||||
use super::*;
|
||||
use crate::edit as ops;
|
||||
use crate::{Clipboard, TransportRequest, cmd};
|
||||
use serde_json::json;
|
||||
use crate::{Clipboard, Engine, Result, TransportRequest, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{AutoParam, AutomationLane, Clip, ClipContent, ClipId, EditMode, Playlist, Session};
|
||||
use soundcraft_time::{Range, Samples};
|
||||
|
||||
@@ -178,7 +180,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"edit.copy_to_target_playlist",
|
||||
"Target Playlist",
|
||||
["Edit", "Copy Selection to..."],
|
||||
None,
|
||||
Some("Alt+Shift+Up"),
|
||||
"{tracks?, start?, end?} — copies the selection into each track's target playlist",
|
||||
has_range,
|
||||
|e, p| to_playlist(e, p, Dest::Target, false, "edit.copy_to_target_playlist")
|
||||
@@ -196,14 +198,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"edit.move_to_target_playlist",
|
||||
"Target Playlist",
|
||||
["Edit", "Move Selection to..."],
|
||||
None,
|
||||
Some("Alt+Shift+T"),
|
||||
"{tracks?, start?, end?} — copies to the target playlist and clears it from the main",
|
||||
has_range,
|
||||
|e, p| to_playlist(e, p, Dest::Target, true, "edit.move_to_target_playlist")
|
||||
),
|
||||
cmd!("edit.move_to_new_playlist", "New Playlist", ["Edit", "Move Selection to..."], None, "{tracks?, start?, end?}", has_range, |e, p| {
|
||||
to_playlist(e, p, Dest::New, true, "edit.move_to_new_playlist")
|
||||
}),
|
||||
cmd!(
|
||||
"edit.move_to_new_playlist",
|
||||
"New Playlist",
|
||||
["Edit", "Move Selection to..."],
|
||||
Some("Alt+Shift+N"),
|
||||
"{tracks?, start?, end?}",
|
||||
has_range,
|
||||
|e, p| to_playlist(e, p, Dest::New, true, "edit.move_to_new_playlist")
|
||||
),
|
||||
cmd!(
|
||||
"edit.move_to_duplicate_playlist",
|
||||
"Duplicate of Main Playlist",
|
||||
@@ -284,7 +292,7 @@ pub(super) fn cut_all_automation(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Result<Value> {
|
||||
let tracks = tracks_required(e, id, p)?;
|
||||
let r = nonempty_range(e, p, id)?;
|
||||
let mut cb = Clipboard { length: r.len(), ..Clipboard::default() };
|
||||
let mut cb = Clipboard { automation_only: true, ..Clipboard::of_range(e.session(), r) };
|
||||
let mut lanes_n = 0;
|
||||
for t in &tracks {
|
||||
cb.tracks.push(Vec::new());
|
||||
@@ -326,7 +334,7 @@ fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Re
|
||||
Ok(json!({"lanes": lanes_n, "length": r.len()}))
|
||||
}
|
||||
|
||||
fn copy_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
pub(super) fn copy_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "edit.copy_clip_gain";
|
||||
let tracks = tracks_required(e, id, p)?;
|
||||
let r = nonempty_range(e, p, id)?;
|
||||
@@ -545,12 +553,13 @@ fn duplicate_extend(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "edit.duplicate_extend";
|
||||
let tracks = tracks_required(e, id, p)?;
|
||||
let r = nonempty_range(e, p, id)?;
|
||||
let (sh, follow) = (shuffle(e.session()), e.session().edit.automation_follows_edit);
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for t in &tracks {
|
||||
let clips = ops::copy_range(s, *t, r);
|
||||
n += ops::paste(s, *t, r.end, &clips, r.len(), false).len();
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
n += ops::paste(s, *t, r.end, &clips, r.len(), sh).map_err(|why| bad(id, why))?.len();
|
||||
if follow && let Some(tr) = s.track_mut(*t) {
|
||||
for l in &mut tr.automation {
|
||||
let pts: Vec<_> = l.points.iter().filter(|pt| r.contains(pt.at)).copied().collect();
|
||||
for pt in pts {
|
||||
@@ -677,7 +686,7 @@ fn snap(e: &mut Engine, p: &Value, next: bool) -> Result<Value> {
|
||||
others.iter().filter(|x| x.end() <= c.start).map(Clip::end).max().map(|en| en - c.start)
|
||||
};
|
||||
if let Some(d) = delta.filter(|d| *d != 0) {
|
||||
moved += ops::move_clips(s, &[id], d, None);
|
||||
moved += ops::move_clips(s, &[id], d, &[]);
|
||||
}
|
||||
}
|
||||
Ok(json!({"moved": moved}))
|
||||
@@ -1044,6 +1053,22 @@ mod tests {
|
||||
assert!((clip_db(&c, 500) + 6.0).abs() < 1e-3, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_clip_gain_can_be_pasted() {
|
||||
let (mut e, t, ids) = session_with_clips(&[(0, 1000), (2000, 1000), (4000, 1000)]);
|
||||
e.execute("clip.gain", &json!({"clip": ids[0].0, "db": -6.0})).unwrap();
|
||||
e.execute("clip.gain", &json!({"clip": ids[2].0, "db": -12.0})).unwrap();
|
||||
// An older copy sits in the clipboard: the paste must get the cut gain, not this one.
|
||||
e.execute("edit.copy_clip_gain", &json!({"start": 4000, "end": 5000})).unwrap();
|
||||
e.execute("edit.select", &json!({"tracks": [t.0], "clips": [ids[0].0], "start": 0, "end": 1000})).unwrap();
|
||||
e.execute("edit.cut_clip_gain", &json!({})).unwrap();
|
||||
let src = e.session().track(t).unwrap().clips()[0].clone();
|
||||
assert!(clip_db(&src, 500).abs() < 1e-3, "cut resets the source: {src:?}");
|
||||
e.execute("edit.paste_clip_gain", &json!({"at": 2000})).unwrap();
|
||||
let c = e.session().track(t).unwrap().clips()[1].clone();
|
||||
assert!((clip_db(&c, 500) + 6.0).abs() < 1e-3, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_to_new_playlist_clears_main() {
|
||||
let (mut e, t, _) = session_with_clips(&[(0, 1000)]);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Event menu: time/tempo operations, MIDI operations, markers.
|
||||
use super::{CommandSpec, always, bad, clip_ids_param, f32_or, f64_or, has_selection, has_tracks, i64_or, position_param, range_param, str_param};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_midi::ops::{DurationOp, QuantizeOptions, VelocityOp};
|
||||
use soundcraft_model::{ClipContent, MarkerKind};
|
||||
use soundcraft_time::TICKS_PER_QUARTER;
|
||||
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
e.session_mut().tempo.scale_tempos(f).map_err(|err| bad("event.tempo_scale", err.to_string()))?;
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("event.tempo_constant", "Constant...", ["Event", "Tempo Operations"], None, "{bpm, start?, end?}", always, tempo),
|
||||
cmd!("event.tempo_constant", "Constant...", ["Event", "Tempo Operations"], None, "{bpm, start?, end?}", always, tempo_constant),
|
||||
cmd!("event.insert_time", "Insert Time...", ["Event", "Time Operations"], None, "{start?, length | end}", has_tracks, insert_time),
|
||||
cmd!("event.cut_time", "Cut Time...", ["Event", "Time Operations"], None, "{start?, end?}", has_tracks, cut_time),
|
||||
cmd!(
|
||||
@@ -94,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
add_marker
|
||||
),
|
||||
cmd!("markers.delete", "Delete Memory Location", [], None, "{number | id}", always, delete_marker),
|
||||
cmd!("markers.edit", "Edit Memory Location", [], None, "{number | id, name?, comments?, at?, color?}", always, edit_marker),
|
||||
cmd!("markers.edit", "Edit Memory Location", [], None, "{number | id, name?, comments?, at?, color?: [r,g,b] | null}", always, edit_marker),
|
||||
cmd!(noundo "markers.recall", "Recall Memory Location", [], Some("Period + n + Period"), "{number}", always, recall_marker),
|
||||
cmd!(noundo "markers.next", "Go to Next Marker", [], None, "{}", always, |e, _| step_marker(e, 1)),
|
||||
cmd!(noundo "markers.previous", "Go to Previous Marker", [], None, "{}", always, |e, _| step_marker(e, -1)),
|
||||
@@ -110,6 +110,30 @@ fn tempo(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"tick": tick, "bpm": bpm}))
|
||||
}
|
||||
|
||||
fn tempo_constant(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "event.tempo_constant";
|
||||
let r = range_param(e, id, p)?;
|
||||
let s = e.session();
|
||||
let (t0, t1) = (s.tempo.samples_to_ticks(r.start, s.sample_rate), s.tempo.samples_to_ticks(r.end, s.sample_rate));
|
||||
if t1 <= t0 {
|
||||
return tempo(e, p);
|
||||
}
|
||||
let bpm = f64_or(p, "bpm", f64::NAN);
|
||||
let s = e.session_mut();
|
||||
// Read before the edit: the end is a musical position, and the new tempo moves it in samples.
|
||||
let after = s.tempo.tempo_at_tick(t1);
|
||||
let had_end = s.tempo.tempos().iter().any(|x| x.tick == t1);
|
||||
s.tempo.set_tempo(t0, bpm).map_err(|err| bad(id, err.to_string()))?;
|
||||
let inside: Vec<i64> = s.tempo.tempos().iter().map(|x| x.tick).filter(|t| *t > t0 && *t < t1).collect();
|
||||
for t in inside {
|
||||
s.tempo.remove_tempo(t);
|
||||
}
|
||||
if !had_end {
|
||||
s.tempo.set_tempo(t1, after).map_err(|err| bad(id, err.to_string()))?;
|
||||
}
|
||||
Ok(json!({"tick": t0, "end_tick": t1, "bpm": bpm}))
|
||||
}
|
||||
|
||||
fn meter(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let num = i64_or(p, "numerator", 4).clamp(1, 64) as u32;
|
||||
let den = i64_or(p, "denominator", 4).clamp(1, 64) as u32;
|
||||
@@ -152,7 +176,8 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
|
||||
for t in ids {
|
||||
crate::edit::clear_range(s, t, r, true);
|
||||
crate::edit::clear_range(s, t, r, false);
|
||||
crate::edit::shift_from(s, t, r.end, -r.len());
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
for l in &mut tr.automation {
|
||||
l.clear_range(r.start, r.end);
|
||||
@@ -275,6 +300,7 @@ fn add_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let (a, b) = match (at, st, en) {
|
||||
(Some(a), _, _) => (a, a),
|
||||
(None, Some(a), Some(b)) => (a, b),
|
||||
(None, Some(p), None) | (None, None, Some(p)) => (p, p),
|
||||
_ if kind == MarkerKind::Selection => (sel.start, sel.end),
|
||||
_ => (if e.transport.playing { e.transport.position } else { sel.start }, sel.start),
|
||||
};
|
||||
@@ -312,9 +338,25 @@ fn delete_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn edit_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let i = marker_index(e, p, "markers.edit")?;
|
||||
let at = position_param(e, "markers.edit", p, "at")?;
|
||||
let color = match p.get("color") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::Array(a)) if a.len() == 3 => {
|
||||
let ch = |i: usize| a.get(i).and_then(Value::as_u64).and_then(|v| u8::try_from(v).ok());
|
||||
match (ch(0), ch(1), ch(2)) {
|
||||
(Some(r), Some(g), Some(b)) => Some(Some([r, g, b])),
|
||||
_ => return Err(bad("markers.edit", "`color` components must be integers 0..=255")),
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(bad("markers.edit", "`color` must be [r,g,b] or null")),
|
||||
};
|
||||
let s = e.session_mut();
|
||||
let Some(m) = s.markers.get_mut(i) else { return Err(bad("markers.edit", "gone")) };
|
||||
if let Some(n) = p.get("rename").and_then(Value::as_str) {
|
||||
if let Some(c) = color {
|
||||
m.color = c;
|
||||
}
|
||||
// `name` is the documented new name; when it was the selector it already matches, so this is a no-op.
|
||||
if let Some(n) = str_param(p, "rename").or_else(|| str_param(p, "name")) {
|
||||
m.name = n.to_string();
|
||||
}
|
||||
if let Some(c) = str_param(p, "comments") {
|
||||
@@ -376,4 +418,101 @@ mod tests {
|
||||
let tick = s.tempo.samples_to_ticks(at, s.sample_rate);
|
||||
assert!((s.tempo.tempo_at_tick(tick) - 133.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// Changing the initial meter keeps a later meter change at its bar (issue #250).
|
||||
#[test]
|
||||
fn earlier_meter_change_keeps_later_meter_on_its_bar() {
|
||||
use soundcraft_time::BarBeat;
|
||||
let mut e = crate::Engine::default();
|
||||
assert_eq!(e.session().sample_rate.hz(), 48_000);
|
||||
e.execute("event.meter", &json!({"numerator": 3, "denominator": 4, "at": 192_000})).unwrap();
|
||||
e.execute("event.meter", &json!({"numerator": 6, "denominator": 4, "at": 0})).unwrap();
|
||||
let map = &e.session().tempo;
|
||||
let later = map.meters().get(1).copied().unwrap();
|
||||
assert_eq!((later.numerator, later.denominator), (3, 4));
|
||||
assert_eq!(map.bar_beat_at_tick(later.tick), BarBeat { bar: 3, beat: 1, tick: 0 });
|
||||
let bar2_beat3 = BarBeat { bar: 2, beat: 3, tick: 0 };
|
||||
assert_eq!(map.bar_beat_at_tick(map.tick_at_bar_beat(bar2_beat3)), bar2_beat3);
|
||||
}
|
||||
|
||||
fn marker_name(e: &crate::Engine, id: u64) -> String {
|
||||
e.session().markers.iter().find(|m| m.id.0 == id).map(|m| m.name.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `markers.edit` documents `name`: with `id` or `number` selecting the location, it is the new name.
|
||||
#[test]
|
||||
fn edit_marker_renames_with_documented_name() {
|
||||
let mut e = crate::Engine::default();
|
||||
let verse = e.execute("markers.add", &json!({"name": "Verse", "at": 1000})).unwrap();
|
||||
let outro = e.execute("markers.add", &json!({"name": "Outro", "at": 3000})).unwrap();
|
||||
let (verse_id, outro_id) = (verse["id"].as_u64().unwrap(), outro["id"].as_u64().unwrap());
|
||||
|
||||
e.execute("markers.edit", &json!({"id": verse_id, "name": "Chorus"})).unwrap();
|
||||
assert_eq!((marker_name(&e, verse_id), marker_name(&e, outro_id)), ("Chorus".into(), "Outro".into()));
|
||||
|
||||
e.execute("markers.edit", &json!({"number": outro["number"], "name": "Bridge"})).unwrap();
|
||||
assert_eq!((marker_name(&e, verse_id), marker_name(&e, outro_id)), ("Chorus".into(), "Bridge".into()));
|
||||
|
||||
e.execute("markers.edit", &json!({"name": "Bridge", "comments": "last"})).unwrap();
|
||||
let s = e.session();
|
||||
let bridge = s.markers.iter().find(|m| m.id.0 == outro_id).unwrap();
|
||||
assert_eq!((bridge.name.as_str(), bridge.comments.as_str()), ("Bridge", "last"));
|
||||
}
|
||||
|
||||
/// `markers.add` with only `start` or only `end` places the marker there, not at the edit selection.
|
||||
#[test]
|
||||
fn markers_add_start_or_end_alone_is_used() {
|
||||
let mut e = demo_engine();
|
||||
assert_ne!(e.session().edit.selection.start, 96_000);
|
||||
let a = e.execute("markers.add", &json!({"name": "A", "start": 96_000})).unwrap();
|
||||
let b = e.execute("markers.add", &json!({"name": "B", "end": 96_000})).unwrap();
|
||||
let c = e.execute("markers.add", &json!({"name": "C", "start": 1000, "end": 2000})).unwrap();
|
||||
let find = |r: &serde_json::Value| e.session().markers.iter().find(|m| Some(m.id.0) == r["id"].as_u64()).map(|m| (m.start, m.end));
|
||||
assert_eq!(find(&a), Some((96_000, 96_000)));
|
||||
assert_eq!(find(&b), Some((96_000, 96_000)));
|
||||
assert_eq!(find(&c), Some((1000, 2000)));
|
||||
}
|
||||
|
||||
/// `markers.edit` documents `color`: it sets the memory location's RGB colour, undoably; `null` clears it.
|
||||
#[test]
|
||||
fn edit_marker_sets_documented_color() {
|
||||
let mut e = demo_engine();
|
||||
let n = e.session().markers.first().unwrap().number;
|
||||
let color = |e: &crate::Engine| e.session().markers.iter().find(|m| m.number == n).unwrap().color;
|
||||
assert_eq!(color(&e), None);
|
||||
|
||||
e.execute("markers.edit", &json!({"number": n, "color": [255, 0, 0]})).unwrap();
|
||||
assert_eq!(color(&e), Some([255, 0, 0]));
|
||||
assert!(e.undo());
|
||||
assert_eq!(color(&e), None);
|
||||
assert!(e.redo());
|
||||
assert_eq!(color(&e), Some([255, 0, 0]));
|
||||
|
||||
e.execute("markers.edit", &json!({"number": n, "color": null})).unwrap();
|
||||
assert_eq!(color(&e), None);
|
||||
|
||||
for bad in [json!([1, 2]), json!([1, 2, 300]), json!("red"), json!([1, 2, -3])] {
|
||||
assert!(e.execute("markers.edit", &json!({"number": n, "color": bad})).is_err());
|
||||
assert_eq!(color(&e), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Constant replaces the tempo over [start, end) only; the tempo at the old end tick carries on after it.
|
||||
#[test]
|
||||
fn tempo_constant_is_bounded_by_its_range() {
|
||||
let mut e = crate::Engine::default();
|
||||
e.execute("event.tempo", &json!({"bpm": 100.0, "at": 0})).unwrap();
|
||||
e.execute("event.tempo", &json!({"bpm": 140.0, "at": 48_000})).unwrap();
|
||||
let s = e.session();
|
||||
let (t0, t1) = (s.tempo.samples_to_ticks(24_000, s.sample_rate), s.tempo.samples_to_ticks(96_000, s.sample_rate));
|
||||
let mid = s.tempo.samples_to_ticks(72_000, s.sample_rate);
|
||||
let after = s.tempo.tempo_at_tick(t1);
|
||||
e.execute("event.tempo_constant", &json!({"bpm": 90.0, "start": 24_000, "end": 96_000})).unwrap();
|
||||
let tempo = &e.session().tempo;
|
||||
assert_eq!(tempo.tempo_at_tick(t0 - 1), 100.0, "before the range");
|
||||
assert_eq!(tempo.tempo_at_tick(t0), 90.0);
|
||||
assert_eq!(tempo.tempo_at_tick(mid), 90.0, "the interior change is replaced");
|
||||
assert_eq!(tempo.tempo_at_tick(t1), after, "the old tempo resumes at the end");
|
||||
assert_eq!(after, 140.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
//! operations (split notes, input quantize, step input, restore/flatten performance), MIDI track
|
||||
//! offsets and real-time properties, chord extraction, Beat Detective, Identify Beat and
|
||||
//! Retrospective Record.
|
||||
use super::{
|
||||
CommandSpec, always, bad, bool_or, clip_ids_param, f32_or, f64_or, has_range, has_selection, has_tracks, i64_or, position_param, range_param,
|
||||
str_param, track_param, tracks_param, tracks_required,
|
||||
};
|
||||
|
||||
use super::more_util::{store_value, track_key, values_with_prefix};
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_midi::Note;
|
||||
use soundcraft_midi::ops::QuantizeOptions;
|
||||
use soundcraft_model::{ChannelFormat, Clip, ClipContent, ClipId, MarkerKind, Session, TrackId, TrackKind};
|
||||
use soundcraft_time::{Range, Samples, TICKS_PER_QUARTER, TempoMap};
|
||||
use soundcraft_time::{MAX_POSITION, Range, Samples, TICKS_PER_QUARTER, TempoMap};
|
||||
|
||||
/// Most tempo events one curve may write.
|
||||
const MAX_TEMPO_EVENTS: i64 = 10_000;
|
||||
@@ -414,7 +417,9 @@ fn step_input(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let end = s.tempo.tick_to_samples(end_tick, sr);
|
||||
// Continue the clip under (or ending at) the insertion point, else start a new one.
|
||||
let host = s.track(t).and_then(|tr| {
|
||||
tr.clips().iter().filter(|c| matches!(c.content, ClipContent::Midi { .. })).find(|c| c.range().contains(at) || c.end() == at).map(|c| c.id)
|
||||
let mut midi = tr.clips().iter().filter(|c| matches!(c.content, ClipContent::Midi { .. }));
|
||||
// A clip starting at `at` wins over one that merely ends there.
|
||||
midi.clone().find(|c| c.range().contains(at)).or_else(|| midi.find(|c| c.end() == at)).map(|c| c.id)
|
||||
});
|
||||
let host = match host {
|
||||
Some(h) => h,
|
||||
@@ -555,6 +560,10 @@ pub fn chord_name(weights: &[f64; 12], bass: usize) -> Option<String> {
|
||||
Some(name)
|
||||
}
|
||||
|
||||
/// The most bars Extract Chords looks at (55 hours of 4/4 at 120 BPM), and notes sounding in them.
|
||||
const MAX_CHORD_BARS: usize = 100_000;
|
||||
const MAX_CHORD_NOTES: usize = 10_000_000;
|
||||
|
||||
fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "event.extract_chords";
|
||||
let (_, t0, t1) = selection_ticks(e, p, id)?;
|
||||
@@ -572,28 +581,44 @@ fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
|
||||
let end = s.tempo.samples_to_ticks(c.end(), s.sample_rate);
|
||||
for n in &sequence.notes {
|
||||
let a = base + n.start;
|
||||
let b = (base + n.end()).min(end);
|
||||
let a = base.saturating_add(n.start);
|
||||
let b = base.saturating_add(n.end()).min(end);
|
||||
if b > a {
|
||||
notes.push((a, b, n.pitch));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let first_bar = s.tempo.bar_beat_at_tick(t0).bar;
|
||||
let mut bar = first_bar;
|
||||
notes.sort_unstable_by_key(|n| n.0);
|
||||
let mut bar = s.tempo.bar_beat_at_tick(t0).bar;
|
||||
let mut sounding: Vec<(i64, i64, u8)> = Vec::new();
|
||||
let (mut next, mut bars, mut heard) = (0, 0, 0);
|
||||
let mut chords: Vec<Value> = Vec::new();
|
||||
let mut marks: Vec<(Samples, String)> = Vec::new();
|
||||
let mut last = String::new();
|
||||
while chords.len() < 2000 {
|
||||
while chords.len() < 2000 && bars < MAX_CHORD_BARS && heard < MAX_CHORD_NOTES {
|
||||
let a = s.tempo.bar_start_tick(bar).max(t0);
|
||||
let b = s.tempo.bar_start_tick(bar + 1).min(t1);
|
||||
if a >= t1 {
|
||||
break;
|
||||
}
|
||||
while let Some(&n) = notes.get(next).filter(|n| n.0 < b) {
|
||||
sounding.push(n);
|
||||
next += 1;
|
||||
}
|
||||
sounding.retain(|n| n.1 > a);
|
||||
if sounding.is_empty() {
|
||||
match notes.get(next) {
|
||||
Some(n) => bar = (bar + 1).max(s.tempo.bar_beat_at_tick(n.0).bar),
|
||||
None => break,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
bars += 1;
|
||||
heard += sounding.len();
|
||||
let mut w = [0.0f64; 12];
|
||||
let mut bass: Option<u8> = None;
|
||||
for (na, nb, pitch) in ¬es {
|
||||
for (na, nb, pitch) in &sounding {
|
||||
let ov = (*nb).min(b) - (*na).max(a);
|
||||
if ov > 0 {
|
||||
if let Some(x) = w.get_mut(usize::from(*pitch) % 12) {
|
||||
@@ -710,13 +735,16 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let t = midi_target(e, p, id)?;
|
||||
let s = e.session();
|
||||
let sr = s.sample_rate;
|
||||
// Latest start tick whose longest note still ends within MAX_POSITION.
|
||||
let max_tick = s.tempo.samples_to_ticks(MAX_POSITION, sr).saturating_sub(TICKS_PER_QUARTER * 1024).max(0);
|
||||
let mut raw: Vec<(i64, Note)> = Vec::new();
|
||||
for v in list.iter().take(100_000) {
|
||||
let tick = match (v.get("start_ticks").and_then(Value::as_i64), v.get("start")) {
|
||||
(Some(tk), _) => tk.max(0),
|
||||
(None, Some(_)) => s.tempo.samples_to_ticks(position_param(e, id, v, "start")?.unwrap_or(0).max(0), sr),
|
||||
_ => return Err(bad(id, "each note needs `start` or `start_ticks`")),
|
||||
};
|
||||
}
|
||||
.min(max_tick);
|
||||
let note = Note {
|
||||
pitch: i64_or(v, "pitch", 60).clamp(0, 127) as u8,
|
||||
velocity: i64_or(v, "velocity", 100).clamp(1, 127) as u8,
|
||||
@@ -730,7 +758,7 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let first = raw.iter().map(|x| x.0).min().unwrap_or(0);
|
||||
// Start the clip on the bar line so the quantize grid lines up with the music.
|
||||
let clip_tick = s.tempo.bar_start_tick(s.tempo.bar_beat_at_tick(first).bar).min(first).max(0);
|
||||
let end_tick = raw.iter().map(|(tk, n)| tk.saturating_add(n.length)).max().unwrap_or(first + 1);
|
||||
let end_tick = raw.iter().map(|(tk, n)| tk.saturating_add(n.length)).max().unwrap_or_else(|| first.saturating_add(1));
|
||||
let mut seq = soundcraft_midi::Sequence::default();
|
||||
for (tk, mut n) in raw {
|
||||
n.start = tk - clip_tick;
|
||||
@@ -744,7 +772,7 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
seq.sort();
|
||||
}
|
||||
let start = s.tempo.tick_to_samples(clip_tick, sr);
|
||||
let end = s.tempo.tick_to_samples(end_tick, sr);
|
||||
let end = s.tempo.tick_to_samples(end_tick, sr).min(MAX_POSITION);
|
||||
let n = seq.notes.len();
|
||||
let s = e.session_mut();
|
||||
let cid = s.new_clip_id();
|
||||
@@ -757,7 +785,6 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn midi_engine() -> (Engine, TrackId) {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"kind": "midi"})).unwrap();
|
||||
@@ -842,6 +869,25 @@ mod tests {
|
||||
assert_eq!(e.session().edit.selection.start, 3 * 24_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_input_at_clip_boundary_uses_next_clip() {
|
||||
let (mut e, t) = midi_engine();
|
||||
let sr = 48_000;
|
||||
e.execute("midi.new_clip", &json!({"track": t.0, "start": 0, "end": sr})).unwrap();
|
||||
let second = e.execute("midi.new_clip", &json!({"track": t.0, "start": sr, "end": 2 * sr})).unwrap()["clip"].as_u64().unwrap();
|
||||
e.execute("edit.select", &json!({"tracks": [t.0], "start": sr})).unwrap();
|
||||
let r = e.execute("event.step_input", &json!({"track": t.0, "pitch": 60, "duration": "1/4"})).unwrap();
|
||||
assert_eq!(r["clip"].as_u64(), Some(second));
|
||||
let mut ranges: Vec<(i64, i64)> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect();
|
||||
ranges.sort();
|
||||
assert_eq!(ranges, vec![(0, sr), (sr, 2 * sr)]);
|
||||
let c = e.session().find_clip(soundcraft_model::ClipId(second)).map(|(_, c)| c.clone()).unwrap();
|
||||
match &c.content {
|
||||
ClipContent::Midi { sequence } => assert_eq!(sequence.notes.iter().map(|n| n.start).collect::<Vec<_>>(), vec![0]),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tempo_curves() {
|
||||
let mut e = Engine::default();
|
||||
@@ -901,6 +947,18 @@ mod tests {
|
||||
assert!(e.session().markers.iter().any(|m| m.ruler == 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chords_far_down_the_timeline_are_found_without_walking_every_bar() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let keys = e.session().track_by_name("Keys").unwrap().id;
|
||||
let far = 1i64 << 39;
|
||||
if let Some(p) = e.session_mut().track_mut(keys).and_then(soundcraft_model::Track::playlist_mut) {
|
||||
p.clips.iter_mut().for_each(|c| c.start += far);
|
||||
}
|
||||
let r = e.execute("event.extract_chords", &json!({"tracks": [keys.0], "start": 0, "end": far + 48_000 * 600})).unwrap();
|
||||
assert!(!r["chords"].as_array().unwrap().is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beat_detective_finds_demo_tempo() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
@@ -911,6 +969,29 @@ mod tests {
|
||||
assert!(r["bpm"].as_f64().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_chords_survives_huge_note_start() {
|
||||
let (mut e, t) = midi_engine();
|
||||
let r = e.execute("midi.new_clip", &json!({"track": t.0, "start": 48_000, "end": 96_000})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": i64::MAX, "length_ticks": 480})).unwrap();
|
||||
e.execute("edit.select", &json!({"tracks": [t.0], "start": 0, "end": 96_000})).unwrap();
|
||||
let r = e.execute("event.extract_chords", &json!({}));
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retro_record_huge_start_ticks_stay_in_range() {
|
||||
for tk in [i64::MAX, 1_000_000_000_000_000] {
|
||||
let (mut e, t) = midi_engine();
|
||||
let notes = json!([{"pitch": 60, "start_ticks": tk, "length_ticks": TICKS_PER_QUARTER * 1024}]);
|
||||
e.execute("event.retrospective_record", &json!({"track": t.0, "notes": notes})).unwrap();
|
||||
let c = e.session().track(t).unwrap().clips()[0].clone();
|
||||
assert!(c.start >= 0 && c.length >= 1, "{tk}: {} {}", c.start, c.length);
|
||||
assert!(c.start.saturating_add(c.length) <= MAX_POSITION, "{tk}: start {} len {}", c.start, c.length);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_song_start_moves_tick_material() {
|
||||
let (mut e, t) = midi_engine();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! File menu commands.
|
||||
use super::{
|
||||
CommandSpec, always, bad, bool_or, clip_ids_param, has_selection, has_tracks, i64_or, position_param, range_param, str_param, track_param,
|
||||
tracks_param, tracks_required,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{Session, TrackKind};
|
||||
use crate::{Engine, EngineError, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{Session, TrackId, TrackKind};
|
||||
use soundcraft_time::SampleRate;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -66,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("file.import_midi", "MIDI...", ["File", "Import"], Some("Cmd+Alt+I"), "{path, at?}", always, |e, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.import_midi", "`path` required"))?.to_string();
|
||||
let at = position_param(e, "file.import_midi", p, "at")?.unwrap_or(0);
|
||||
let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let bytes = crate::io::read_file(&path)?;
|
||||
let tracks = crate::io::import_midi_bytes(e, &bytes, at, bool_or(p, "tempo_map", true))?;
|
||||
Ok(json!({"tracks": tracks}))
|
||||
}),
|
||||
@@ -80,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|e, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.import_video", "`path` required"))?.to_string();
|
||||
let at = position_param(e, "file.import_video", p, "at")?.unwrap_or(0).max(0);
|
||||
let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let bytes = crate::io::read_file(&path)?;
|
||||
crate::io::import_video_bytes(e, &path, bytes, at)
|
||||
}
|
||||
),
|
||||
@@ -123,6 +126,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad("file.export_clips", "no clips selected"));
|
||||
}
|
||||
for (t, c) in ids.iter().filter_map(|id| e.session().find_clip(*id)) {
|
||||
let channels = e.session().track(t).map_or(1, |x| x.channels());
|
||||
crate::io::refuse_longer(e.session(), "file.export_clips", &c.name, "export", c.length, crate::io::render_limit(channels))?;
|
||||
}
|
||||
let n = crate::io::export_clips(e, &ids, &dir)?;
|
||||
Ok(json!({"written": n, "dir": dir}))
|
||||
}),
|
||||
@@ -194,6 +201,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
/// Writing a session over an existing file needs `replace: true` (see `io::replaces`).
|
||||
fn refuse_replacing(e: &Engine, id: &str, file: &std::path::Path, p: &Value) -> Result<()> {
|
||||
// First, so a path outside the write root is refused as such and never reported as existing.
|
||||
crate::file_access::check_write(file).map_err(EngineError::Io)?;
|
||||
if crate::io::replaces(e, id, file) && !bool_or(p, "replace", false) {
|
||||
return Err(bad(id, format!("{} already exists: pass `replace: true` to replace it", file.display())));
|
||||
}
|
||||
@@ -231,8 +240,8 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let target = import_audio_target(e, p)?;
|
||||
let mut out = Vec::new();
|
||||
for path in &paths {
|
||||
let bytes = std::fs::read(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("Audio").to_string();
|
||||
let bytes = crate::io::read_file(path)?;
|
||||
let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Audio").to_string();
|
||||
let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?;
|
||||
out.push(r);
|
||||
}
|
||||
@@ -275,10 +284,25 @@ fn fold_down_param(p: &Value, id: &str) -> Result<bool> {
|
||||
}
|
||||
|
||||
fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let job = bounce_job(e, p)?;
|
||||
let (path, seconds) = (job.path().to_string_lossy().into_owned(), e.session().sample_rate.seconds(job.range().len()));
|
||||
let done = job.run()?;
|
||||
Ok(json!({
|
||||
"path": path,
|
||||
"bytes": done.bytes,
|
||||
"seconds": seconds,
|
||||
"peak_db": done.peak_db,
|
||||
"lufs": done.lufs,
|
||||
"true_peak_db": done.true_peak_db,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The bounce `file.bounce_mix` runs for `p`, not rendered yet.
|
||||
pub fn bounce_job(e: &Engine, p: &Value) -> Result<crate::io::Bounce> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string();
|
||||
let r = range_param(e, "file.bounce_mix", p)?;
|
||||
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
|
||||
let ext = std::path::Path::new(&path).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase();
|
||||
let ext = std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
|
||||
let format_name = str_param(p, "format").unwrap_or(ext.as_str());
|
||||
let format = soundcraft_audio_io::encode_format_for(format_name).ok_or_else(|| {
|
||||
bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS))
|
||||
@@ -292,11 +316,7 @@ fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let normalize = bool_or(p, "normalize", false);
|
||||
let fold = fold_down_param(p, "file.bounce_mix")?;
|
||||
let bus = bounce_bus(e, p)?;
|
||||
let (bytes, stats) = crate::io::bounce_source(e, r, &opts, normalize, fold, bus)?;
|
||||
crate::io::write_file(&path, &bytes)?;
|
||||
Ok(
|
||||
json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}),
|
||||
)
|
||||
crate::io::Bounce::new(e.session_arc(), std::path::Path::new(&path), r, &opts, bus, normalize, fold)
|
||||
}
|
||||
|
||||
/// Write the score of the chosen MIDI tracks as MusicXML (or SVG when `svg`).
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Media availability checks without decoding files or changing the session.
|
||||
use super::{CommandSpec, always};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::ClipContent;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -12,6 +12,55 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
struct ClipContext<'a> {
|
||||
track: &'a soundcraft_model::Track,
|
||||
playlist: &'a soundcraft_model::Playlist,
|
||||
active: bool,
|
||||
session_sample_rate: u32,
|
||||
}
|
||||
|
||||
fn check_audio_clip(
|
||||
ctx: &ClipContext<'_>,
|
||||
clip: &soundcraft_model::Clip,
|
||||
source: soundcraft_model::SourceId,
|
||||
offset: i64,
|
||||
metadata: Option<&soundcraft_model::Source>,
|
||||
audio: Option<&soundcraft_model::SourceAudio>,
|
||||
issues: &mut Vec<Value>,
|
||||
) {
|
||||
let mut add = |code: &str, message: &str| {
|
||||
issues.push(json!({"code": code, "message": message, "track": ctx.track.name, "track_id": ctx.track.id.0,
|
||||
"playlist": ctx.playlist.name, "active": ctx.active, "clip": clip.name, "clip_id": clip.id.0,
|
||||
"source_id": source.0, "path": metadata.map(|m| m.path.as_str())}));
|
||||
};
|
||||
if metadata.is_none() {
|
||||
add("missing_source", "Source metadata is missing. Reimport the audio and replace this clip.");
|
||||
}
|
||||
let Some(audio) = audio else {
|
||||
add("unavailable_audio", "Audio is not loaded; this clip will be silent. Restore the media and reopen the session, or reimport it.");
|
||||
return;
|
||||
};
|
||||
if audio.buffer.sample_rate != ctx.session_sample_rate {
|
||||
add("sample_rate_mismatch", "Loaded audio differs from the session sample rate. Reimport it to convert to the session rate.");
|
||||
}
|
||||
if audio.buffer.channels.is_empty() || audio.frames() == 0 {
|
||||
add("empty_audio", "Loaded audio has no playable frames. Reimport a valid audio file.");
|
||||
return;
|
||||
}
|
||||
if audio.buffer.channels.iter().any(|channel| channel.len() != audio.frames()) {
|
||||
add("uneven_channels", "Loaded audio channels have different lengths. Reimport a valid audio file; bounds below use the shortest channel.");
|
||||
}
|
||||
if !clip.stretch.is_finite() || clip.stretch <= 0.0 || clip.length <= 0 {
|
||||
add("invalid_clip", "Clip length or stretch is invalid. Replace this clip.");
|
||||
}
|
||||
let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 };
|
||||
// Match the mixer's last requested source sample, including elastic playback.
|
||||
let last = offset as f64 + clip.length.saturating_sub(1).max(0) as f64 / stretch;
|
||||
if offset < 0 || last >= audio.frames() as f64 {
|
||||
add("source_bounds", "Clip extends outside the loaded audio; part will be silent. Trim the clip or replace its source.");
|
||||
}
|
||||
}
|
||||
|
||||
fn report(e: &mut Engine, _: &Value) -> Result<Value> {
|
||||
let s = e.session();
|
||||
let sources: BTreeMap<_, _> = s.sources.iter().map(|source| (source.id, source)).collect();
|
||||
@@ -24,51 +73,16 @@ fn report(e: &mut Engine, _: &Value) -> Result<Value> {
|
||||
for track in &s.tracks {
|
||||
for (index, playlist) in track.playlists.iter().enumerate() {
|
||||
let active = index == track.active_playlist;
|
||||
let ctx = ClipContext { track, playlist, active, session_sample_rate: s.sample_rate.hz() };
|
||||
for clip in &playlist.clips {
|
||||
let ClipContent::Audio { source, offset } = clip.content else { continue };
|
||||
clips += 1;
|
||||
if active {
|
||||
active_end = active_end.max(clip.end());
|
||||
}
|
||||
let metadata = sources.get(&source);
|
||||
let audio = s.pool.get(source);
|
||||
let mut add = |code: &str, message: &str| {
|
||||
issues.push(json!({"code": code, "message": message, "track": track.name, "track_id": track.id.0,
|
||||
"playlist": playlist.name, "active": active, "clip": clip.name, "clip_id": clip.id.0,
|
||||
"source_id": source.0, "path": metadata.map(|m| m.path.as_str())}));
|
||||
};
|
||||
if metadata.is_none() {
|
||||
add("missing_source", "Source metadata is missing. Reimport the audio and replace this clip.");
|
||||
}
|
||||
let Some(audio) = audio else {
|
||||
add(
|
||||
"unavailable_audio",
|
||||
"Audio is not loaded; this clip will be silent. Restore the media and reopen the session, or reimport it.",
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if audio.buffer.sample_rate != s.sample_rate.hz() {
|
||||
add("sample_rate_mismatch", "Loaded audio differs from the session sample rate. Reimport it to convert to the session rate.");
|
||||
}
|
||||
if audio.buffer.channels.is_empty() || audio.frames() == 0 {
|
||||
add("empty_audio", "Loaded audio has no playable frames. Reimport a valid audio file.");
|
||||
continue;
|
||||
}
|
||||
if audio.buffer.channels.iter().any(|channel| channel.len() != audio.frames()) {
|
||||
add(
|
||||
"uneven_channels",
|
||||
"Loaded audio channels have different lengths. Reimport a valid audio file; bounds below use the shortest channel.",
|
||||
);
|
||||
}
|
||||
if !clip.stretch.is_finite() || clip.stretch <= 0.0 || clip.length <= 0 {
|
||||
add("invalid_clip", "Clip length or stretch is invalid. Replace this clip.");
|
||||
}
|
||||
let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 };
|
||||
// Match the mixer's last requested source sample, including elastic playback.
|
||||
let last = offset as f64 + clip.length.saturating_sub(1).max(0) as f64 / stretch;
|
||||
if offset < 0 || last >= audio.frames() as f64 {
|
||||
add("source_bounds", "Clip extends outside the loaded audio; part will be silent. Trim the clip or replace its source.");
|
||||
}
|
||||
let metadata = sources.get(&source).copied();
|
||||
let audio = s.pool.get(source).map(|a| a.as_ref());
|
||||
check_audio_clip(&ctx, clip, source, offset, metadata, audio, &mut issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! An instrument track's instrument (`mix.instrument`): the plugin that turns the track's MIDI
|
||||
//! into audio, built in or hosted (CLAP, VST3, Audio Units). The mixer plays `Track::instrument`;
|
||||
//! the instrument is otherwise only set when the track is created (`track.new`).
|
||||
use super::{CommandSpec, bad, has_selection, str_param, tracks_required};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{Insert, TrackKind};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -14,7 +14,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Instrument",
|
||||
[],
|
||||
None,
|
||||
"{track?, plugin: id} — an instrument track's instrument: built-in, `clap:`, `vst3:` or `au:` id",
|
||||
"{track?, plugin: id, params?: {id: value}} — an instrument track's instrument: built-in, `clap:`, `vst3:` or `au:` id; `params` sets built-in parameters (clamped into range; unknown ids and other fields are errors)",
|
||||
has_selection,
|
||||
set_instrument
|
||||
),
|
||||
@@ -30,7 +30,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Fields `mix.instrument` reads. Anything else (a parameter written beside `plugin`, say) used to
|
||||
/// be accepted and ignored, so a synth's settings looked set while the render never changed.
|
||||
const FIELDS: [&str; 4] = ["track", "tracks", "plugin", "params"];
|
||||
|
||||
fn set_instrument(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !FIELDS.contains(&k.as_str()))) {
|
||||
return Err(bad(
|
||||
"mix.instrument",
|
||||
format!("unknown field `{k}`: an instrument's parameters go in `params` ({{id: value}}), or one at a time in mix.instrument_param"),
|
||||
));
|
||||
}
|
||||
let plugin = str_param(p, "plugin").ok_or_else(|| bad("mix.instrument", "`plugin` required"))?;
|
||||
let info = super::clap::plugin_info(plugin).ok_or_else(|| bad("mix.instrument", format!("unknown plugin `{plugin}`")))?;
|
||||
if !info.is_instrument {
|
||||
@@ -40,10 +50,23 @@ fn set_instrument(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if e.session().track(t).map(|tr| tr.kind) != Some(TrackKind::Instrument) {
|
||||
return Err(bad("mix.instrument", "only instrument tracks have an instrument"));
|
||||
}
|
||||
// Validate the parameters before touching the session.
|
||||
let mut values = Vec::new();
|
||||
if let Some(v) = p.get("params") {
|
||||
let obj = v.as_object().ok_or_else(|| bad("mix.instrument", "`params` must be an object"))?;
|
||||
for (k, v) in obj {
|
||||
let pi = info.params.iter().find(|pi| pi.id == k).ok_or_else(|| bad("mix.instrument", format!("`{plugin}` has no parameter `{k}`")))?;
|
||||
let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("mix.instrument", format!("`{k}` must be a number")))?;
|
||||
values.push((k.clone(), pi.clamp(x as f32)));
|
||||
}
|
||||
}
|
||||
let mut ins = Insert::new(info.id);
|
||||
for pi in info.params {
|
||||
ins.params.insert(pi.id.to_string(), pi.default);
|
||||
}
|
||||
for (k, v) in values {
|
||||
ins.params.insert(k, v);
|
||||
}
|
||||
if let Some(tr) = e.session_mut().track_mut(t) {
|
||||
tr.instrument = Some(ins);
|
||||
}
|
||||
@@ -68,7 +91,9 @@ fn set_instrument_param(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use crate::Engine;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::TrackId;
|
||||
use soundcraft_midi::{Note, Sequence};
|
||||
use soundcraft_model::{Clip, TrackId};
|
||||
use soundcraft_time::{Range, TICKS_PER_QUARTER};
|
||||
|
||||
fn track(e: &mut Engine, kind: &str) -> TrackId {
|
||||
e.execute("track.new", &json!({"kind": kind})).unwrap();
|
||||
@@ -111,6 +136,63 @@ mod tests {
|
||||
e.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
/// One note of 440 Hz (A4) on the track, `secs` of render, and the RMS of the result.
|
||||
fn rms_of_a_note(e: &mut Engine, t: TrackId, secs: i64) -> f32 {
|
||||
let q = TICKS_PER_QUARTER;
|
||||
let mut seq = Sequence::default();
|
||||
seq.notes.push(Note { pitch: 93, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 2 * q });
|
||||
let s = e.session_mut();
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(s, t, Clip::midi(cid, "note", 0, 48_000 * 4, seq));
|
||||
let out = soundcraft_mix::render_range(e.session(), Range::new(0, 48_000 * secs), 512);
|
||||
let n = out[0].len().max(1) as f32;
|
||||
(out[0].iter().map(|x| x * x).sum::<f32>() / n).sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_given_with_the_instrument_reach_the_render_and_unknown_fields_are_refused() {
|
||||
// A high note through a closed and an open filter: the cutoff has to change the render.
|
||||
let mut closed = Engine::default();
|
||||
let t = track(&mut closed, "instrument");
|
||||
let r = closed.execute("mix.instrument", &json!({"track": t.0, "plugin": "subtractive_synth", "params": {"cutoff": 200}})).unwrap();
|
||||
assert_eq!(r["plugin"], "subtractive_synth");
|
||||
let stored = closed.session().track(t).and_then(|tr| tr.instrument.as_ref()).and_then(|i| i.params.get("cutoff").copied());
|
||||
assert_eq!(stored, Some(200.0));
|
||||
let mut open = Engine::default();
|
||||
let o = track(&mut open, "instrument");
|
||||
open.execute("mix.instrument", &json!({"track": o.0, "plugin": "subtractive_synth", "params": {"cutoff": 12_000}})).unwrap();
|
||||
let (quiet, loud) = (rms_of_a_note(&mut closed, t, 2), rms_of_a_note(&mut open, o, 2));
|
||||
assert!(loud > quiet * 3.0, "cutoff 12000 plays a high note much louder than 200: {loud} vs {quiet}");
|
||||
// Values are clamped into range; the instrument starts from its defaults for the rest.
|
||||
let mut e = Engine::default();
|
||||
let t = track(&mut e, "instrument");
|
||||
e.execute("mix.instrument", &json!({"track": t.0, "plugin": "subtractive_synth", "params": {"cutoff": 1.0e9}})).unwrap();
|
||||
let ins = e.session().track(t).and_then(|tr| tr.instrument.clone()).unwrap();
|
||||
assert_eq!(ins.params.get("cutoff"), Some(&20_000.0));
|
||||
assert_eq!(ins.params.len(), soundcraft_dsp::plugin_info("subtractive_synth").map(|p| p.params.len()).unwrap());
|
||||
|
||||
// Nothing is accepted and then ignored: a parameter beside `plugin`, an unknown id, a value
|
||||
// that is not a number and a `params` that is not an object are errors, and change nothing.
|
||||
let mut e = Engine::default();
|
||||
let t = track(&mut e, "instrument");
|
||||
e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": 777})).unwrap();
|
||||
let before = e.session().track(t).and_then(|tr| tr.instrument.clone());
|
||||
let depth = e.revision;
|
||||
for bad in [
|
||||
json!({"track": t.0, "plugin": "drum_synth", "cutoff": 200}),
|
||||
json!({"track": t.0, "plugin": "drum_synth", "params": {"nope": 1}}),
|
||||
json!({"track": t.0, "plugin": "drum_synth", "params": {"decay": "long"}}),
|
||||
json!({"track": t.0, "plugin": "drum_synth", "params": 3}),
|
||||
] {
|
||||
let err = e.execute("mix.instrument", &bad).unwrap_err().to_string();
|
||||
assert!(!err.is_empty(), "{bad}");
|
||||
assert_eq!(e.session().track(t).and_then(|tr| tr.instrument.clone()), before, "{bad}: the instrument is untouched");
|
||||
assert_eq!(e.revision, depth, "{bad}: nothing was recorded");
|
||||
}
|
||||
let err = e.execute("mix.instrument", &json!({"track": t.0, "plugin": "drum_synth", "cutoff": 200})).unwrap_err().to_string();
|
||||
assert!(err.contains("cutoff") && err.contains("params"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_effects_unknown_plugins_and_other_track_kinds() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! MIDI note editing (MIDI Editor, pencil on MIDI tracks).
|
||||
use super::{CommandSpec, always, bad, clip_id, has_tracks, i64_or, range_param, tracks_required};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_midi::{Note, Sequence};
|
||||
use soundcraft_model::{Clip, ClipContent, ClipId};
|
||||
use soundcraft_time::TICKS_PER_QUARTER;
|
||||
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
"midi.new_clip",
|
||||
"New MIDI Clip",
|
||||
[],
|
||||
["Clip"],
|
||||
None,
|
||||
"{track, start?, end?|length?} (default: the edit selection, or 1 bar)",
|
||||
has_tracks,
|
||||
@@ -26,10 +26,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn clip_id(p: &Value, cmd: &str) -> Result<ClipId> {
|
||||
p.get("clip").and_then(Value::as_u64).map(ClipId).ok_or_else(|| bad(cmd, "`clip` id required"))
|
||||
}
|
||||
|
||||
fn seq_mut<'a>(e: &'a mut Engine, id: ClipId, cmd: &str) -> Result<&'a mut Sequence> {
|
||||
match e.session_mut().find_clip_mut(id).map(|c| &mut c.content) {
|
||||
Some(ClipContent::Midi { sequence }) => Ok(sequence),
|
||||
@@ -130,8 +126,8 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let seq = seq_mut(e, id, "midi.notes_move")?;
|
||||
for (i, n) in seq.notes.iter_mut().enumerate() {
|
||||
if idx.contains(&i) {
|
||||
n.start = (n.start + dt).max(0);
|
||||
n.pitch = (i64::from(n.pitch) + ds).clamp(0, 127) as u8;
|
||||
n.start = n.start.saturating_add(dt).max(0);
|
||||
n.pitch = i64::from(n.pitch).saturating_add(ds).clamp(0, 127) as u8;
|
||||
}
|
||||
}
|
||||
seq.sort();
|
||||
@@ -161,6 +157,21 @@ mod tests {
|
||||
assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_move_huge_params_do_not_overflow() {
|
||||
let mut e = Engine::default();
|
||||
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
|
||||
let r = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 480})).unwrap();
|
||||
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "ticks": i64::MAX})).unwrap();
|
||||
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
|
||||
assert_eq!(n["notes"][0]["start"], i64::MAX);
|
||||
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "semitones": i64::MAX})).unwrap();
|
||||
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
|
||||
assert_eq!(n["notes"][0]["pitch"], 127);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_audio_tracks() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
+235
-24
@@ -1,8 +1,10 @@
|
||||
//! Mixer, insert, send and automation commands.
|
||||
use super::{
|
||||
CommandSpec, always, bad, bool_or, f32_or, has_range, has_selection, has_tracks, i64_or, position_param, range_param, str_param, tracks_required,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, EngineError, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{AutoParam, AutomationMode, INSERT_SLOTS, Insert, SEND_SLOTS, SendSlot, Session, TrackId};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -73,7 +75,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
)),
|
||||
cmd!("mix.trim", "Input Trim", [], None, "{tracks?, db}", has_selection, |e, p| {
|
||||
let tracks = tracks_required(e, "mix.trim", p)?;
|
||||
let db = f32_or(p, "db", 0.0).clamp(-144.0, 24.0);
|
||||
let db = (p.get("db").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("mix.trim", "`db` (number) required"))? as f32)
|
||||
.clamp(-144.0, 24.0);
|
||||
let s = e.session_mut();
|
||||
for t in tracks {
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
@@ -100,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Plugin Parameters",
|
||||
[],
|
||||
None,
|
||||
"{track?, slot, params: {id: value}, preset?: name}",
|
||||
"{track?, slot, params: {id: value}, preset?: name} (unknown ids and non-numeric values are errors)",
|
||||
has_selection,
|
||||
insert_params
|
||||
),
|
||||
@@ -135,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Automation Breakpoint",
|
||||
[],
|
||||
None,
|
||||
"{track?, param: volume|pan|mute|send_a_level|plugin:a:<id>, at, value}",
|
||||
"{track?, param: volume|pan|mute|send_a_level|plugin:a:<id>, at, value, from?}",
|
||||
has_selection,
|
||||
set_point
|
||||
),
|
||||
@@ -181,19 +184,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Apply a change to `t` and its active mix-group siblings.
|
||||
fn set_grouped(s: &mut Session, t: TrackId, attr: &str, f: impl Fn(&mut soundcraft_model::Track)) {
|
||||
let mut targets = vec![t];
|
||||
/// `t` plus the members of its active mix groups that share `attr`.
|
||||
fn grouped_ids(s: &Session, t: TrackId, attr: &str) -> Vec<TrackId> {
|
||||
let mut ids = vec![t];
|
||||
for g in &s.groups {
|
||||
if g.active && g.mix && g.members.contains(&t) && g.attributes.iter().any(|a| a == attr) {
|
||||
for m in &g.members {
|
||||
if !targets.contains(m) {
|
||||
targets.push(*m);
|
||||
if !ids.contains(m) {
|
||||
ids.push(*m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in targets {
|
||||
ids
|
||||
}
|
||||
|
||||
/// Apply a change to `t` and its active mix-group siblings.
|
||||
fn set_grouped(s: &mut Session, t: TrackId, attr: &str, f: impl Fn(&mut soundcraft_model::Track)) {
|
||||
for id in grouped_ids(s, t, attr) {
|
||||
if let Some(tr) = s.track_mut(id) {
|
||||
f(tr);
|
||||
}
|
||||
@@ -218,7 +226,13 @@ fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
return Err(bad("mix.volume", "`db` or `delta_db` required"));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
// Tracks already moved as a group sibling of an earlier target must not move again.
|
||||
let mut done: Vec<TrackId> = Vec::new();
|
||||
for t in &tracks {
|
||||
if done.contains(t) {
|
||||
continue;
|
||||
}
|
||||
done.extend(grouped_ids(s, *t, "volume"));
|
||||
let cur = s.track(*t).map_or(0.0, |tr| tr.mixer.volume_db);
|
||||
let new = match (abs, delta) {
|
||||
(Some(a), _) => a as f32,
|
||||
@@ -234,7 +248,8 @@ fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
|
||||
fn pan(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "mix.pan", p)?;
|
||||
let v = f32_or(p, "pan", 0.0).clamp(-1.0, 1.0);
|
||||
let v = (p.get("pan").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("mix.pan", "`pan` (number) required"))? as f32)
|
||||
.clamp(-1.0, 1.0);
|
||||
let idx = i64_or(p, "index", 0).clamp(0, 1) as usize;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
@@ -287,17 +302,30 @@ fn slot_of(p: &Value, key: &str, max: usize) -> Option<usize> {
|
||||
(i < max).then_some(i)
|
||||
}
|
||||
|
||||
fn slot_error(cmd: &str, max: usize) -> EngineError {
|
||||
bad(cmd, format!("`slot` must be 0..{} or a..{}", max - 1, (b'a' + max as u8 - 1) as char))
|
||||
}
|
||||
|
||||
fn track_slot(e: &Engine, p: &Value, cmd: &str, max: usize) -> Result<(TrackId, usize)> {
|
||||
let t = tracks_required(e, cmd, p)?.first().copied().ok_or_else(|| bad(cmd, "no track"))?;
|
||||
let slot = slot_of(p, "slot", max).ok_or_else(|| bad(cmd, format!("`slot` must be 0..{} or a..{}", max - 1, (b'a' + max as u8 - 1) as char)))?;
|
||||
let slot = slot_of(p, "slot", max).ok_or_else(|| slot_error(cmd, max))?;
|
||||
Ok((t, slot))
|
||||
}
|
||||
|
||||
/// An optional `slot`: absent (or null) means "first free"; a present but invalid value is
|
||||
/// an error, never silently treated as absent.
|
||||
fn optional_slot(p: &Value, cmd: &str, max: usize) -> Result<Option<usize>> {
|
||||
match p.get("slot") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => slot_of(p, "slot", max).map(Some).ok_or_else(|| slot_error(cmd, max)),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let plugin = str_param(p, "plugin").ok_or_else(|| bad("mix.insert", "`plugin` required"))?.to_string();
|
||||
let info = super::clap::plugin_info(&plugin).ok_or_else(|| bad("mix.insert", format!("unknown plugin `{plugin}`")))?;
|
||||
let t = tracks_required(e, "mix.insert", p)?.first().copied().ok_or_else(|| bad("mix.insert", "no track"))?;
|
||||
let explicit = slot_of(p, "slot", INSERT_SLOTS);
|
||||
let explicit = optional_slot(p, "mix.insert", INSERT_SLOTS)?;
|
||||
let s = e.session_mut();
|
||||
let tr = s.track_mut(t).ok_or_else(|| bad("mix.insert", "no track"))?;
|
||||
let slot =
|
||||
@@ -360,7 +388,8 @@ fn insert_param(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"value": v}))
|
||||
}
|
||||
|
||||
/// Apply many parameters at once (preset recall); unknown ids are ignored, values clamped.
|
||||
/// Apply many parameters at once (preset recall); values are clamped. Unknown ids and
|
||||
/// non-numeric values are errors, not silent no-ops: a typo must not look like success.
|
||||
fn insert_params(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let (t, slot) = track_slot(e, p, "mix.insert_params", INSERT_SLOTS)?;
|
||||
let obj = p.get("params").and_then(Value::as_object).cloned().ok_or_else(|| bad("mix.insert_params", "`params` must be an object"))?;
|
||||
@@ -372,11 +401,29 @@ fn insert_params(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
.and_then(Option::as_mut)
|
||||
.ok_or_else(|| bad("mix.insert_params", "empty slot"))?;
|
||||
let info = super::clap::plugin_info(&ins.plugin).ok_or_else(|| bad("mix.insert_params", "unknown plugin"))?;
|
||||
let mut unknown: Vec<&str> = Vec::new();
|
||||
let mut non_numeric: Vec<&str> = Vec::new();
|
||||
for (k, v) in &obj {
|
||||
if !info.params.iter().any(|x| x.id == k.as_str()) {
|
||||
unknown.push(k.as_str());
|
||||
} else if v.as_f64().is_none_or(|f| !f.is_finite()) {
|
||||
non_numeric.push(k.as_str());
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
unknown.sort_unstable();
|
||||
return Err(bad("mix.insert_params", format!("`{}` has no parameter(s): {}", info.id, unknown.join(", "))));
|
||||
}
|
||||
if !non_numeric.is_empty() {
|
||||
non_numeric.sort_unstable();
|
||||
return Err(bad("mix.insert_params", format!("parameter(s) need a numeric value: {}", non_numeric.join(", "))));
|
||||
}
|
||||
let mut n = 0;
|
||||
for (k, v) in obj {
|
||||
let (Some(pi), Some(f)) = (info.params.iter().find(|x| x.id == k), v.as_f64().filter(|f| f.is_finite())) else { continue };
|
||||
ins.params.insert(k, (f as f32).clamp(pi.min, pi.max));
|
||||
n += 1;
|
||||
if let (Some(pi), Some(f)) = (info.params.iter().find(|x| x.id == k), v.as_f64().filter(|f| f.is_finite())) {
|
||||
ins.params.insert(k, (f as f32).clamp(pi.min, pi.max));
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if let Some(name) = preset {
|
||||
ins.preset = name;
|
||||
@@ -397,9 +444,9 @@ fn insert_move(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn send(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let bus = str_param(p, "bus").ok_or_else(|| bad("mix.send", "`bus` required"))?.to_string();
|
||||
let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?;
|
||||
let explicit = slot_of(p, "slot", SEND_SLOTS);
|
||||
let explicit = optional_slot(p, "mix.send", SEND_SLOTS)?;
|
||||
let s = e.session_mut();
|
||||
let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo);
|
||||
let bid = s.bus_by_name(&bus).map(|b| b.id).ok_or_else(|| bad("mix.send", format!("no bus named \"{bus}\"")))?;
|
||||
let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?;
|
||||
let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?;
|
||||
// Re-targeting an existing send keeps its level, pan, mute and pre/post setting.
|
||||
@@ -442,15 +489,21 @@ fn lane_param(p: &Value, cmd: &str) -> Result<AutoParam> {
|
||||
str_param(p, "param").map_or(Ok(AutoParam::Volume), |s| AutoParam::parse(s).ok_or_else(|| bad(cmd, format!("unknown automation param `{s}`"))))
|
||||
}
|
||||
|
||||
/// With `from` (a write pass's previous point), the points between `from` and `at` are removed first.
|
||||
fn set_point(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "automation.set_point", p)?;
|
||||
let param = lane_param(p, "automation.set_point")?;
|
||||
let at = position_param(e, "automation.set_point", p, "at")?.unwrap_or(e.session().edit.selection.start).max(0);
|
||||
let from = position_param(e, "automation.set_point", p, "from")?;
|
||||
let v = p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("automation.set_point", "`value` required"))? as f32;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
tr.lane_mut(¶m).set_point(at, v);
|
||||
let lane = tr.lane_mut(¶m);
|
||||
if let Some(from) = from {
|
||||
lane.clear_range(from.saturating_add(1), at);
|
||||
}
|
||||
lane.set_point(at, v);
|
||||
}
|
||||
}
|
||||
Ok(json!({"at": at, "value": v}))
|
||||
@@ -488,7 +541,7 @@ pub(crate) fn current_value(tr: &soundcraft_model::Track, param: &AutoParam) ->
|
||||
|
||||
fn clear_lane(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "automation.clear", p)?;
|
||||
let param = str_param(p, "param").and_then(AutoParam::parse);
|
||||
let param = if str_param(p, "param").is_some() { Some(lane_param(p, "automation.clear")?) } else { None };
|
||||
let r = range_param(e, "automation.clear", p)?;
|
||||
let (a, b) = if r.is_empty() { (i64::MIN, i64::MAX) } else { (r.start, r.end) };
|
||||
let s = e.session_mut();
|
||||
@@ -669,7 +722,13 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
|
||||
fn copy_to_send(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "automation.copy_to_send", p)?;
|
||||
let slot = slot_of(p, "send", SEND_SLOTS).unwrap_or(0) as u8;
|
||||
// No `send` means Send A (the menu item passes none); a given one must be valid.
|
||||
let slot = match p.get("send") {
|
||||
None | Some(Value::Null) => 0,
|
||||
Some(_) => slot_of(p, "send", SEND_SLOTS).ok_or_else(|| {
|
||||
bad("automation.copy_to_send", format!("`send` must be 0..{} or a..{}", SEND_SLOTS - 1, (b'a' + SEND_SLOTS as u8 - 1) as char))
|
||||
})?,
|
||||
} as u8;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
@@ -788,6 +847,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_point_from_drops_the_points_in_between() {
|
||||
let (mut e, t) = tone_session(&[(100, -20.0), (150, -20.0), (200, -20.0), (250, -20.0), (300, -20.0)]);
|
||||
e.execute("automation.set_point", &json!({"track": t.0, "from": 100, "at": 250, "value": 0.0})).unwrap();
|
||||
// A loop wrap: `from` lies after `at`, so nothing goes.
|
||||
e.execute("automation.set_point", &json!({"track": t.0, "from": 400, "at": 50, "value": -6.0})).unwrap();
|
||||
let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap();
|
||||
let points: Vec<(i64, f32)> = lane.points.iter().map(|p| (p.at, p.value)).collect();
|
||||
assert_eq!(points, [(50, -6.0), (100, -20.0), (250, 0.0), (300, -20.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_gain_points_inside_the_selection_are_kept() {
|
||||
let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]);
|
||||
@@ -802,16 +872,157 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_params_rejects_unknown_and_non_numeric_keys() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
let t = e.session().tracks[0].id.0;
|
||||
e.execute("mix.insert", &json!({"track": t, "slot": 0, "plugin": "gain"})).unwrap();
|
||||
fn gain(e: &Engine) -> f32 {
|
||||
e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().params["gain"]
|
||||
}
|
||||
|
||||
// A typo names no parameter: the call fails and says which key.
|
||||
let err = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain_db": -14.0}})).unwrap_err();
|
||||
assert!(err.to_string().contains("gain_db"), "{err}");
|
||||
|
||||
// Mixed keys fail as a whole and change nothing.
|
||||
let err = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": -14.0, "width": 1.0}})).unwrap_err();
|
||||
assert!(err.to_string().contains("width"), "{err}");
|
||||
assert_eq!(gain(&e), 0.0);
|
||||
|
||||
// Non-numeric and non-finite values are rejected too.
|
||||
assert!(e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": "loud"}})).is_err());
|
||||
assert!(e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": null}})).is_err());
|
||||
assert_eq!(gain(&e), 0.0);
|
||||
|
||||
// Correct keys still apply, clamped to the parameter range.
|
||||
let r = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": -14.0}})).unwrap();
|
||||
assert_eq!(r["set"], 1);
|
||||
assert_eq!(gain(&e), -14.0);
|
||||
e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": 999.0}})).unwrap();
|
||||
assert_eq!(gain(&e), 24.0);
|
||||
|
||||
// Empty params with a preset name still recall (the rename path).
|
||||
e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {}, "preset": "Saved"})).unwrap();
|
||||
assert_eq!(e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().preset, "Saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_to_send_rejects_an_invalid_send_slot() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let history = e.undo_history().len();
|
||||
let lanes = |e: &Engine| e.session().tracks.iter().filter(|t| t.lane(&AutoParam::SendLevel(0)).is_some_and(|l| !l.points.is_empty())).count();
|
||||
let before = lanes(&e);
|
||||
for bad_params in [json!({"track": "Kick", "send": 99}), json!({"track": "Kick", "send": "zz"})] {
|
||||
let r = e.execute("automation.copy_to_send", &bad_params);
|
||||
assert!(r.is_err(), "{bad_params} accepted: {r:?}");
|
||||
assert_eq!(e.undo_history().len(), history, "{bad_params} pushed an undo step");
|
||||
assert_eq!(lanes(&e), before, "{bad_params} edited Send A");
|
||||
}
|
||||
// No `send` (the menu item's call) still means Send A.
|
||||
e.execute("automation.copy_to_send", &json!({"track": "Kick"})).unwrap();
|
||||
assert_eq!(lanes(&e), before + 1);
|
||||
// A valid slot still copies.
|
||||
e.execute("automation.copy_to_send", &json!({"track": "Kick", "send": "a"})).unwrap();
|
||||
assert_eq!(lanes(&e), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retargeting_a_send_keeps_its_settings() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
|
||||
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
|
||||
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap();
|
||||
e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap();
|
||||
e.execute("mix.new_bus", &json!({"name": "Delay"})).unwrap();
|
||||
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap();
|
||||
let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap();
|
||||
let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id;
|
||||
assert_eq!(sl.target, soundcraft_model::Route::Bus(delay));
|
||||
assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true));
|
||||
}
|
||||
|
||||
fn grouped_demo() -> Engine {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
e.execute("mix.volume", &json!({"tracks": [3], "db": 0.0})).unwrap();
|
||||
e.execute("mix.volume", &json!({"tracks": [6], "db": 0.0})).unwrap();
|
||||
e.execute("track.group", &json!({"tracks": [3, 6]})).unwrap();
|
||||
e
|
||||
}
|
||||
|
||||
fn volumes(e: &Engine) -> Vec<f32> {
|
||||
[3, 6].iter().map(|id| e.session().track(TrackId(*id)).unwrap().mixer.volume_db).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_delta_on_a_whole_group_applies_once() {
|
||||
let mut e = grouped_demo();
|
||||
e.execute("mix.volume", &json!({"tracks": [3, 6], "delta_db": -3.0})).unwrap();
|
||||
assert_eq!(volumes(&e), [-3.0, -3.0]);
|
||||
|
||||
let mut e = grouped_demo();
|
||||
e.execute("mix.volume", &json!({"tracks": [3], "delta_db": -3.0})).unwrap();
|
||||
assert_eq!(volumes(&e), [-3.0, -3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_absolute_on_a_whole_group_keeps_offsets() {
|
||||
let mut e = grouped_demo();
|
||||
e.session_mut().track_mut(TrackId(6)).unwrap().mixer.volume_db = -2.0;
|
||||
e.execute("mix.volume", &json!({"tracks": [3, 6], "db": -10.0})).unwrap();
|
||||
assert_eq!(volumes(&e), [-10.0, -12.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_with_unknown_param_keeps_lanes() {
|
||||
let (mut e, t) = tone_session(&[(0, -6.0), (100, -3.0)]);
|
||||
e.session_mut().track_mut(t).unwrap().lane_mut(&AutoParam::Pan(0)).set_point(0, 0.5);
|
||||
e.session_mut().edit.selection = Range::new(0, 0);
|
||||
let count = |e: &Engine| e.session().track(t).unwrap().automation.iter().map(|l| l.points.len()).sum::<usize>();
|
||||
let r = e.execute("automation.clear", &json!({"track": t.0, "param": "volum"}));
|
||||
assert!(r.is_err(), "unknown param succeeded: {r:?}");
|
||||
assert_eq!(count(&e), 3, "unknown param changed the lanes");
|
||||
e.execute("automation.clear", &json!({"track": t.0, "param": "volume"})).unwrap();
|
||||
assert_eq!(count(&e), 1, "named param cleared the wrong lanes");
|
||||
e.execute("automation.clear", &json!({"track": t.0})).unwrap();
|
||||
assert_eq!(count(&e), 0, "absent param must still clear every lane");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_rejects_unknown_bus() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
|
||||
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
|
||||
let err = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Nowhere"})).unwrap_err();
|
||||
assert!(err.to_string().contains("no bus named \"Nowhere\""), "{err}");
|
||||
assert_eq!(e.session().busses.len(), 1);
|
||||
assert!(e.session().tracks[0].mixer.sends.iter().all(Option::is_none));
|
||||
e.execute("mix.send", &json!({"track": "Gtr", "bus": "verb"})).unwrap();
|
||||
assert_eq!(e.session().busses.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_send_reject_invalid_slot() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
|
||||
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
|
||||
for slot in [json!(12), json!("k"), json!("x1")] {
|
||||
let r = e.execute("mix.insert", &json!({"track": "Gtr", "slot": slot, "plugin": "compressor"}));
|
||||
assert!(r.is_err(), "insert slot {slot} accepted: {r:?}");
|
||||
}
|
||||
for slot in [json!(12), json!("k")] {
|
||||
let r = e.execute("mix.send", &json!({"track": "Gtr", "slot": slot, "bus": "Verb"}));
|
||||
assert!(r.is_err(), "send slot {slot} accepted: {r:?}");
|
||||
}
|
||||
let mixer = &e.session().tracks[0].mixer;
|
||||
assert!(mixer.inserts.iter().all(Option::is_none));
|
||||
assert!(mixer.sends.iter().all(Option::is_none));
|
||||
|
||||
// Leaving `slot` out still picks the first free slot.
|
||||
let r = e.execute("mix.insert", &json!({"track": "Gtr", "plugin": "compressor"})).unwrap();
|
||||
assert_eq!(r["slot"], 0);
|
||||
let r = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Verb"})).unwrap();
|
||||
assert_eq!(r["slot"], 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,10 @@ mod more_util;
|
||||
mod options;
|
||||
mod plugin_state;
|
||||
mod query;
|
||||
mod sampler;
|
||||
pub(crate) mod sampler;
|
||||
mod setup_more;
|
||||
mod surround;
|
||||
mod tab;
|
||||
mod track;
|
||||
mod track_more;
|
||||
mod transport;
|
||||
@@ -35,6 +36,11 @@ mod video;
|
||||
mod view;
|
||||
mod view_more;
|
||||
mod vst3;
|
||||
mod zoom_toggle;
|
||||
|
||||
pub use file::bounce_job;
|
||||
|
||||
pub(crate) use edit::remember_selection;
|
||||
|
||||
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
|
||||
/// `Err(reason)` = disabled.
|
||||
@@ -75,7 +81,7 @@ impl CommandSpec {
|
||||
CommandInfo {
|
||||
id: self.id.into(),
|
||||
label: self.label.into(),
|
||||
menu: self.menu.iter().map(|s| s.to_string()).collect(),
|
||||
menu: self.menu.iter().map(ToString::to_string).collect(),
|
||||
shortcut: self.shortcut.map(str::to_string),
|
||||
params: self.params.into(),
|
||||
enabled: en.is_ok(),
|
||||
@@ -110,6 +116,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(mix::specs());
|
||||
v.extend(options::specs());
|
||||
v.extend(transport::specs());
|
||||
v.extend(tab::specs());
|
||||
v.extend(query::specs());
|
||||
v.extend(health::specs());
|
||||
v.extend(audiosuite::specs());
|
||||
@@ -128,6 +135,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(instrument::specs());
|
||||
v.extend(sampler::specs());
|
||||
v.extend(video::specs());
|
||||
v.extend(zoom_toggle::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
@@ -238,9 +246,7 @@ pub fn tracks_required(e: &Engine, cmd: &str, p: &Value) -> Result<Vec<TrackId>>
|
||||
if t.is_empty() { Err(bad(cmd, "no tracks: pass `track`/`tracks` or select tracks")) } else { Ok(t) }
|
||||
}
|
||||
|
||||
/// Largest position accepted from parameters: 2^40 samples (about 265 days at 48 kHz). Clamping
|
||||
/// here keeps every later addition and subtraction on positions far away from overflow.
|
||||
pub const MAX_POSITION: Samples = 1 << 40;
|
||||
pub use soundcraft_time::MAX_POSITION;
|
||||
|
||||
/// Position from `key`: a number of samples, or `{"seconds": x}`, or a string in the main counter format.
|
||||
/// Values are clamped to ±[`MAX_POSITION`].
|
||||
@@ -275,7 +281,7 @@ fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Opt
|
||||
}
|
||||
};
|
||||
for f in fmts {
|
||||
if let Ok(x) = soundcraft_time::parse_position(body, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start) {
|
||||
if let Ok(x) = s.parse_position(body, f) {
|
||||
return Ok(Some(x));
|
||||
}
|
||||
}
|
||||
@@ -297,6 +303,10 @@ pub fn range_param(e: &Engine, cmd: &str, p: &Value) -> Result<Range> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clip_id(p: &Value, cmd: &str) -> Result<ClipId> {
|
||||
p.get("clip").and_then(Value::as_u64).map(ClipId).ok_or_else(|| bad(cmd, "`clip` id required"))
|
||||
}
|
||||
|
||||
pub fn clip_ids_param(e: &Engine, p: &Value) -> Vec<ClipId> {
|
||||
if let Some(arr) = p.get("clips").and_then(Value::as_array) {
|
||||
return arr.iter().filter_map(Value::as_u64).map(ClipId).collect();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Helpers shared by the newer command modules: settings kept in `EditState.flags`/`values`,
|
||||
//! per-track settings, target playlists, clip-gain envelopes and stretch rendering.
|
||||
use super::bad;
|
||||
use crate::{Engine, Result};
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||
use soundcraft_model::{Clip, ClipContent, EditState, Session, SourceId, TrackId};
|
||||
use soundcraft_time::Samples;
|
||||
@@ -132,9 +133,10 @@ pub fn set_clip_env(c: &mut Clip, mut pts: Vec<(Samples, f32)>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest audio we render in one stretch (one hour at the session rate).
|
||||
/// Longest audio we render in one stretch: one hour at the session rate, and no more than
|
||||
/// `time_stretch` makes.
|
||||
fn max_render(s: &Session) -> Samples {
|
||||
s.sample_rate.samples(3600.0)
|
||||
s.sample_rate.samples(3600.0).min(soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples)
|
||||
}
|
||||
|
||||
/// Render the audio a clip plays, time-stretched (pitch preserved) to `out_len` samples, into a new
|
||||
@@ -142,8 +144,10 @@ fn max_render(s: &Session) -> Samples {
|
||||
pub fn render_stretched(s: &mut Session, cmd: &str, c: &Clip, out_len: Samples) -> Result<Option<SourceId>> {
|
||||
let ClipContent::Audio { source, offset } = c.content else { return Ok(None) };
|
||||
let Some(audio) = s.pool.get(source).cloned() else { return Ok(None) };
|
||||
if out_len <= 0 || out_len > max_render(s) {
|
||||
return Err(bad(cmd, "the target length must be between 1 sample and one hour"));
|
||||
let max = max_render(s);
|
||||
if out_len <= 0 || out_len > max {
|
||||
let secs = max as f64 / s.sample_rate.as_f64();
|
||||
return Err(bad(cmd, format!("the target length must be between 1 sample and {secs:.0} s")));
|
||||
}
|
||||
let stretch = if c.stretch.is_finite() && c.stretch > 0.0 { c.stretch } else { 1.0 };
|
||||
let src_len = ((c.length as f64) / stretch).round().max(1.0);
|
||||
@@ -184,7 +188,6 @@ pub fn replace_clip(s: &mut Session, t: TrackId, old: soundcraft_model::ClipId,
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn text_settings_round_trip() {
|
||||
let mut ed = EditState::default();
|
||||
@@ -202,4 +205,12 @@ mod tests {
|
||||
assert_eq!(env_db(&[], 50), 0.0);
|
||||
assert_eq!(env_db(&env, 500), -10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stretches_are_no_longer_than_time_stretch_makes() {
|
||||
let mut s = Session::default();
|
||||
assert_eq!(max_render(&s), s.sample_rate.samples(3600.0));
|
||||
s.sample_rate = soundcraft_time::SampleRate::new(96_000).unwrap();
|
||||
assert_eq!(max_render(&s), soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Options menu toggles and Setup › Session settings.
|
||||
use super::{CommandSpec, always, bad, bool_or, position_param, str_param};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{BitDepthSetting, EditState};
|
||||
use soundcraft_time::FrameRate;
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Third-party plugin state in the session (`mix.insert_state`): the UI reads each live plugin's
|
||||
//! state from the audio engine right before saving and stores it on its insert (base64), from
|
||||
//! where the mixer restores it into new instances.
|
||||
//! state right before saving and stores it on its insert (base64), from where the mixer restores
|
||||
//! it into new instances.
|
||||
use super::{CommandSpec, bad, has_tracks, track_param};
|
||||
use crate::{Engine, Result};
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{INSERT_SLOTS, b64};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Read-only queries for agents.
|
||||
use super::{CommandInfo, CommandSpec, always, bad, command_specs, has_tracks, position_param, str_param, track_param};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let at = position_param(e, "session.format_time", p, "at")?.unwrap_or(0);
|
||||
let s = e.session();
|
||||
let f = str_param(p, "format").and_then(soundcraft_time::TimeFormat::from_id).unwrap_or(s.edit.main_counter);
|
||||
Ok(json!({"text": soundcraft_time::format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start)}))
|
||||
Ok(json!({"text": s.format_position(at, f)}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
//! The source is saved in `Audio Files/` with the session like any other audio, so a session that
|
||||
//! uses a sample reopens with it. Sampler parameters (root note, key range, ADSR, loop…) are set
|
||||
//! with `mix.instrument_param`, or all at once with `params` here.
|
||||
use super::{CommandSpec, bad, has_selection, str_param, tracks_required};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{Insert, TrackKind};
|
||||
use crate::{Engine, EngineError, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{Insert, TrackId, TrackKind};
|
||||
use std::path::Path;
|
||||
|
||||
const SAMPLER: &str = "sampler";
|
||||
@@ -55,19 +55,30 @@ fn load(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
values.push((k.clone(), pi.clamp(x as f32)));
|
||||
}
|
||||
}
|
||||
let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string();
|
||||
let ext = Path::new(&name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase);
|
||||
let (format, buf) = soundcraft_audio_io::decode(&bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
let bytes = crate::io::read_file(&path)?;
|
||||
let name = Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Sample").to_string();
|
||||
attach(e, t, &name, &bytes, Some(&path), values)
|
||||
}
|
||||
|
||||
/// Decodes `bytes` (a file called `name`, for the format hint) into a new session source and
|
||||
/// makes it the sample of track `t`'s Sampler, which becomes its instrument if it is not
|
||||
/// already; `values` are sampler parameters to set. Fails before touching the session.
|
||||
pub(crate) fn attach(e: &mut Engine, t: TrackId, name: &str, bytes: &[u8], path: Option<&str>, values: Vec<(String, f32)>) -> Result<Value> {
|
||||
let id = "mix.sampler_load";
|
||||
if e.session().track(t).map(|tr| tr.kind) != Some(TrackKind::Instrument) {
|
||||
return Err(bad(id, "only instrument tracks have a sampler"));
|
||||
}
|
||||
let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?;
|
||||
let ext = Path::new(name).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
|
||||
let (format, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
if buf.frames() == 0 || buf.num_channels() == 0 {
|
||||
return Err(EngineError::Io(format!("{name}: no audio")));
|
||||
}
|
||||
if buf.frames().saturating_mul(buf.num_channels()) > MAX_SAMPLE_VALUES {
|
||||
return Err(EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")));
|
||||
}
|
||||
let buf = crate::io::at_rate(buf, e.session().sample_rate.hz(), MAX_SAMPLE_VALUES)
|
||||
.ok_or_else(|| EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")))?;
|
||||
let channels = buf.num_channels();
|
||||
let s = e.session_mut();
|
||||
let src = crate::io::add_source(s, &name, buf, Some(&path), format.format);
|
||||
let src = crate::io::add_source(s, name, buf, path, format.format);
|
||||
let (frames, rate, stem) = s.source(src).map_or((0, 0, String::new()), |x| (x.frames, x.sample_rate, x.name.clone()));
|
||||
let tr = s.track_mut(t).ok_or_else(|| bad(id, "no track"))?;
|
||||
let keep = tr.instrument.as_ref().is_some_and(|i| i.plugin == SAMPLER);
|
||||
@@ -186,6 +197,33 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_dropped_bytes_as_one_undo_step() {
|
||||
let d = dir("bytes");
|
||||
let wav = d.join("tone.wav");
|
||||
tone_wav(&wav, 440.0, 48_000, 0.5, 1);
|
||||
let bytes = std::fs::read(&wav).unwrap();
|
||||
let mut e = Engine::default();
|
||||
let t = instrument_track(&mut e);
|
||||
let steps = e.undo_history().len();
|
||||
let r = crate::io::load_sampler_bytes(&mut e, t, "dropped.wav", &bytes).unwrap();
|
||||
assert_eq!(r["name"], "dropped");
|
||||
assert_eq!(r["frames"], 24_000);
|
||||
assert_eq!(e.undo_label(), Some("Load Sampler Sample"));
|
||||
assert_eq!(e.undo_history().len(), steps + 1);
|
||||
let src = e.session().sources.first().cloned().unwrap();
|
||||
assert!(src.unsaved, "written into Audio Files on the next save");
|
||||
assert!(e.undo());
|
||||
assert!(e.session().sources.is_empty());
|
||||
assert_eq!(e.session().track(t).unwrap().instrument.as_ref().map(|i| i.plugin.as_str()), Some("subtractive_synth"));
|
||||
assert!(crate::io::load_sampler_bytes(&mut e, t, "junk.wav", b"not audio").is_err());
|
||||
e.execute("track.new", &json!({"kind": "audio"})).unwrap();
|
||||
let audio = e.session().tracks.last().unwrap().id;
|
||||
assert!(crate::io::load_sampler_bytes(&mut e, audio, "tone.wav", &bytes).is_err(), "an audio track");
|
||||
assert!(e.session().sources.is_empty());
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_flac_and_aiff_too() {
|
||||
let d = dir("formats");
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
//! More Options toggles, the Setup menu (hardware, playback engine, I/O, timecode, click,
|
||||
//! shortcuts, MIDI setup) and File › Import › Session Data.
|
||||
use super::{CommandSpec, always, bad, command_specs, i64_or, str_param, track_param, tracks_param};
|
||||
|
||||
use super::more_util::{set_text, store_value, text, toggle_flag, values_with_prefix};
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
|
||||
use crate::{Engine, EngineError, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, SourceId, TrackId, TrackKind};
|
||||
use soundcraft_time::Timecode;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::path::Path;
|
||||
|
||||
macro_rules! option {
|
||||
($id:literal, $label:literal, $flag:literal) => {
|
||||
@@ -147,7 +146,9 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let r = soundcraft_time::Range::new(0, end);
|
||||
let mut n = 0;
|
||||
for t in tracks {
|
||||
if crate::io::consolidate(e, t, r)? {
|
||||
// Each track keeps its own length (its material plus a second), as before.
|
||||
let tr = crate::io::bounded_to_track(e.session(), t, r);
|
||||
if crate::io::consolidate(e, id, t, tr)? {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
@@ -318,7 +319,7 @@ fn current_feet_frames(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let at = s.edit.selection.start.max(0);
|
||||
let frames_at = s.frame_rate.frames_at(at, s.sample_rate);
|
||||
let start = ft * 16 + fr - frames_at;
|
||||
s.edit.values.insert("feet_frames.start".into(), start as f64);
|
||||
s.edit.values.insert(soundcraft_model::FEET_FRAMES_START.into(), start as f64);
|
||||
Ok(json!({"feet_frames_start": start}))
|
||||
}
|
||||
|
||||
@@ -401,24 +402,11 @@ fn midi_inputs(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"enabled": enabled}))
|
||||
}
|
||||
|
||||
/// Decode a source file of another session (paths relative to that session's folder).
|
||||
fn load_source(dir: &Path, src: &Source, sr: u32) -> Option<soundcraft_audio_io::AudioBuffer> {
|
||||
let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
|
||||
let bytes = std::fs::read(&p).ok()?;
|
||||
let (_, mut buf) = soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())).ok()?;
|
||||
if buf.sample_rate != sr && buf.sample_rate > 0 {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, sr);
|
||||
buf.sample_rate = sr;
|
||||
}
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "file.import_session_data";
|
||||
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(id, "`path` required"))?.to_string();
|
||||
let text_v = std::fs::read_to_string(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let text_v = crate::io::read_text(&path)?;
|
||||
let other = Session::from_json(&text_v).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let dir = Path::new(&path).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let names: Option<Vec<String>> =
|
||||
p.get("tracks").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
let picked: Vec<soundcraft_model::Track> = match &names {
|
||||
@@ -441,31 +429,36 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
needed.sort_by_key(|x| x.0);
|
||||
needed.dedup();
|
||||
let sr = e.session().sample_rate.hz();
|
||||
let decoded: Vec<(Source, Option<soundcraft_audio_io::AudioBuffer>)> =
|
||||
needed.iter().filter_map(|sid| other.source(*sid).cloned()).map(|src| (src.clone(), load_source(&dir, &src, sr))).collect();
|
||||
let s = e.session_mut();
|
||||
let mut files = crate::io::DecodedFiles::new();
|
||||
let mut map: std::collections::BTreeMap<u64, SourceId> = Default::default();
|
||||
let mut missing = Vec::new();
|
||||
for (src, buf) in decoded {
|
||||
let nid = SourceId(s.alloc());
|
||||
let abs = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
|
||||
for src in needed.iter().filter_map(|sid| other.source(*sid)) {
|
||||
let mut ns = src.clone();
|
||||
ns.id = nid;
|
||||
ns.path = abs.to_string_lossy().into_owned();
|
||||
match buf {
|
||||
Some(b) => {
|
||||
ns.frames = b.frames() as u64;
|
||||
ns.sample_rate = sr;
|
||||
ns.unsaved = true;
|
||||
s.pool.insert(nid, Arc::new(SourceAudio::new(b)));
|
||||
ns.id = SourceId(s.alloc());
|
||||
match crate::io::stored_path(&src.path, Path::new(&path)) {
|
||||
Ok(abs) => {
|
||||
ns.path = abs.to_string_lossy().into_owned();
|
||||
match crate::io::decode_file(&mut files, &abs, sr) {
|
||||
Ok(audio) => {
|
||||
ns.frames = audio.buffer.frames() as u64;
|
||||
ns.sample_rate = sr;
|
||||
ns.unsaved = true;
|
||||
s.pool.insert(ns.id, audio);
|
||||
}
|
||||
Err(_) => {
|
||||
ns.unsaved = false;
|
||||
missing.push(ns.path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
Err(why) => {
|
||||
ns.unsaved = false;
|
||||
missing.push(ns.path.clone());
|
||||
missing.push(format!("{}: {why}", ns.path));
|
||||
}
|
||||
}
|
||||
map.insert(src.id.0, ns.id);
|
||||
s.sources.push(ns);
|
||||
map.insert(src.id.0, nid);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for mut tr in picked {
|
||||
@@ -510,7 +503,6 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn option_flags_toggle() {
|
||||
let mut e = Engine::default();
|
||||
@@ -556,8 +548,7 @@ mod tests {
|
||||
e.execute("edit.select", &json!({"start": 48_000})).unwrap();
|
||||
e.execute("setup.current_timecode", &json!({"timecode": "01:00:00:00"})).unwrap();
|
||||
let s = e.session();
|
||||
let shown =
|
||||
soundcraft_time::format_position(48_000, soundcraft_time::TimeFormat::Timecode, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
|
||||
let shown = s.format_position(48_000, soundcraft_time::TimeFormat::Timecode);
|
||||
assert_eq!(shown, "01:00:00:00");
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user