mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-28 20:46:58 -06:00
Compare commits
979
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a564d24eb1 | ||
|
|
e14cea42b2 | ||
|
|
d683aee3eb | ||
|
|
0197e27fc4 | ||
|
|
5bbefeae62 | ||
|
|
a44d1b830b | ||
|
|
eec559f057 | ||
|
|
b854771251 | ||
|
|
0a95b6dadf | ||
|
|
cf96cb888c | ||
|
|
e0951aecf9 | ||
|
|
01b39850ad | ||
|
|
b48df2a8b9 | ||
|
|
106678471f | ||
|
|
7d4ae23202 | ||
|
|
541096d2cb | ||
|
|
353ea145aa | ||
|
|
35905fb90b | ||
|
|
d7b1562cc1 | ||
|
|
9048e04d60 | ||
|
|
e954639900 | ||
|
|
bbebfa1a6e | ||
|
|
9821168bc4 | ||
|
|
e46339606a | ||
|
|
835de9f038 | ||
|
|
e910e72940 | ||
|
|
54467f544b | ||
|
|
4311742624 | ||
|
|
a8ca533b9b | ||
|
|
3a139d4bed | ||
|
|
092122b49c | ||
|
|
be75be896a | ||
|
|
f77c360f94 | ||
|
|
a6cec46275 | ||
|
|
10e26f6fe2 | ||
|
|
c0dc2c3ec6 | ||
|
|
06f30628bb | ||
|
|
746a825ed5 | ||
|
|
fe4cd8095d | ||
|
|
a41f254aa0 | ||
|
|
698507cf30 | ||
|
|
1e9a0e9c88 | ||
|
|
ee050db59c | ||
|
|
37e155c828 | ||
|
|
0ca492e0ab | ||
|
|
3e56738290 | ||
|
|
d5be1e6de3 | ||
|
|
7b19c3068a | ||
|
|
9b60e84fc3 | ||
|
|
02b153797d | ||
|
|
4a0e4caa3f | ||
|
|
dc9e84978f | ||
|
|
aa74bffac1 | ||
|
|
40ced8132b | ||
|
|
b322ff2f13 | ||
|
|
c5ddbbafd1 | ||
|
|
f0f9addd16 | ||
|
|
e884d6eb37 | ||
|
|
4ab3bde39b | ||
|
|
824cf5cecb | ||
|
|
f6a27c2ccf | ||
|
|
08679d5a65 | ||
|
|
7469834420 | ||
|
|
e220666546 | ||
|
|
93800e0764 | ||
|
|
33c979cd34 | ||
|
|
a05b1e7dc1 | ||
|
|
c29510af86 | ||
|
|
1cc7b40e6a | ||
|
|
42f22369fb | ||
|
|
b47f544c7a | ||
|
|
7bf20dbbac | ||
|
|
de206f46b6 | ||
|
|
4f959bee40 | ||
|
|
0ca5dd74ab | ||
|
|
848978a4af | ||
|
|
f3b897f532 | ||
|
|
c13e55c7c3 | ||
|
|
419e7726bb | ||
|
|
bc530b172a | ||
|
|
fb9d4ac78b | ||
|
|
76bd7bc3af | ||
|
|
c4fee770ca | ||
|
|
c0f72e5fe3 | ||
|
|
7ec4216ba2 | ||
|
|
d33563ebe2 | ||
|
|
95243c7698 | ||
|
|
4240873586 | ||
|
|
9eb2077dd1 | ||
|
|
3fe53ee834 | ||
|
|
9e7583d99b | ||
|
|
9f03eeb354 | ||
|
|
ec7cfbb8ac | ||
|
|
d2d6518a5f | ||
|
|
045938728f | ||
|
|
2e17315d5d | ||
|
|
678cb0f57d | ||
|
|
a1eafbd1c5 | ||
|
|
c8f44fbbef | ||
|
|
fd07262983 | ||
|
|
9719f955a4 | ||
|
|
98aa5dd1b5 | ||
|
|
9a5ab203d2 | ||
|
|
0cb3c692ac | ||
|
|
5bd755f604 | ||
|
|
b0e35aa988 | ||
|
|
bb990d9bfa | ||
|
|
2adec7f128 | ||
|
|
038c4c0ca7 | ||
|
|
fc87f5a5c6 | ||
|
|
a851761270 | ||
|
|
c84bdda2f1 | ||
|
|
886969bd19 | ||
|
|
6c03d7e8de | ||
|
|
ef646cf626 | ||
|
|
aa06d87410 | ||
|
|
c422084a4d | ||
|
|
1779610626 | ||
|
|
7cec02403e | ||
|
|
7ae583895d | ||
|
|
b7fe88a72e | ||
|
|
16c3e222b7 | ||
|
|
04d014b662 | ||
|
|
ffd88ee73d | ||
|
|
279d32080e | ||
|
|
6db7d29218 | ||
|
|
11aa5b99b3 | ||
|
|
567172f530 | ||
|
|
6a4bcadf1e | ||
|
|
cdc07eef35 | ||
|
|
0a1b1b185d | ||
|
|
a0f78c0a1f | ||
|
|
03183ae308 | ||
|
|
58b4208e1f | ||
|
|
2ac0200c11 | ||
|
|
a6963aaeea | ||
|
|
9a9da473c6 | ||
|
|
b7dda1433a | ||
|
|
a19b67eb58 | ||
|
|
3903e0a894 | ||
|
|
0982bdedee | ||
|
|
62d2a47878 | ||
|
|
8e8157f550 | ||
|
|
c2da4cf912 | ||
|
|
0888ca8ac3 | ||
|
|
77d96affc3 | ||
|
|
a18f86cc89 | ||
|
|
ad7b9bcac6 | ||
|
|
19f1069109 | ||
|
|
5088846d45 | ||
|
|
b0f07331a2 | ||
|
|
1053073302 | ||
|
|
a50fdf9cc9 | ||
|
|
99579a5421 | ||
|
|
0000566f5e | ||
|
|
b8b9a90c6f | ||
|
|
dfb3815eb3 | ||
|
|
bf9fa3cc60 | ||
|
|
64669e9a7c | ||
|
|
07b85277ee | ||
|
|
1f72dc30be | ||
|
|
525a4e02fe | ||
|
|
8b34dc533f | ||
|
|
4b54d72c23 | ||
|
|
6b84a4f046 | ||
|
|
67865b23e8 | ||
|
|
e82de5cb97 | ||
|
|
fe4bb3fe0c | ||
|
|
a4d3d8541f | ||
|
|
e8abd44712 | ||
|
|
cc493b2d8b | ||
|
|
ab1849cbf8 | ||
|
|
265645a8fc | ||
|
|
9b68982738 | ||
|
|
ef8a63f8c3 | ||
|
|
9ecf197aa7 | ||
|
|
072b4ff3da | ||
|
|
0235d11d4c | ||
|
|
767feacf72 | ||
|
|
d01abd0e4e | ||
|
|
f0d5acb800 | ||
|
|
7c690b19e1 | ||
|
|
d47abe484d | ||
|
|
f431791c26 | ||
|
|
53a18ec037 | ||
|
|
44067c8779 | ||
|
|
24fa4668ef | ||
|
|
ce235c6f80 | ||
|
|
811d78729d | ||
|
|
52471b37e9 | ||
|
|
44663d9601 | ||
|
|
9049eb53c5 | ||
|
|
a36228f0ec | ||
|
|
d9f2b070cb | ||
|
|
38d62661ce | ||
|
|
e85a4801e9 | ||
|
|
239c78e3a0 | ||
|
|
8063aa7173 | ||
|
|
2cfc56d515 | ||
|
|
ce42466dd6 | ||
|
|
79adead5bf | ||
|
|
a966917c76 | ||
|
|
06db0bd373 | ||
|
|
6c596fca13 | ||
|
|
5cdc72a0ed | ||
|
|
589608a5d3 | ||
|
|
55d9e8f86d | ||
|
|
84495f1c9b | ||
|
|
095b483ce1 | ||
|
|
816a6efb77 | ||
|
|
1b3e87c9c0 | ||
|
|
2a5d308c55 | ||
|
|
b1466e451c | ||
|
|
24543e1486 | ||
|
|
d49e114d84 | ||
|
|
da26cf832a | ||
|
|
5029626f9e | ||
|
|
590ac97e93 | ||
|
|
9b8283b908 | ||
|
|
30ef8306f8 | ||
|
|
7587ae2690 | ||
|
|
b74fca0fb0 | ||
|
|
8198d6e35a | ||
|
|
1c1df8728d | ||
|
|
52253e89c5 | ||
|
|
63eacf115d | ||
|
|
76bac6495e | ||
|
|
16371a8502 | ||
|
|
717ae97af5 | ||
|
|
01c8c85ebc | ||
|
|
187ee51ae0 | ||
|
|
4ef6458e4e | ||
|
|
152c2c45e6 | ||
|
|
96130d22c7 | ||
|
|
988d678eec | ||
|
|
797aaac058 | ||
|
|
7b339fe921 | ||
|
|
5703db0b80 | ||
|
|
1816f2dc92 | ||
|
|
09c2e71141 | ||
|
|
dfde8a6724 | ||
|
|
0d8a92f53b | ||
|
|
e8940766d7 | ||
|
|
b5bcf577b2 | ||
|
|
4eee6b17a8 | ||
|
|
82616dcfb8 | ||
|
|
5ddd4b115f | ||
|
|
c8f8fc45eb | ||
|
|
9ac264184e | ||
|
|
3c04a3c36f | ||
|
|
278b41b8ff | ||
|
|
c09dab9fef | ||
|
|
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 | ||
|
|
4110f5ff10 | ||
|
|
a72e46e7b6 | ||
|
|
172dc4edc6 | ||
|
|
31f0389397 | ||
|
|
c1bf044a85 | ||
|
|
2578ea69a3 | ||
|
|
3a1167ffd5 | ||
|
|
52bd33bb06 | ||
|
|
516ef23703 | ||
|
|
6f3591ae11 | ||
|
|
c6037f3f7d | ||
|
|
4a4e82daba | ||
|
|
0a9d29f227 | ||
|
|
055ddbee7a | ||
|
|
89d113a52a | ||
|
|
1e036eaa17 | ||
|
|
d65b04571c | ||
|
|
2a232036c2 | ||
|
|
bde132e0bf | ||
|
|
86b31e71d3 | ||
|
|
4b1c025427 | ||
|
|
783ce8f44b | ||
|
|
a4b6bd0179 | ||
|
|
ef65ceafb8 | ||
|
|
da404551b1 | ||
|
|
f2ae345f20 | ||
|
|
3ea890496e | ||
|
|
7a086baa7b | ||
|
|
18dd68eb1d | ||
|
|
f3a19480d8 | ||
|
|
d484a716ad | ||
|
|
6594badf37 | ||
|
|
5f03a5991e | ||
|
|
69caa17ff7 | ||
|
|
ba467b2b72 | ||
|
|
9477e7cb08 | ||
|
|
29c014deef | ||
|
|
0dfb074820 | ||
|
|
adab577f9a | ||
|
|
05fe56ec1a | ||
|
|
eb4e2d21ba | ||
|
|
fb087c879b | ||
|
|
f887489603 | ||
|
|
705c2b9077 | ||
|
|
8840fbc87a | ||
|
|
0a6e08a9c3 | ||
|
|
075104f448 | ||
|
|
fff096876d | ||
|
|
0224ca1e24 | ||
|
|
ea0e4719fc | ||
|
|
039b9f4f03 | ||
|
|
2cba9ed4cd | ||
|
|
315745b022 | ||
|
|
c3d37f367d | ||
|
|
952ca5e4ed | ||
|
|
800b27013d | ||
|
|
253707f65f | ||
|
|
8a5da53870 | ||
|
|
6125f49ab1 | ||
|
|
7bf80663dc | ||
|
|
6b4a7da010 | ||
|
|
b50373d6db | ||
|
|
1e1458caf1 | ||
|
|
76696826cd | ||
|
|
3a28a65769 | ||
|
|
01fd2d0cb4 | ||
|
|
f8b2f8ce2e | ||
|
|
d4096db1f0 | ||
|
|
28c936a256 | ||
|
|
750db81371 | ||
|
|
79adfc0b57 | ||
|
|
90318fd6d6 | ||
|
|
95b14c8df0 | ||
|
|
2dd8f7da55 | ||
|
|
08b0704b0c | ||
|
|
d13f6657f8 | ||
|
|
bddc113f46 | ||
|
|
52dc4f9822 | ||
|
|
33d59b26a6 | ||
|
|
b7329ee904 | ||
|
|
9a398d79ff | ||
|
|
9afb266fce | ||
|
|
df0e8928d7 | ||
|
|
75eb152f03 | ||
|
|
2a43710e52 | ||
|
|
6c84416c3c | ||
|
|
807230167d | ||
|
|
f9d04ffc98 | ||
|
|
7bcb4e4802 | ||
|
|
9209909ff7 | ||
|
|
edd2b0ef73 | ||
|
|
8ac95607b7 | ||
|
|
7b22753330 | ||
|
|
d8ab50b5fc | ||
|
|
8d252c8b2e | ||
|
|
686f8c9692 | ||
|
|
7f2ff83c44 | ||
|
|
0c19777c8a | ||
|
|
bf3cd7ac09 | ||
|
|
245015fd89 | ||
|
|
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 | ||
|
|
6cf896e76c | ||
|
|
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 | ||
|
|
df0c4790b7 | ||
|
|
30372886cf | ||
|
|
217bea462d | ||
|
|
5e1c247eda | ||
|
|
faf2a13468 | ||
|
|
9f1a62470d | ||
|
|
e1e15cf3ab | ||
|
|
cfce845f70 | ||
|
|
87b348fecc | ||
|
|
acd9e7d54e | ||
|
|
33ad8264c8 | ||
|
|
75fae5535f | ||
|
|
acb08ff5f0 | ||
|
|
092b3cc890 | ||
|
|
b9dec547df | ||
|
|
525f00b719 | ||
|
|
48c3f65e12 | ||
|
|
6754f04a05 | ||
|
|
7f2a6977b5 | ||
|
|
c5a8fd2f52 | ||
|
|
7c7e8deaaa | ||
|
|
6347aad211 | ||
|
|
c5efedd740 | ||
|
|
ef0ce90b66 | ||
|
|
2ec2698331 | ||
|
|
f855836af1 | ||
|
|
7a1035a3d5 | ||
|
|
480b0b2dd7 | ||
|
|
b01abf6dd7 | ||
|
|
d37386721a | ||
|
|
1c52ad1c6e | ||
|
|
e7e4a19cfa | ||
|
|
18f81b037b | ||
|
|
ad953ed82d | ||
|
|
03bebb572d | ||
|
|
5a34f4118c | ||
|
|
59476adfd3 | ||
|
|
2d7311197d | ||
|
|
4e846b8f02 | ||
|
|
3e97e49bfb | ||
|
|
ef04444f69 | ||
|
|
4e1c18f949 | ||
|
|
19fb808b2b | ||
|
|
3055cbb0c0 | ||
|
|
e27a7e8ea4 | ||
|
|
b25a49ee5a | ||
|
|
157e2a9547 | ||
|
|
638f32d705 | ||
|
|
deea994688 | ||
|
|
e26a5f5f42 | ||
|
|
050bfbd140 | ||
|
|
7c26ea4f44 | ||
|
|
3b947b94bd | ||
|
|
7b2f1ac200 | ||
|
|
69f76ca0a5 | ||
|
|
d87d0373ee | ||
|
|
6a35fcd3a9 | ||
|
|
df49660bb5 | ||
|
|
df70f6ad69 | ||
|
|
ec920cd975 | ||
|
|
8f432ee0c1 | ||
|
|
69570e5b54 | ||
|
|
66bf162aa9 | ||
|
|
8d294c08f8 | ||
|
|
cc36a3eba6 | ||
|
|
a10eb47032 | ||
|
|
4fd015f836 | ||
|
|
fc095e7fe5 | ||
|
|
902220e4d4 | ||
|
|
a77c351da3 | ||
|
|
5243f9a2a8 | ||
|
|
ce8eda7575 | ||
|
|
bb1048e0df | ||
|
|
5f122fb30d | ||
|
|
f68e6c438a | ||
|
|
6122dac051 | ||
|
|
1cf2dfd781 | ||
|
|
3f44d4ed43 | ||
|
|
9bd609a572 | ||
|
|
99b3ffe48a | ||
|
|
3731299851 | ||
|
|
7442bc9bb1 | ||
|
|
f1ff34f533 | ||
|
|
0f32d4d4cc | ||
|
|
c716584e19 | ||
|
|
a8452b908c | ||
|
|
f935d6ee79 | ||
|
|
ceae137642 | ||
|
|
776b771f59 | ||
|
|
b76161b698 | ||
|
|
cc0eb1caf5 | ||
|
|
adb14703e4 | ||
|
|
cb655d9ec4 | ||
|
|
6a12573e4f | ||
|
|
cffc932e57 | ||
|
|
e7c31321f3 | ||
|
|
55dfcc95d1 | ||
|
|
75430d2472 | ||
|
|
113292cc1a | ||
|
|
9850a41f29 | ||
|
|
eddc846f51 | ||
|
|
d552da531f | ||
|
|
e60c41c2c8 | ||
|
|
b9345a3576 | ||
|
|
e102ed3656 | ||
|
|
27d934e726 | ||
|
|
18a2558104 | ||
|
|
54e3761d62 | ||
|
|
153381e03b | ||
|
|
62a0b683e5 | ||
|
|
86824b592a | ||
|
|
d7c3818859 | ||
|
|
240aa848a0 | ||
|
|
534b44d430 | ||
|
|
992f60c79c | ||
|
|
7208c610cd | ||
|
|
b2a3b0f861 | ||
|
|
16b7c38b6b | ||
|
|
99992fe78c | ||
|
|
75d3901ab4 | ||
|
|
592991789e | ||
|
|
7414719f37 | ||
|
|
814ae99a32 | ||
|
|
0434bb1397 | ||
|
|
7c64b27753 | ||
|
|
5589d97257 | ||
|
|
4410486fc2 | ||
|
|
849cc79675 | ||
|
|
2afbfd9477 | ||
|
|
09d64ab2ec | ||
|
|
404940b40d | ||
|
|
63b487589c | ||
|
|
6b98c21fed | ||
|
|
bd00c3855c | ||
|
|
aa4f038f1a | ||
|
|
6964c50d00 | ||
|
|
330a698397 | ||
|
|
718bb5c7fb | ||
|
|
4aa837e933 | ||
|
|
840939faf7 | ||
|
|
15fea1ed5d | ||
|
|
9ed4bdd4b1 | ||
|
|
3bc2d92b4e | ||
|
|
e5b5f9907f | ||
|
|
0d3228ef9c | ||
|
|
f9d0d26e5b | ||
|
|
f68f737b3d | ||
|
|
dba0bea03d | ||
|
|
e77503a815 | ||
|
|
3a4f18647f | ||
|
|
af0174942a | ||
|
|
dc2ffda4d6 | ||
|
|
9001c76b56 | ||
|
|
3364f7ca30 | ||
|
|
f6deacbf9a | ||
|
|
d034c8a17b | ||
|
|
d03eba8648 | ||
|
|
1fa02e5499 | ||
|
|
5033eb8408 | ||
|
|
c4c48d8527 | ||
|
|
9ef16e1e6c | ||
|
|
30347ee42f | ||
|
|
7b25efd8cd | ||
|
|
59721f7c87 | ||
|
|
fe4f59a856 | ||
|
|
abcec3938b | ||
|
|
ec33d4011b | ||
|
|
35a6fbccc0 | ||
|
|
1aad99ff4c | ||
|
|
734c8e18bc | ||
|
|
c4a88ee64a | ||
|
|
d07624da1f | ||
|
|
82340cee5c | ||
|
|
3b9d5b9423 | ||
|
|
be859f6cc5 | ||
|
|
b92370d32f | ||
|
|
94e13543ec | ||
|
|
0734281104 | ||
|
|
fa88fbb942 | ||
|
|
d3348bcbbc | ||
|
|
1f5bc965fe | ||
|
|
cfe6b42d00 | ||
|
|
42d08558ab |
@@ -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,16 +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 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:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "crates/playback/**"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "47 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -24,27 +27,5 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
# 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/freebsd.yml@main
|
||||
@@ -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
|
||||
@@ -19,49 +19,5 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/soundcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/soundcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.soundcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.soundcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml"
|
||||
python3 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.soundcraft" and m["command"] == "soundcraft", m["id"]
|
||||
assert "--socket=pulseaudio" in m["finish-args"], "audio needs --socket=pulseaudio"
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args", "add-extensions"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
# 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/packaging-lint.yml@main
|
||||
|
||||
+22
-357
@@ -1,11 +1,3 @@
|
||||
# Release pipeline: every push to `release` builds signed installers for every platform and
|
||||
# creates or updates the draft GitHub Release "SoundCraft v<version>". See docs/releasing.md.
|
||||
#
|
||||
# The jobs that sign (macOS, Windows) and the one that drafts the release run in the `release`
|
||||
# environment (restricted to the `release` branch), which holds the signing secrets. Every secret
|
||||
# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
|
||||
# other jobs sign nothing and get no secrets, so a dispatch on any branch builds and checks them
|
||||
# (a dry run); the signed jobs are refused there, and so the draft release is skipped.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -18,360 +10,33 @@ on:
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
SOUNDCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job reads no secrets (and gates every other job).
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### SoundCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
environment: release
|
||||
env:
|
||||
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: packaging/macos/import-cert.sh
|
||||
- name: Build, sign, notarize
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
libasound2-dev pkg-config \
|
||||
desktop-file-utils appstream python3-yaml zsync
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/soundcraft$|metainfo|applications'
|
||||
ldd target/release/soundcraft
|
||||
# cpal's ALSA backend: the one audio library linked at build time (see nfpm.yaml).
|
||||
ldd target/release/soundcraft | grep -q libasound
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.soundcraft.yml')); assert m['id'] == 'ai.storyteller.soundcraft'"
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
# Update information is embedded, and the .zsync for delta updates sits beside it.
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|storytold|soundcraft|latest|soundcraft-*-linux-${{ matrix.arch }}.AppImage.zsync'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
|
||||
# `appstreamcli compose`, which renders the scalable icon and fails without it.
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/soundcraft-$SOUNDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml).
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: inputs.version != ''
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
|
||||
- name: Build and package in a FreeBSD VM
|
||||
# v1.5.9, pinned by commit.
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
mem: 12288
|
||||
# Passed through by name; never templated into the script.
|
||||
envs: "SOUNDCRAFT_VERSION SOUNDCRAFT_BUILD_DATE SOUNDCRAFT_BUILD_SHA"
|
||||
# alsa-lib: cpal's audio backend on FreeBSD (ALSA over OSS via alsa-plugins-oss at runtime).
|
||||
prepare: |
|
||||
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM.
|
||||
export CARGO_TARGET_DIR=/var/tmp/soundcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
|
||||
- name: Build and zip
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
# Needs the signed jobs, which only run on the `release` branch: a branch dispatch never drafts.
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
# The shared workflow creates the draft release, which needs write access to contents.
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -- * | tee SHA256SUMS.txt
|
||||
- name: Create or update the draft release
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="SoundCraft v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != "true" ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updating draft $tag: replacing its assets"
|
||||
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
|
||||
gh release delete-asset "$tag" "$asset" --yes
|
||||
done
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
echo "Creating draft $tag"
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
# 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/release.yml@main
|
||||
with:
|
||||
app-name: soundcraft
|
||||
app-title: SoundCraft
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
|
||||
@@ -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
|
||||
@@ -4,19 +4,17 @@
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
|
||||
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "53 5 * * *"
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -26,70 +24,10 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: soundcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
install:
|
||||
name: install and run on ARM64
|
||||
needs: package
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: soundcraft-windows-arm64
|
||||
path: dist
|
||||
- name: Install the MSI, check it, run it, uninstall it
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
|
||||
$dir = Join-Path $env:ProgramFiles 'SoundCraft'
|
||||
foreach ($exe in 'soundcraft.exe', 'soundcraft-cli.exe') {
|
||||
$path = Join-Path $dir $exe
|
||||
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
|
||||
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
|
||||
Write-Output "ok $path (ARM64)"
|
||||
}
|
||||
# The installed CLI runs natively on ARM64.
|
||||
$cli = Join-Path $dir 'soundcraft-cli.exe'
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "soundcraft-cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok soundcraft-cli --version: $($out.Trim())"
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
|
||||
if (Test-Path (Join-Path $dir 'soundcraft.exe')) { throw 'uninstall left soundcraft.exe behind' }
|
||||
Write-Output 'ok uninstalled'
|
||||
windows-arm64:
|
||||
# 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/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: soundcraft
|
||||
app-dir: SoundCraft
|
||||
|
||||
@@ -6,7 +6,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed).
|
||||
2. Read `ROADMAP.md` (committed) for status, milestones and the parity estimate.
|
||||
2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs follow craftrules `standards/progress-docs.md`: `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
|
||||
3. Work autonomously; don't stop to ask unless a decision is genuinely the user's.
|
||||
|
||||
## ⚠️ Assets: the absolute rule
|
||||
@@ -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`)
|
||||
@@ -51,7 +52,7 @@ Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin: panels read e
|
||||
## Everything is a command
|
||||
User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (windows, dialogs) live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). Menus, shortcuts, the CLI, the control channel and MCP all dispatch the same ids. Programmatic calls never open dialogs; only menu-style invocation (`ui.menu.invoke`) does. New commands go in a new module with its own `specs()`, registered by one line in `cmd/mod.rs`.
|
||||
|
||||
Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the incumbent's menu leaves (names only). `cargo xtask parity` writes `docs/parity.md`; a test enforces a floor that only rises.
|
||||
Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the incumbent's menu leaves (names only). `cargo xtask parity` writes `docs/parity-checklist.md` (generated; the hand-written assessment is `docs/target-app-parity.md`); a test enforces a floor that only rises.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p soundcraft -- --demo --control 0` (demo session + control channel on a free port; the port is printed).
|
||||
@@ -66,10 +67,10 @@ Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the inc
|
||||
`cargo xtask ci` = fmt check, `clippy --workspace --all-targets -D warnings`, tests, assets, layers, wasm check. Commit after each arc of work that builds; one topic per commit.
|
||||
|
||||
## Release
|
||||
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak manifest), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`.
|
||||
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone lands.
|
||||
`ROADMAP.md` (committed) is the one-page summary (stage, numbers, dimensions, languages, progress log); milestones live in `docs/roadmap.md`. Update both whenever a milestone lands, and close or shrink the matching `docs/gaps.md` entry.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
|
||||
+11
-1
@@ -22,9 +22,19 @@ are bundled.
|
||||
## Fonts
|
||||
|
||||
SoundCraft bundles no font files. It uses the default fonts shipped inside the `egui` crate
|
||||
(Ubuntu-Light: Ubuntu Font Licence 1.0; Hack: MIT; Noto Emoji: OFL-1.1; emoji-icon-font: OFL-1.1/MIT)
|
||||
(Ubuntu-Light: Ubuntu Font Licence 1.0; Hack: MIT; Noto Emoji: OFL-1.1; emoji-icon-font: MIT)
|
||||
and, at runtime, the operating system's own UI fonts when available (never redistributed).
|
||||
|
||||
## Bundled egui font icons
|
||||
|
||||
These dependency fonts are embedded by the pinned egui font definitions; no font file is added
|
||||
here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts.
|
||||
|
||||
| Asset | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in NOTICE) |
|
||||
| `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in NOTICE) |
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Author | Source | License |
|
||||
|
||||
Generated
+1104
-411
File diff suppressed because it is too large
Load Diff
+44
-7
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
version = "0.13.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -32,19 +32,36 @@ 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"
|
||||
cpal = "0.16"
|
||||
# Session bundles (one file holding a session and its audio, for the browser): stored entries only.
|
||||
zip = { version = "9", default-features = false }
|
||||
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 = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
|
||||
|
||||
# Video containers and bitstreams, shared with the other craft apps.
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
dark-light = "3"
|
||||
# 650b1c0b058017c4eb3af06d5b39586d6732e07b is the revision produced by the separate craft-libs current-main-0135
|
||||
# integration: 25da's egui-0.37 port + 6e7f's docking Workspace/DockCommand + final egui/winit
|
||||
# pins. Raw 6e7f/16fb3d46 contain the older egui 0.36 UI; keep this reviewed
|
||||
# immutable revision to retain the required docking APIs on egui 0.37.
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "650b1c0b058017c4eb3af06d5b39586d6732e07b", features = ["serde"] }
|
||||
egui = { version = "0.37" }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent).
|
||||
web-time = "1.1"
|
||||
egui_kittest = "0.37"
|
||||
rfd = "0.17"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
@@ -59,12 +76,32 @@ 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
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
[patch.crates-io]
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -12,6 +12,277 @@ Third-party material
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in ATTRIBUTION.md.
|
||||
|
||||
None: every bundled asset is original to SoundCraft. Icons are drawn in code; demo audio is
|
||||
synthesised in code. The egui default fonts (Ubuntu-Light, Hack, Noto Emoji, Emoji Icon Font) ship
|
||||
inside the egui crate under their own open licenses (see ATTRIBUTION.md).
|
||||
Original SoundCraft material: icons are drawn in code; demo audio is synthesised in code.
|
||||
The egui default fonts (Ubuntu-Light, Hack, Noto Emoji, Emoji Icon Font) ship inside the egui crate
|
||||
under their own open licenses (see ATTRIBUTION.md).
|
||||
|
||||
Default egui fonts (dependency assets)
|
||||
|
||||
The pinned egui 0.37 dependency embeds these faces through `default_fonts` and
|
||||
`monochrome_emoji_fonts`:
|
||||
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
No font file is copied into this repository. The full upstream notices follow.
|
||||
|
||||
egui icon font subset (dependency asset)
|
||||
----------------------------------------
|
||||
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
|
||||
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
|
||||
uses the same MIT license. Upstream license text follows unchanged.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
Noto Emoji font (dependency asset)
|
||||
----------------------------------
|
||||
epaint_default_fonts 0.37, pinned with egui 0.37 at
|
||||
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts,
|
||||
embeds NotoEmoji-Regular.ttf, the monochrome emoji fallback used when egui's
|
||||
`monochrome_emoji_fonts` feature is enabled. The font's copyright notice and the
|
||||
upstream SIL Open Font License 1.1 text follow unchanged.
|
||||
|
||||
Copyright 2013 Google Inc. All Rights Reserved.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Ubuntu Light font (dependency asset)
|
||||
------------------------------------
|
||||
epaint_default_fonts 0.37, pinned with egui 0.37 at
|
||||
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts,
|
||||
embeds Ubuntu-Light.ttf as a default fallback. The font's copyright notice and the
|
||||
upstream Ubuntu Font Licence 1.0 text follow unchanged.
|
||||
|
||||
Copyright 2011 Canonical Ltd. Licensed under the Ubuntu Font Licence 1.0
|
||||
|
||||
-------------------------------
|
||||
UBUNTU FONT LICENCE Version 1.0
|
||||
-------------------------------
|
||||
|
||||
PREAMBLE
|
||||
This licence allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely. The fonts, including any derivative works, can be
|
||||
bundled, embedded, and redistributed provided the terms of this licence
|
||||
are met. The fonts and derivatives, however, cannot be released under
|
||||
any other licence. The requirement for fonts to remain under this
|
||||
licence does not require any document created using the fonts or their
|
||||
derivatives to be published under this licence, as long as the primary
|
||||
purpose of the document is not to be a vehicle for the distribution of
|
||||
the fonts.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this licence and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Original Version" refers to the collection of Font Software components
|
||||
as received under this licence.
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to
|
||||
a new environment.
|
||||
|
||||
"Copyright Holder(s)" refers to all individuals and companies who have a
|
||||
copyright ownership of the Font Software.
|
||||
|
||||
"Substantially Changed" refers to Modified Versions which can be easily
|
||||
identified as dissimilar to the Font Software by users of the Font
|
||||
Software comparing the Original Version with the Modified Version.
|
||||
|
||||
To "Propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification and with or without charging
|
||||
a redistribution fee), making available to the public, and in some
|
||||
countries other activities as well.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
This licence does not grant any rights under trademark law and all such
|
||||
rights are reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of the Font Software, to propagate the Font Software, subject to
|
||||
the below conditions:
|
||||
|
||||
1) Each copy of the Font Software must contain the above copyright
|
||||
notice and this licence. These can be included either as stand-alone
|
||||
text files, human-readable headers or in the appropriate machine-
|
||||
readable metadata fields within text or binary files as long as those
|
||||
fields can be easily viewed by the user.
|
||||
|
||||
2) The font name complies with the following:
|
||||
(a) The Original Version must retain its name, unmodified.
|
||||
(b) Modified Versions which are Substantially Changed must be renamed to
|
||||
avoid use of the name of the Original Version or similar names entirely.
|
||||
(c) Modified Versions which are not Substantially Changed must be
|
||||
renamed to both (i) retain the name of the Original Version and (ii) add
|
||||
additional naming elements to distinguish the Modified Version from the
|
||||
Original Version. The name of such Modified Versions must be the name of
|
||||
the Original Version, with "derivative X" where X represents the name of
|
||||
the new work, appended to that name.
|
||||
|
||||
3) The name(s) of the Copyright Holder(s) and any contributor to the
|
||||
Font Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except (i) as required by this licence, (ii) to
|
||||
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
|
||||
their explicit written permission.
|
||||
|
||||
4) The Font Software, modified or unmodified, in part or in whole, must
|
||||
be distributed entirely under this licence, and must not be distributed
|
||||
under any other licence. The requirement for fonts to remain under this
|
||||
licence does not affect any document created using the Font Software,
|
||||
except any version of the Font Software extracted from a document
|
||||
created using the Font Software may only be distributed under this
|
||||
licence.
|
||||
|
||||
TERMINATION
|
||||
This licence becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Hack font (dependency asset)
|
||||
----------------------------
|
||||
epaint_default_fonts 0.37, pinned with egui 0.37 at
|
||||
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts,
|
||||
embeds Hack-Regular.ttf as the default monospace fallback. The upstream notice and
|
||||
licences follow unchanged.
|
||||
|
||||
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
|
||||
|
||||
The work in the DejaVu project was committed to the public domain.
|
||||
|
||||
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Source Foundry Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
BITSTREAM VERA LICENSE
|
||||
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
|
||||
|
||||
@@ -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">
|
||||
@@ -107,17 +114,19 @@ where things are.
|
||||
Scrubber tools; cut, copy, paste, duplicate, repeat, shift, insert silence, separate (at
|
||||
selection, on grid, at transients), heal, consolidate, strip silence, nudge; fades and
|
||||
crossfades in five shapes, drawn straight from the clip corners; playlists with comping lanes;
|
||||
clip gain lines; edit groups; single-key Commands Focus shortcuts; undo for everything.
|
||||
clip gain lines; edit groups; single-key Commands Focus shortcuts; trackpad scrolling that
|
||||
follows your fingers and pinch-to-zoom around the pointer; undo for everything.
|
||||
- **Mixing:** fader and pan with automation, pre/post sends, busses, aux inputs, master faders,
|
||||
VCA masters, routing folders, solo modes, mute and solo groups, phase invert and trim, clip
|
||||
effects, and automatic plugin delay compensation.
|
||||
- **Automation:** volume, pan, mute, send and every plugin parameter; Write, Touch, Latch and
|
||||
Trim passes recorded live from the faders; thin, glide, coalesce and convert to clip gain.
|
||||
- **Plugins:** 7-band and 1-band EQ, compressor/limiter, expander/gate, de-esser, maximizer,
|
||||
channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, saturator, lo-fi,
|
||||
rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, signal generator, dither,
|
||||
a subtractive synth and a drum synth, all original. **CLAP plugins** load too. Any processor
|
||||
runs offline as AudioSuite, and user presets save per plugin.
|
||||
channel strip, room and plate reverbs, mod delay, auto pan, chorus, flanger, phaser, ring
|
||||
modulator, saturator, lo-fi, rectifier, subharmonic, pitch shifter, time shift, gain, trim,
|
||||
invert, DC removal, signal generator, dither, a subtractive synth, a drum synth and a sampler
|
||||
(plays any WAV, AIFF, FLAC, MP3 or OGG across the keyboard), all original. **CLAP plugins**
|
||||
load too. Any processor runs offline as AudioSuite, and user presets save per plugin.
|
||||
- **MIDI:** instrument and MIDI tracks, a piano-roll MIDI editor with a velocity lane, an event
|
||||
list, a score view, step input, quantize, transpose, real-time properties, Standard MIDI Files,
|
||||
and Audio-to-MIDI from a sung or played melody.
|
||||
@@ -129,7 +138,7 @@ where things are.
|
||||
pitch-preserving Elastic warping.
|
||||
- **Audio files:** WAV/BWF/RF64, AIFF/AIFC and FLAC in and out; MP3, Ogg Vorbis, AAC, ALAC, CAF
|
||||
and the audio of MP4/MOV movies in. Any sample rate and bit depth.
|
||||
- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with dither and
|
||||
- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with optional dither and
|
||||
normalisation, reporting peak, true peak and integrated loudness (LUFS).
|
||||
- **Fast:** a parallel mix engine, copy-on-write undo, and a UI that holds 120 frames per second
|
||||
with seventy-odd tracks playing.
|
||||
@@ -137,7 +146,8 @@ where things are.
|
||||
## Getting started
|
||||
|
||||
Download a build from the [releases page](https://github.com/storytold/soundcraft/releases), or build
|
||||
from source with a recent stable Rust:
|
||||
from source with Rust 1.95 or later for the desktop/web UI (the core workspace's declared
|
||||
minimum remains Rust 1.90):
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/soundcraft
|
||||
@@ -201,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).
|
||||
@@ -236,15 +249,20 @@ and layering checks and the WebAssembly build.
|
||||
|
||||
## Status and roadmap
|
||||
|
||||
SoundCraft is **pre-alpha, and close to its first alpha**: it covers 94 % of the incumbent's menu
|
||||
items and roughly two thirds of its features in depth. The core works and is fun to use, and plenty is still missing. The
|
||||
honest status, what's next and our effort estimates are in [`ROADMAP.md`](ROADMAP.md); the
|
||||
menu-by-menu comparison is in [`docs/parity.md`](docs/parity.md). Bug reports and wish lists are
|
||||
SoundCraft is **pre-alpha**: it covers 94 % of the incumbent's menu items (measured), about 70 % of
|
||||
its features by presence, and is roughly 40 % of the way to replacing it for real work: record,
|
||||
edit, mix and bounce work end to end, but choosing an audio interface and buffer size,
|
||||
recording MIDI from a controller and session interchange (AAF/OMF) are still missing, and those
|
||||
are the alpha checklist. Translations are missing too. The status, the stage and our effort
|
||||
estimates are in [`ROADMAP.md`](ROADMAP.md); the full assessment is in
|
||||
[`docs/target-app-parity.md`](docs/target-app-parity.md), the ranked list of what's missing in
|
||||
[`docs/gaps.md`](docs/gaps.md), and the menu-by-menu comparison in
|
||||
[`docs/parity-checklist.md`](docs/parity-checklist.md). Bug reports and wish lists are
|
||||
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
|
||||
|
||||
@@ -271,6 +289,10 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
|
||||
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
|
||||
and web backend defaults are unchanged.
|
||||
|
||||
DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcompiler.dll` or
|
||||
`dxil.dll` is needed ("Parent device is lost" at start, #185). `$env:WGPU_DX12_COMPILER = "dxc"`
|
||||
(or `"auto"`) selects wgpu's DXC handling again for troubleshooting.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
@@ -288,6 +310,8 @@ and web backend defaults are unchanged.
|
||||
| Fedora/RHEL/openSUSE | `soundcraft-<ver>-linux-x86_64.rpm` | `soundcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `soundcraft-<ver>-linux-x86_64.tar.gz` | `soundcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
|
||||
RISC-V (riscv64): `soundcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (glibc 2.39+, e.g. Ubuntu 24.04).
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
@@ -358,11 +382,9 @@ 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>
|
||||
</p>
|
||||
|
||||
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. Drag a panel header to a target edge or another header. Its context menu offers Float Panel, Move Panel to Group, and Close Panel; the tab group's menu adds Float Tab Group and Close Tab Group. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
|
||||
|
||||
+184
-55
@@ -1,54 +1,114 @@
|
||||
# SoundCraft roadmap
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
|
||||
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **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 file is
|
||||
the honest status: what works today, what is missing, and how far we are.
|
||||
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
|
||||
the one-page summary; the detail is in [`docs/target-app-parity.md`](docs/target-app-parity.md)
|
||||
(assessment), [`docs/gaps.md`](docs/gaps.md) (ranked work list), [`docs/roadmap.md`](docs/roadmap.md)
|
||||
(milestones and beta plan) and [`docs/parity-checklist.md`](docs/parity-checklist.md) (generated
|
||||
menu checklist).
|
||||
|
||||
## Where we are (2026-10-07)
|
||||
## Headline numbers
|
||||
|
||||
| Measure | Value | How |
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Menu-catalog parity (engine + UI) | **482 / 512 menu items (94 %)**; engine alone 397 / 512, see [`docs/parity.md`](docs/parity.md) | `cargo xtask parity` compares the incumbent's 512 menu leaves (names observed black-box) with our command registry. Most of what is left is Avid's online services (sign-in, cloud projects, collaboration, Splice, Learn), Dolby Atmos-specific items, HEAT and ARA |
|
||||
| Estimated overall feature parity | **~65 %** | judgement across the areas below, weighted by how much working engineers rely on them. Menu items are only the surface; depth (editing feel, plugin catalogue, MIDI and score editing, video) is where the gap is |
|
||||
| Distance to an **alpha** | **~85 % there, ~12–18 wall-clock hours** of Claude Opus 5.5 work | see [Alpha](#alpha) below |
|
||||
| Distance to **100 % parity** | **~110–150 wall-clock hours** of Claude Opus 5.5 work with a single agent; roughly 40–55 hours with 3–4 agents in parallel (the last three features in this session were built in parallel this way) | sum of the per-area estimates below |
|
||||
| Menu breadth | **482 / 512 Pro Tools menu leaves (94.1 %)**; engine alone 397 / 512 | measured (`cargo xtask parity` + UI-layer aliases) |
|
||||
| Feature breadth | **~70 %** | estimated (menus plus plugins, hardware, formats, immersive, cloud) |
|
||||
| **Ready for real work** | **~40 %** | estimated, weighted by dimension |
|
||||
| Mainstream practitioner (tracking/mixing engineer) | **~36 %** | estimated: 50.6 % weekly-area depth × 0.90 interaction × 0.92 stability × 0.85 file exchange ([how](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user)) |
|
||||
| Essentials user (song or podcast, defaults) | **~57 %** | estimated: 72.4 % core-feature depth × 0.90 launch/stability × 0.90 discoverability × 0.97 files ([how](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user)) |
|
||||
| Remaining to alpha | **~110–180 Opus 5.5 agent-hours** (the failing gate rows) | estimated, calibrated on PR history |
|
||||
| Remaining to beta | **~350–570 Opus 5.5 agent-hours** (includes the alpha work; ~60–70 % parallelizable) | estimated |
|
||||
| Remaining to full parity | **~950–1,450 Opus 5.5 agent-hours** | estimated |
|
||||
|
||||
### Area by area
|
||||
| Audience | Ready % | Agent-hours to ~95 % | Work that dominates |
|
||||
|---|---:|---:|---|
|
||||
| Full target (ready for real work) | ~40 % | ~950–1,450 h | Hardware, the stock plugin catalogue, AAF/OMF and immersive, localization, fidelity |
|
||||
| Mainstream practitioner | ~36 % | ~380–600 h | Interface and MIDI recording, AAF/OMF, stock-plugin depth, core-path bugs |
|
||||
| Essentials user | ~57 % | ~110–200 h | Launch and platform fixes, onboarding, effect panels and presets, MP3/AAC export |
|
||||
|
||||
| Area | Status | Parity | Remaining (h) |
|
||||
|---|---|---:|---:|
|
||||
| Session model, save/load, undo | Tracks, playlists, clips, fades, clip gain, automation, markers, groups, busses, video; JSON `.scraft` + Audio Files folder; plugin state; autosave; copy-on-write undo with gesture coalescing | 85 % | 8 |
|
||||
| Audio file I/O | WAV/BWF/RF64, AIFF/AIFC, FLAC (read/write); MP3, OGG, AAC, ALAC, CAF (read); peaks. Whole files are decoded into memory: disk streaming for very long sessions is still to do | 70 % | 12 |
|
||||
| Editing | Slip/Shuffle/Spot/Grid, cut/copy/paste/clear/duplicate/repeat/shift, Paste Special (merge MIDI, markers, clip gain/effects), separate/heal/trim/consolidate/strip silence, fades & crossfades, nudge, playlists with comping, every tool gesture, edit groups, clip groups (and `.scgrp` import/export) | 75 % | 20 |
|
||||
| Mixer & routing | Faders, pan, mute/solo (SIP, implicit solo), sends pre/post, busses, aux inputs, master faders, VCAs, routing folders, 10 inserts, plugin delay compensation, clip effects, preamp/instrument/object views | 75 % | 15 |
|
||||
| Automation | Breakpoint lanes for volume/pan/mute/sends/plugin params, trim automation; live Write/Touch/Latch passes; write/thin/glide/convert/coalesce. Surround-pan automation is still to do | 60 % | 15 |
|
||||
| Plugins (built-in) | 26 original processors + 2 instruments, AudioSuite offline processing for the whole AudioSuite menu | 45 % | 30 |
|
||||
| Third-party plugins | CLAP, VST3 and Audio Units (macOS): audio, parameters, latency, notes, state saved in sessions, instances created/destroyed off the audio thread, editors (floating CLAP GUIs, VST3 views on macOS, verified with Melodyne). Missing: AU editors, embedded CLAP GUIs, VST3 editors on Windows/Linux, ARA | 80 % | 10 |
|
||||
| MIDI & notation | MIDI/instrument tracks, SMF import/export, MIDI editor (piano roll, velocity, rubber-band select, keyboard transpose/move), event list, step input, quantize/transpose/velocity/duration, real-time properties, Score Editor, MusicXML export (Sibelius) and printable SVG score | 55 % | 20 |
|
||||
| Recording | Input capture, punch in/out (selection, on the fly, pre/post-roll), loop record into playlists, input monitoring through the channel strip, autosave and recovery | 60 % | 10 |
|
||||
| Elastic Audio / TCE / Beat Detective | Pitch-preserving warp on Elastic tracks, TCE to timeline, conform to tempo, Beat Detective and Identify Beat windows; audio-to-MIDI pitch detection | 45 % | 15 |
|
||||
| Surround | Main and bus formats from stereo to 9.1.6 and Ambisonics, surround panner with divergence/centre/LFE/height, ITU fold-down, multichannel bounce, Renderer window, object/bed routing | 60 % | 12 |
|
||||
| Video | Picture track with thumbnails, Video and Video Universe windows, H.264 (Baseline/Main/High), ProRes (all flavours) and Motion JPEG decoders in pure Rust, sync offset, relinking. Missing: HEVC/AV1/DNx, timecode-track placement, video export | 55 % | 15 |
|
||||
| UI fidelity | Edit + Mix windows, toolbar, rulers, track headers, menus for the whole catalog (macOS system menu bar), floating windows, dialogs (every file command has a path prompt), command palette | 70 % | 20 |
|
||||
| Agent control | CLI, JSON control channel, MCP server (headless + bridged), offscreen UI renders | 90 % (ahead of the incumbent) | 3 |
|
||||
| Release engineering | Signed macOS universal, Windows x64/x86, Linux AppImage/deb/rpm/tar/Flatpak, FreeBSD, Web/WASM on every push to `release` | 80 % | 3 |
|
||||
Hours are Opus 5.5 agent wall-clock hours, calibrated on this repo's PRs; 60–70 % parallelizes.
|
||||
Detail: [target-app-parity.md](docs/target-app-parity.md#readiness-by-audience).
|
||||
|
||||
## Alpha
|
||||
**Why pre-alpha:** the core-workflow gate in [docs/roadmap.md](docs/roadmap.md#alpha-gate) fails.
|
||||
Editing, mixing, bouncing, and saving and reopening work end to end. But a Pro Tools user can't
|
||||
record from a chosen audio interface at a chosen buffer size (only the default devices, a fixed
|
||||
512-sample block, and takes held in RAM). They can't record MIDI from a keyboard (there is no
|
||||
MIDI device layer). And they can't bring a session in or hand one back (no AAF/OMF/`.ptx`).
|
||||
Readiness (~40 %) sits right at the alpha bar, but a gate failure keeps the app pre-alpha. The
|
||||
2026-10-07 estimate ("~65 % overall, 110–150 h to 100 %") counted feature presence. This one
|
||||
measures readiness and adds hardware, localization and field bug reports.
|
||||
|
||||
An alpha is a build people can download, open the demo or their own audio in, record, edit, mix
|
||||
and bounce without crashes or lost work. Feature-wise SoundCraft is past that bar; what is left is
|
||||
shipping and hardening:
|
||||
## By dimension
|
||||
|
||||
1. **Publish the repository and cut the first release** (needs a decision from the maintainers:
|
||||
the GitHub repo, the release secrets, and a push to the `release` branch). The pipelines exist
|
||||
but have never run end to end. ~2 h once unblocked, mostly fixing what the first run finds.
|
||||
2. **Platform smoke tests** of the installers on Windows, Linux and FreeBSD (only macOS has been
|
||||
exercised by hand). ~3–4 h.
|
||||
3. **Disk streaming** of long sources, so an hour-long multitrack session does not need all of its
|
||||
audio in RAM. ~5–6 h.
|
||||
4. **A hands-on QA pass** in the live app: recording on real interfaces, long sessions, plugin-heavy
|
||||
mixes, save/reopen loops. ~3–4 h.
|
||||
5. **Docs**: a short user guide next to the existing agent/CLI docs. ~1 h.
|
||||
| Dimension | Ready | Remaining (h) | Doc |
|
||||
|---|---:|---:|---|
|
||||
| Features | ~45 % | 520–810 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
| UI/UX fidelity | ~55 % | 60–100 | [ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats | ~40 % | 70–120 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware (interfaces, MIDI, surfaces, sync) | ~12 % | 110–180 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization | ~12 % (1 of Pro Tools' 8 languages) | 70–110 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | ~35 % (unmeasured) | 40–70 | [hardware-parity.md](docs/hardware-parity.md#performance) |
|
||||
| Stability | ~45 % | 50–80 | [gaps.md](docs/gaps.md) |
|
||||
| Platforms | ~65 % (5 platforms built, macOS exercised) | 20–40 | [gaps.md](docs/gaps.md) |
|
||||
| Ecosystem / plugins | ~30 % | in Features + Hardware | [plugin-parity.md](docs/plugin-parity.md) |
|
||||
| AI features | ~5 % | 30–50 | [gaps.md](docs/gaps.md) |
|
||||
| Agent control | ahead of Pro Tools | 3 | [control-protocol.md](docs/control-protocol.md), [mcp.md](docs/mcp.md) |
|
||||
|
||||
## Features
|
||||
|
||||
| Area | Breadth | Ready | Remaining (h) |
|
||||
|---|---:|---:|---:|
|
||||
| Recording and monitoring | 75 % | 40 % | 40–60 |
|
||||
| Editing | 92 % | 65 % | 40–60 |
|
||||
| Mixing and routing | 85 % | 60 % | 35–50 |
|
||||
| Automation | 80 % | 55 % | 20–30 |
|
||||
| Built-in plugins and AudioSuite | 40 % | 35 % | 80–120 |
|
||||
| Third-party plugin hosting | 70 % | 45 % | 40–60 |
|
||||
| MIDI and instruments | 70 % | 30 % | 50–80 |
|
||||
| Score and notation | 60 % | 35 % | 20–30 |
|
||||
| Elastic Audio, TCE, Beat Detective | 70 % | 35 % | 40–60 |
|
||||
| Surround and immersive | 55 % | 35 % | 50–80 |
|
||||
| Video and post | 60 % | 40 % | 40–60 |
|
||||
| Session, file management, interchange | 55 % | 35 % | 60–100 |
|
||||
| Collaboration and cloud (Avid services) | 0 % | 0 % | owner decision |
|
||||
| Agent control | 100 % | 100 % | 3 |
|
||||
|
||||
Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#feature-areas).
|
||||
|
||||
## Languages
|
||||
|
||||
| Language | Code | Status | Translated |
|
||||
|---|---|---|---:|
|
||||
| English | en | full | 100 % |
|
||||
| Simplified Chinese | zh-Hans | none | 0 % |
|
||||
| Spanish | es | none (PR #143 open) | 0 % |
|
||||
| Hindi | hi | none | 0 % |
|
||||
| Arabic | ar | none | 0 % |
|
||||
| French | fr | none (PR #143 open) | 0 % |
|
||||
| Portuguese | pt | none | 0 % |
|
||||
| Indonesian | id | none | 0 % |
|
||||
| Japanese | ja | none | 0 % |
|
||||
| German | de | none | 0 % |
|
||||
| Korean | ko | none | 0 % |
|
||||
| Vietnamese | vi | none | 0 % |
|
||||
|
||||
Other languages shipped: 0. Detail: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
|
||||
Ranked; the alpha gate, the alpha checklist and the beta plan are in [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
1. **Alpha blocker:** audio interfaces: device and buffer-size choice, duplex stream, ASIO; record to disk (G-01, G-06). 35–55 h.
|
||||
2. **Alpha blocker:** MIDI devices: record from keyboards, play instruments live, output (G-02). 20–35 h.
|
||||
3. **Alpha blocker:** AAF/OMF import and export (G-03). 50–90 h.
|
||||
4. macOS plugin entitlement (#153), then AU editors (G-05). 1 h for the entitlement (needed for the mix row), then 15–25 h.
|
||||
5. Land the open bug-fix PRs and clear the correctness backlog (G-04). 15–25 h.
|
||||
6. PR CI running `cargo xtask ci` on three OSes (G-14). 5–10 h.
|
||||
7. Fidelity harness against Pro Tools (G-07). 20–30 h.
|
||||
|
||||
## Robustness
|
||||
|
||||
@@ -57,22 +117,91 @@ command parameters through the engine and mixer; they have caught fade, overflow
|
||||
unbounded-allocation bugs. Every command also runs with empty parameters on empty and demo
|
||||
sessions. Hosted plugins live in isolated unsafe crates (`clap-host`, `vst3-host`, `au-host`) and
|
||||
are created, loaded and destroyed off the audio thread. The video decoders survive hundreds of
|
||||
mutated and truncated movies.
|
||||
mutated and truncated movies. Field reports from the first public week are tracked in
|
||||
[gaps.md](docs/gaps.md) (G-04).
|
||||
|
||||
## Current focus
|
||||
## Progress log
|
||||
|
||||
1. The alpha list above.
|
||||
2. Surround-pan automation; AU editor views.
|
||||
3. Built-in plugin depth (more processors, better UIs).
|
||||
4. MIDI and score editing depth.
|
||||
- **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change.
|
||||
- **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change.
|
||||
- **2026-10-24:** While the Strip Silence window is open, the Edit window draws a rectangle around each range that Strip would keep on the selected tracks, padded by Clip Start Pad and Clip End Pad, and the rectangles follow the sliders as they move. They are removed when the window closes. `edit.strip_silence_preview` returns the same ranges without changing the session. No readiness percentage change.
|
||||
- **2026-10-24:** The Strip Silence window has a Rename button. It opens Rename Selected Clips, where a name, a starting number, a count of zeros and a suffix set the names of the clips that Strip, Extract and Separate leave in the selection (for example SFX023.Reel1, SFX024.Reel1). The app keeps the scheme for later uses of Strip Silence until the app quits, or until Clear and then OK empty it. No readiness percentage change.
|
||||
- **2026-10-24:** The Strip Silence window has Extract and Separate buttons. Extract removes the audio and keeps the silent parts of the selection, and Separate separates the clips at the edges of the silence without removing anything. Minimum Strip Duration goes up to 4,000 ms. No readiness percentage change.
|
||||
- **2026-10-24:** Clip › Loop… (Cmd+Alt+L) opens the Clip Looping dialog: Number of Loops, Loop Length in the main counter's time format, or Loop Until End of Session or Next Clip. A loop length that is not a whole number of loops cuts the last loop short, and Unloop removes that short loop too. No readiness percentage change.
|
||||
- **2026-10-24:** AudioSuite ▸ Pitch Shift ▸ Vari-Fi slows the selected clips down to a complete stop or speeds them up from one, and the pitch follows the speed. Fit To keeps each clip's length and plays two-thirds of it; Extend plays all of it and makes the clip 150 % long. Fades On fades the audio out with the slowdown or in with the speedup. Before, the menu item resampled the clip at a constant speed. No readiness percentage change.
|
||||
- **2026-10-24:** A built-in Auto Pan plugin pans the mono sum of a stereo track between left and right. An LFO with seven waveforms moves it at a rate in BPM or at the session tempo, running free, once, in step with the transport, or when the level crosses a threshold or a manual trigger changes; an envelope follower can move it by level instead. Width, Manual and Path set the field, and Output sets the level. No readiness percentage change.
|
||||
- **2026-10-24:** A built-in Subharmonic (`subharmonic`) adds the octave below a lower and an upper band of the input, chosen from four frequency ranges, with a direct bass band, a gain and a solo for each band, Drive with six characters, HP and LP filters, and a Mix that keeps one path at full level. No readiness percentage change.
|
||||
- **2026-10-23:** A built-in Ring Modulator (`ring_mod`) applies ring modulation, frequency modulation or a positive or negative resonator, and an LFO, an envelope follower or sample and hold moves its frequency. AudioSuite › Modulation › Sci-Fi now runs it instead of the Flanger. No readiness percentage change.
|
||||
- **2026-10-19:** Float32 WAV and AIFF-C encoding preserve finite samples above full scale. Integer PCM still clips at full scale. Tests cover whole-file and streaming encoders, non-finite samples, and bounce output with normalization on and off. No readiness percentage change.
|
||||
- **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-16:** Split into Mono extracts the correct source channel for each mono track instead of playing channel zero on every track. Clip offsets, gain, fades and stretch, source timestamps, Undo/Redo and saved audio are preserved; repeated clips share each extracted mono source. Unavailable media returns an error without partial edits. 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-14:** `soundcraft-cli convert` dithers only when the output loses resolution (fewer bits than the source, a float or lossy source, or resampled/normalized samples); a same-depth, wider or 32-bit float conversion copies the samples exactly and digital silence stays silent. `--dither` and `--no-dither` override the choice (#548)
|
||||
- **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 (VST3 host messages):** The VST3 host creates `IMessage` and `IAttributeList` objects for plugins (`IHostApplication::createInstance`), so plugins whose controller and processor talk through `IConnectionPoint::notify` work: Line 6 Helix Stadium Native now loads its content database (it reported "Failed to get content database recovery info (Out of memory condition)") and exposes its parameters. No readiness percentage change.
|
||||
- **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/
|
||||
localization parity). v0.4.0 released. Community fixes: Cut All Automation, snap to neighbours,
|
||||
tempo stretch, I/O Setup, clip export names, menu-only dialogs, playback stop on new session.
|
||||
- **2026-10-09** — Light theme and live system appearance, clip-gain fader and nudge, missing-media
|
||||
warnings, session audio health, malformed-JSON and encoder-extension checks in the CLI, macOS
|
||||
system menu bar, Windows DX12 default, Linux fontconfig, metronome click in playback.
|
||||
- **2026-10-08** — v0.2.x–v0.3.0 released (Flatpak, AppImage zsync); VST3 quit crash fixed;
|
||||
Ctrl-as-Cmd shortcuts on Windows/Linux; plugin menus by vendor, AU inserts, instrument picker;
|
||||
rotating log file; branded DMG.
|
||||
- **2026-10-07** — Repository published; About window credits; release CI on macOS runners.
|
||||
- **2026-10-06** — Video track, Audio Unit hosting, plugin state and editors, Renderer; Mix
|
||||
window views; score export and print; surround mixing; VST3 hosting; property tests.
|
||||
- **2026-10-05** — Foundation crates, Edit and Mix windows, playback, CLI/MCP, parity report,
|
||||
recording and punch, CLAP hosting, Elastic Audio, Beat Detective, release pipeline.
|
||||
|
||||
## Milestones
|
||||
## Revision history
|
||||
|
||||
- **M0–M4 (done):** workspace, gates, model, IO, DSP, mix engine, command engine, Edit/Mix UI, realtime playback, metering, plugins, bounce.
|
||||
- **M5 (done):** editing depth (tools, modes, playlists, fades, comping, clip groups).
|
||||
- **M6 (done for the first cut):** recording, punch, loop record, monitoring.
|
||||
- **M7 (in progress):** MIDI and notation editing.
|
||||
- **M8 (done):** CLI, control channel, MCP, agent acceptance test.
|
||||
- **M9 (in progress):** AudioSuite breadth (done), Elastic Audio, Beat Detective.
|
||||
- **M10 (next):** alpha release on all platforms.
|
||||
- **M11 (mostly done):** third-party plugin hosting (CLAP, VST3, AU), video, surround.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Strip Silence Extract and Separate; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Clip Looping dialog; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: AudioSuite Vari-Fi speed ramps; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Auto Pan plugin; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Subharmonic plugin; readiness estimates unchanged |
|
||||
| 2026-10-23 | trivial | Progress log: Ring Modulator plugin; readiness estimates unchanged |
|
||||
| 2026-10-19 | trivial | Float32 file encoding preserves finite headroom (#791); readiness estimates unchanged |
|
||||
| 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-16 | trivial | Split into Mono preserves each source channel, clip edits and saved audio; 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 |
|
||||
| 2026-10-10 | major | Rewritten to the progress-docs standard: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md |
|
||||
| 2026-10-07 | major | Menu parity 94 %, ~65 % estimated feature parity, alpha list |
|
||||
| 2026-10-06 | minor | Alpha and parity estimates |
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! 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;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AutomationArgs {
|
||||
pub read_roots: Vec<PathBuf>,
|
||||
pub write_roots: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
/// Returns whether this argument is a root flag. Each flag may be given more than once (#874),
|
||||
/// in either spelling (`--flag DIR`, `--flag=DIR`): every use adds a folder.
|
||||
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 roots = match flag {
|
||||
"--automation-read-root" => &mut self.read_roots,
|
||||
"--automation-write-root" => &mut self.write_roots,
|
||||
_ => return Ok(false),
|
||||
};
|
||||
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"));
|
||||
}
|
||||
roots.push(value.into());
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_add_up_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::default();
|
||||
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_roots, [PathBuf::from("a folder")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
|
||||
assert_eq!(args.next().as_deref(), Some("song.scraft"));
|
||||
// #874: a flag given again adds a folder, in either spelling.
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]);
|
||||
}
|
||||
|
||||
#[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 { read_roots: vec!["read".into()], write_roots: vec!["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_roots, [PathBuf::from("read")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
|
||||
}
|
||||
let mut roots = AutomationArgs::default();
|
||||
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::default();
|
||||
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_roots, [PathBuf::from("-folder")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
|
||||
}
|
||||
}
|
||||
+324
-50
@@ -2,36 +2,97 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! soundcraft-cli info FILE audio file or .scraft summary
|
||||
//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize]
|
||||
//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize] [--dither | --no-dither]
|
||||
//! dithers only when the output loses resolution, unless told
|
||||
//! soundcraft-cli run [--in S.scraft | --demo] [--cmd ID[=JSON]]… [--bounce OUT] [--start T --end T] [--save S.scraft] [--inspect]
|
||||
//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT] (one `ID JSON` per line)
|
||||
//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT [--start T --end T]] (one `ID JSON` per line;
|
||||
//! without --start/--end, --bounce covers the whole session)
|
||||
//! 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 roots confine every file the
|
||||
//! agent's commands read (open, import, presets) and write
|
||||
//! (save, bounce, export) to those folders, links followed;
|
||||
//! each flag may be given more than once, a path inside any
|
||||
//! of its folders being allowed, and a kind 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;
|
||||
|
||||
/// Print without panicking on a closed pipe.
|
||||
/// Print without panicking on a closed pipe. A write error must not skip later work
|
||||
/// such as `--bounce` or `--save`, and must not by itself report success.
|
||||
macro_rules! outln {
|
||||
($($t:tt)*) => {{
|
||||
let mut o = std::io::stdout().lock();
|
||||
if writeln!(o, $($t)*).is_err() { return ExitCode::SUCCESS; }
|
||||
let _ = writeln!(o, $($t)*);
|
||||
}};
|
||||
}
|
||||
|
||||
fn arg_value(args: &[String], flag: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned()
|
||||
args.iter().enumerate().find_map(|(i, a)| {
|
||||
if a == flag { args.get(i + 1).cloned() } else { a.strip_prefix(flag).and_then(|rest| rest.strip_prefix('=')).map(str::to_owned) }
|
||||
})
|
||||
}
|
||||
|
||||
/// Every option a subcommand accepts, with whether it carries a value.
|
||||
type Options = [(&'static str, bool)];
|
||||
|
||||
/// A misspelled option, or one whose value is missing, reads as absent through `arg_value`, so the
|
||||
/// subcommand would run without what was asked for and still report success. Reject both instead.
|
||||
fn check_options(args: &[String], allowed: &Options) -> Result<(), String> {
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let Some(name) = args[i].strip_prefix("--") else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let (name, inline) = name.split_once('=').map_or((name, None), |(n, v)| (n, Some(v)));
|
||||
let flag = format!("--{name}");
|
||||
let Some(&(_, takes_value)) = allowed.iter().find(|(f, _)| f == &flag) else {
|
||||
return Err(format!("unknown option {flag}"));
|
||||
};
|
||||
match (takes_value, inline) {
|
||||
(false, Some(_)) => return Err(format!("{flag} takes no value")),
|
||||
(true, None) => {
|
||||
// A following option is the next flag, not this one's value.
|
||||
if args.get(i + 1).is_none_or(|v| v.starts_with("--")) {
|
||||
return Err(format!("{flag} needs a value"));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--in`, `--demo`/`--sample`, `--bounce`, `--start`, `--end`, `--save` and `--inspect`, read by
|
||||
/// `load_engine` and `finish` and so shared by `run` and `script`.
|
||||
const ENGINE_OPTIONS: &Options = &[
|
||||
("--in", true),
|
||||
("--demo", false),
|
||||
("--sample", false),
|
||||
("--bounce", true),
|
||||
("--start", true),
|
||||
("--end", true),
|
||||
("--save", true),
|
||||
("--inspect", false),
|
||||
];
|
||||
|
||||
fn fail(msg: impl std::fmt::Display) -> ExitCode {
|
||||
eprintln!("soundcraft-cli: {msg}");
|
||||
ExitCode::FAILURE
|
||||
@@ -101,7 +162,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());
|
||||
@@ -113,32 +174,46 @@ 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") };
|
||||
const OPTIONS: &Options = &[("--rate", true), ("--bit-depth", true), ("--normalize", false), ("--dither", false), ("--no-dither", false)];
|
||||
if let Err(e) = check_options(&args[2..], OPTIONS) {
|
||||
return fail(e);
|
||||
}
|
||||
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 dither_flag = match parse_dither(args) {
|
||||
Ok(d) => d,
|
||||
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 (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
|
||||
let ext = std::path::Path::new(input).extension().and_then(std::ffi::OsStr::to_str);
|
||||
let (info, 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") {
|
||||
let normalize = args.iter().any(|a| a == "--normalize");
|
||||
if 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 processed = rate.is_some_and(|r| r != info.sample_rate) || normalize;
|
||||
let dither = dither_flag.unwrap_or_else(|| auto_dither(info.sample_format, bit_depth, processed));
|
||||
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 };
|
||||
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, 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(()) => {
|
||||
outln!("wrote {output} ({} ch, {} Hz, {:.2} s)", buf.num_channels(), buf.sample_rate, buf.duration_secs());
|
||||
@@ -148,6 +223,60 @@ 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}`")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `--dither` / `--no-dither`: `Some` when the caller chose, `None` to decide from the depths.
|
||||
fn parse_dither(args: &[String]) -> Result<Option<bool>, String> {
|
||||
match (args.iter().any(|a| a == "--dither"), args.iter().any(|a| a == "--no-dither")) {
|
||||
(true, true) => Err("--dither and --no-dither: choose one".into()),
|
||||
(true, false) => Ok(Some(true)),
|
||||
(false, true) => Ok(Some(false)),
|
||||
(false, false) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `convert` dithers when not told: only when writing integer samples that cannot hold the
|
||||
/// source exactly, i.e. the output has fewer bits than the source, the source is float or lossy, or
|
||||
/// resampling/normalizing produced new sample values. A 16-bit file written as 16 or 24-bit, or
|
||||
/// anything written as 32-bit float, is copied sample for sample (digital silence stays silent).
|
||||
fn auto_dither(source: soundcraft_audio_io::SampleFormat, target: soundcraft_audio_io::BitDepth, processed: bool) -> bool {
|
||||
use soundcraft_audio_io::{BitDepth, SampleFormat};
|
||||
let target_bits = match target {
|
||||
BitDepth::Float32 => return false,
|
||||
BitDepth::Int16 => 16,
|
||||
BitDepth::Int24 => 24,
|
||||
BitDepth::Int32 => 32,
|
||||
};
|
||||
if processed {
|
||||
return true;
|
||||
}
|
||||
match source {
|
||||
SampleFormat::Int8 => target_bits < 8,
|
||||
SampleFormat::Int16 => target_bits < 16,
|
||||
SampleFormat::Int24 => target_bits < 24,
|
||||
// Decoded through f32, which holds 24 bits: a 32-bit integer source has already lost
|
||||
// resolution, so treat it like float.
|
||||
SampleFormat::Int32 | SampleFormat::Float32 | SampleFormat::Float64 | SampleFormat::Compressed => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
@@ -181,13 +310,18 @@ fn finish(e: &mut Engine, args: &[String]) -> ExitCode {
|
||||
if let Some(s) = arg_value(args, "--end") {
|
||||
p["end"] = time_arg(&s);
|
||||
}
|
||||
// No range given: bounce the whole session, not whatever edit selection the commands left.
|
||||
if p.get("start").is_none() && p.get("end").is_none() {
|
||||
p["start"] = json!(0);
|
||||
p["end"] = json!(e.session().content_end().max(1));
|
||||
}
|
||||
match e.execute("file.bounce_mix", &p) {
|
||||
Ok(v) => outln!("{v}"),
|
||||
Err(err) => return fail(err),
|
||||
}
|
||||
}
|
||||
if let Some(path) = arg_value(args, "--save") {
|
||||
match e.execute("session.save", &json!({"path": path})) {
|
||||
match e.execute("session.save", &json!({"path": path, "replace": true})) {
|
||||
Ok(v) => outln!("{v}"),
|
||||
Err(err) => return fail(err),
|
||||
}
|
||||
@@ -212,32 +346,42 @@ fn parse_cmd_spec(spec: &str) -> Result<(String, Value), String> {
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> ExitCode {
|
||||
let options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--cmd", true)]).collect();
|
||||
if let Err(e) = check_options(args, &options) {
|
||||
return fail(e);
|
||||
}
|
||||
let mut e = match load_engine(args) {
|
||||
Ok(e) => e,
|
||||
Err(err) => return fail(err),
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
if args.get(i).is_some_and(|a| a == "--cmd")
|
||||
&& let Some(spec) = args.get(i + 1)
|
||||
{
|
||||
let (id, params) = match parse_cmd_spec(spec) {
|
||||
Ok(v) => v,
|
||||
Err(err) => return fail(err),
|
||||
};
|
||||
match e.execute(&id, ¶ms) {
|
||||
Ok(v) => outln!("{id}: {v}"),
|
||||
Err(err) => return fail(format!("{id}: {err}")),
|
||||
}
|
||||
i += 1;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
let spec = match a.strip_prefix("--cmd=") {
|
||||
Some(inline) => inline,
|
||||
None if a == "--cmd" => match it.next() {
|
||||
Some(next) => next,
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
let (id, params) = match parse_cmd_spec(spec) {
|
||||
Ok(v) => v,
|
||||
Err(err) => return fail(err),
|
||||
};
|
||||
match e.execute(&id, ¶ms) {
|
||||
Ok(v) => outln!("{id}: {v}"),
|
||||
Err(err) => return fail(format!("{id}: {err}")),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
finish(&mut e, args)
|
||||
}
|
||||
|
||||
fn script(args: &[String]) -> ExitCode {
|
||||
let Some(path) = args.first() else { return fail("script FILE") };
|
||||
let options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--keep-going", false)]).collect();
|
||||
if let Err(e) = check_options(&args[1..], &options) {
|
||||
return fail(e);
|
||||
}
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return fail(format!("{path}: {e}")),
|
||||
@@ -294,12 +438,45 @@ fn describe(args: &[String]) -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Control-channel methods. Every other dotted id is an engine command.
|
||||
fn is_control_method(method: &str) -> bool {
|
||||
matches!(
|
||||
method,
|
||||
"engine.execute"
|
||||
| "command"
|
||||
| "engine.commands"
|
||||
| "engine.parity"
|
||||
| "session.inspect"
|
||||
| "document.inspect"
|
||||
| "ui.inspect"
|
||||
| "ui.menu.list"
|
||||
| "ui.menu.invoke"
|
||||
| "ui.set"
|
||||
| "ui.move"
|
||||
| "ui.click"
|
||||
| "ui.drag"
|
||||
| "ui.key"
|
||||
| "ui.text"
|
||||
| "ui.screenshot"
|
||||
| "app.quit"
|
||||
)
|
||||
}
|
||||
|
||||
/// A command id is shorthand for `engine.execute`. Documented control methods stay methods.
|
||||
fn app_request(method: &str, params: Value) -> (String, Value) {
|
||||
if is_control_method(method) || !method.contains('.') {
|
||||
(method.to_string(), params)
|
||||
} else {
|
||||
("engine.execute".to_string(), json!({"command": method, "params": params}))
|
||||
}
|
||||
}
|
||||
|
||||
fn app(args: &[String]) -> ExitCode {
|
||||
let port = arg_value(args, "--port").or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok()).unwrap_or_else(|| "7979".into());
|
||||
let positional: Vec<&String> = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, a)| !a.starts_with("--") && !(*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port")))
|
||||
.filter(|(i, a)| !(a.starts_with("--") || (*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port"))))
|
||||
.map(|(_, a)| a)
|
||||
.collect();
|
||||
let Some(method) = positional.first() else { return fail("app [--port P] METHOD [JSON]") };
|
||||
@@ -311,17 +488,8 @@ fn app(args: &[String]) -> ExitCode {
|
||||
None => json!({}),
|
||||
};
|
||||
let mut r = Remote::new(&port);
|
||||
// A bare command id is shorthand for engine.execute.
|
||||
let (m, p) = if method.contains('.')
|
||||
&& !method.starts_with("ui.")
|
||||
&& !method.starts_with("engine.")
|
||||
&& !method.starts_with("session.")
|
||||
&& !method.starts_with("app.")
|
||||
{
|
||||
("engine.execute".to_string(), json!({"command": method, "params": params}))
|
||||
} else {
|
||||
(method.to_string(), params)
|
||||
};
|
||||
// A bare command id is shorthand for engine.execute. Control-channel methods stay methods.
|
||||
let (m, p) = app_request(method, params);
|
||||
match r.call(&m, p) {
|
||||
Ok(v) => {
|
||||
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
@@ -331,15 +499,63 @@ 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::default();
|
||||
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 read: Vec<&std::path::Path> = roots_args.read_roots.iter().map(std::path::PathBuf::as_path).collect();
|
||||
let write: Vec<&std::path::Path> = roots_args.write_roots.iter().map(std::path::PathBuf::as_path).collect();
|
||||
let roots = AutomationRoots::new(&read, &write)?;
|
||||
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());
|
||||
eprintln!("soundcraft-cli: files confined: {}", roots.describe());
|
||||
h = h.with_automation_roots(Some(roots));
|
||||
}
|
||||
let mut s = Server::new(h);
|
||||
s.serve(stdin.lock(), stdout.lock())
|
||||
};
|
||||
match r {
|
||||
@@ -351,7 +567,7 @@ fn mcp(args: &[String]) -> ExitCode {
|
||||
fn parity(args: &[String]) -> ExitCode {
|
||||
let p = soundcraft_engine::catalog::parity_json();
|
||||
let mut md = String::new();
|
||||
md.push_str("# Parity report\n\nGenerated by `soundcraft-cli parity --write docs/parity.md` (also `cargo xtask parity`).\n");
|
||||
md.push_str("# Parity report\n\nGenerated by `soundcraft-cli parity --write docs/parity-checklist.md` (also `cargo xtask parity`).\n");
|
||||
md.push_str("It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry.\n");
|
||||
md.push_str("UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel).\n\n");
|
||||
md.push_str(&format!(
|
||||
@@ -380,6 +596,63 @@ fn parity(args: &[String]) -> ExitCode {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_ids_are_execute_shorthand_and_control_methods_stay_methods() {
|
||||
let (m, p) = app_request("engine.history", json!({}));
|
||||
assert_eq!(m, "engine.execute");
|
||||
assert_eq!(p["command"], "engine.history");
|
||||
let (m, p) = app_request("ui.theme", json!({"mode": "light"}));
|
||||
assert_eq!(m, "engine.execute");
|
||||
assert_eq!(p["params"]["mode"], "light");
|
||||
let (m, _) = app_request("session.new", json!({"name": "Session A"}));
|
||||
assert_eq!(m, "engine.execute");
|
||||
let (m, _) = app_request("track.new", json!({}));
|
||||
assert_eq!(m, "engine.execute");
|
||||
for method in ["session.inspect", "engine.commands", "engine.parity", "ui.screenshot", "app.quit"] {
|
||||
assert_eq!(app_request(method, json!({})).0, method);
|
||||
}
|
||||
}
|
||||
|
||||
#[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 convert_dither_flags() {
|
||||
let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(parse_dither(&args(&["in.wav", "out.wav"])), Ok(None));
|
||||
assert_eq!(parse_dither(&args(&["in.wav", "out.wav", "--dither"])), Ok(Some(true)));
|
||||
assert_eq!(parse_dither(&args(&["in.wav", "out.wav", "--no-dither"])), Ok(Some(false)));
|
||||
assert!(parse_dither(&args(&["in.wav", "out.wav", "--dither", "--no-dither"])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_dithers_only_when_resolution_is_lost() {
|
||||
use soundcraft_audio_io::{BitDepth, SampleFormat};
|
||||
// Same depth or wider: the samples fit exactly.
|
||||
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int16, false));
|
||||
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int24, false));
|
||||
assert!(!auto_dither(SampleFormat::Int24, BitDepth::Int24, false));
|
||||
assert!(!auto_dither(SampleFormat::Int8, BitDepth::Int16, false));
|
||||
// Float output never dithers.
|
||||
assert!(!auto_dither(SampleFormat::Float32, BitDepth::Float32, true));
|
||||
assert!(!auto_dither(SampleFormat::Int24, BitDepth::Float32, false));
|
||||
// Fewer bits, a float or lossy source, or processed samples: dither.
|
||||
assert!(auto_dither(SampleFormat::Int24, BitDepth::Int16, false));
|
||||
assert!(auto_dither(SampleFormat::Float32, BitDepth::Int24, false));
|
||||
assert!(auto_dither(SampleFormat::Compressed, BitDepth::Int16, false));
|
||||
assert!(auto_dither(SampleFormat::Int32, BitDepth::Int24, false));
|
||||
assert!(auto_dither(SampleFormat::Int16, BitDepth::Int16, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_media_gets_one_warning_each() {
|
||||
assert!(missing_warnings(&[]).is_empty());
|
||||
@@ -404,6 +677,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,95 @@
|
||||
//! `soundcraft-cli convert` dithers only when the output loses resolution (#548), run as the real binary.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("sc-cli-convert-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A 16-bit mono WAV of `samples`, written without dither so the file holds exactly these values.
|
||||
fn write_wav16(path: &Path, samples: &[i16]) {
|
||||
let mut buf = AudioBuffer::new(48_000, 1, samples.len());
|
||||
for (d, s) in buf.channels[0].iter_mut().zip(samples) {
|
||||
*d = f32::from(*s) / 32768.0;
|
||||
}
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
std::fs::write(path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn convert(input: &Path, output: &Path, extra: &[&str]) {
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"))
|
||||
.env("SOUNDCRAFT_NO_PREFS", "1")
|
||||
.arg("convert")
|
||||
.arg(input)
|
||||
.arg(output)
|
||||
.args(extra)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
}
|
||||
|
||||
/// The decoded samples of `path`, scaled to 16-bit steps.
|
||||
fn steps16(path: &Path) -> Vec<f64> {
|
||||
let (_, buf) = soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap();
|
||||
buf.channels[0].iter().map(|s| f64::from(*s) * 32768.0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_copies_samples_when_nothing_is_lost() {
|
||||
let dir = tmpdir("exact");
|
||||
let (silence, tone) = (dir.join("silence.wav"), dir.join("tone.wav"));
|
||||
let tone_samples: Vec<i16> = (0..4800).map(|i| (8000.0 * (f64::from(i) * 0.05).sin()) as i16).collect();
|
||||
write_wav16(&silence, &[0; 4800]);
|
||||
write_wav16(&tone, &tone_samples);
|
||||
let want: Vec<f64> = tone_samples.iter().map(|s| f64::from(*s)).collect();
|
||||
|
||||
// Same depth, wider and float: every sample comes back unchanged, silence stays silent.
|
||||
for (depth, name) in [("16", "s16.wav"), ("24", "s24.wav"), ("32f", "s32.wav")] {
|
||||
let out = dir.join(name);
|
||||
convert(&silence, &out, &["--bit-depth", depth]);
|
||||
assert!(steps16(&out).iter().all(|s| *s == 0.0), "silence at {depth} is not silent");
|
||||
let out = dir.join(format!("t{name}"));
|
||||
convert(&tone, &out, &["--bit-depth", depth]);
|
||||
assert_eq!(steps16(&out), want, "tone at {depth} changed");
|
||||
}
|
||||
// The default output depth (24-bit) too.
|
||||
let out = dir.join("default.wav");
|
||||
convert(&tone, &out, &[]);
|
||||
assert_eq!(steps16(&out), want);
|
||||
|
||||
// Asked for, dither is still applied.
|
||||
let out = dir.join("forced.wav");
|
||||
convert(&silence, &out, &["--bit-depth", "16", "--dither"]);
|
||||
assert!(steps16(&out).iter().any(|s| *s != 0.0), "--dither did not dither");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_dithers_when_reducing_depth_unless_told_not_to() {
|
||||
let dir = tmpdir("reduce");
|
||||
let src = dir.join("src24.wav");
|
||||
// A 24-bit file with detail below one 16-bit step.
|
||||
let mut buf = AudioBuffer::new(48_000, 1, 4800);
|
||||
for (i, s) in buf.channels[0].iter_mut().enumerate() {
|
||||
*s = 0.3 / 32768.0 * (i as f32 * 0.01).sin();
|
||||
}
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None };
|
||||
std::fs::write(&src, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
|
||||
|
||||
let dithered = dir.join("d16.wav");
|
||||
convert(&src, &dithered, &["--bit-depth", "16"]);
|
||||
assert!(steps16(&dithered).iter().any(|s| *s != 0.0), "24 to 16-bit was not dithered");
|
||||
// Plain rounding: everything is below half a step, so it all rounds to zero.
|
||||
let plain = dir.join("p16.wav");
|
||||
convert(&src, &plain, &["--bit-depth", "16", "--no-dither"]);
|
||||
assert!(steps16(&plain).iter().all(|s| *s == 0.0), "--no-dither still dithered");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
//! `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::{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
|
||||
}
|
||||
|
||||
/// `path` made absolute with links followed (`/var` becomes `/private/var` on macOS), as the server
|
||||
/// prints a root: without the `\\?\` prefix that `canonicalize` adds on Windows.
|
||||
fn resolved(path: &Path) -> PathBuf {
|
||||
let path = std::fs::canonicalize(path).unwrap();
|
||||
if !cfg!(windows) {
|
||||
return path;
|
||||
}
|
||||
let text = path.to_str().unwrap();
|
||||
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
|
||||
return PathBuf::from(format!(r"\\{rest}"));
|
||||
}
|
||||
text.strip_prefix(r"\\?\").map_or(path.clone(), PathBuf::from)
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
mcp_session_in(None, args, calls)
|
||||
}
|
||||
|
||||
/// [`mcp_session`] with the server started in the working directory `cwd`.
|
||||
fn mcp_session_in(cwd: Option<&Path>, args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
|
||||
let mut cmd = bin();
|
||||
if let Some(cwd) = cwd {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
let mut child = cmd.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);
|
||||
}
|
||||
|
||||
/// #874: each root flag may be given more than once. A path inside any root of its kind passes;
|
||||
/// the two roots' common parent, `..` out of a root and a link out of one stay refused.
|
||||
#[test]
|
||||
fn mcp_automation_roots_take_several_folders() {
|
||||
let base = resolved(&tmpdir("several"));
|
||||
for d in ["in", "in2", "out", "out2", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
for f in ["in/a.wav", "in2/b.wav", "outside/secret.wav", "parent.wav"] {
|
||||
make_wav(&base.join(f));
|
||||
}
|
||||
let p = |f: &str| base.join(f).to_str().unwrap().to_string();
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let calls = [
|
||||
("import_audio", serde_json::json!({"path": p("in/a.wav")})),
|
||||
("import_audio", serde_json::json!({"path": p("in2/b.wav")})),
|
||||
// The roots' common parent is not a root.
|
||||
("import_audio", serde_json::json!({"path": p("parent.wav")})),
|
||||
("import_audio", serde_json::json!({"path": format!("{}{sep}..{sep}outside{sep}secret.wav", p("in2"))})),
|
||||
("bounce_mix", serde_json::json!({"path": p("out/a.wav")})),
|
||||
("bounce_mix", serde_json::json!({"path": p("out2/b.wav")})),
|
||||
("bounce_mix", serde_json::json!({"path": p("x.wav")})),
|
||||
// Read and write stay separate: a read root is not a write root, nor the other way round.
|
||||
("bounce_mix", serde_json::json!({"path": p("in2/c.wav")})),
|
||||
("import_audio", serde_json::json!({"path": p("out2/b.wav")})),
|
||||
];
|
||||
let read2 = format!("--automation-read-root={}", p("in2"));
|
||||
let args = ["--automation-read-root", &p("in"), &read2, "--automation-write-root", &p("out"), "--automation-write-root", &p("out2")];
|
||||
let (success, stderr, replies) = mcp_session(&args, &calls);
|
||||
assert!(success, "{stderr}");
|
||||
assert!(stderr.contains(&format!("read {}, {}", p("in"), p("in2"))), "{stderr}");
|
||||
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
|
||||
assert_eq!(errors, [false, false, true, true, false, false, true, true, true], "{replies:?}");
|
||||
for (i, kind) in [(2, "read"), (3, "read"), (6, "write"), (7, "write"), (8, "read")] {
|
||||
assert!(text(&replies[i]).contains(&format!("outside the {kind} roots ")), "{}", text(&replies[i]));
|
||||
}
|
||||
assert!(base.join("out/a.wav").is_file() && base.join("out2/b.wav").is_file());
|
||||
assert!(!base.join("x.wav").exists() && !base.join("in2/c.wav").exists());
|
||||
|
||||
// A link in one root that leads out of all of them is outside.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(base.join("outside/secret.wav"), base.join("in2/leak.wav")).unwrap();
|
||||
let (_, _, replies) = mcp_session(&args, &[("import_audio", serde_json::json!({"path": p("in2/leak.wav")}))]);
|
||||
assert!(text(&replies[0]).contains("outside the read roots"), "{}", text(&replies[0]));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_save_session_name_lands_in_the_working_directory_inside_the_roots() {
|
||||
let work = tmpdir("bare-name");
|
||||
let work_s = work.to_str().unwrap();
|
||||
let calls = [
|
||||
("new_session", serde_json::json!({"name": "t", "sample_rate": 48000})),
|
||||
("execute", serde_json::json!({"command": "track.new", "params": {"kind": "audio", "name": "Tone", "format": "mono"}})),
|
||||
("save_session", serde_json::json!({"path": "t.scraft"})),
|
||||
];
|
||||
let (success, stderr, replies) = mcp_session_in(Some(&work), &["--automation-read-root", work_s, "--automation-write-root", work_s], &calls);
|
||||
assert!(success, "{stderr}");
|
||||
for reply in &replies {
|
||||
assert_eq!(reply["result"]["isError"], false, "{reply}");
|
||||
}
|
||||
assert!(work.join("t.scraft").is_file());
|
||||
let _ = std::fs::remove_dir_all(work);
|
||||
}
|
||||
|
||||
#[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");
|
||||
// #874: a flag may be given again, but every folder it names must exist.
|
||||
fails(&["--automation-read-root", dir, "--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
fails(&["--automation-write-root", dir, "--automation-write-root"], "needs a folder");
|
||||
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}");
|
||||
}
|
||||
|
||||
/// Without `--start`/`--end`, `--bounce` covers the whole session, not a stray edit selection
|
||||
/// left by the script (a bare `19.2` in command params is 19 samples).
|
||||
#[test]
|
||||
fn script_bounce_without_range_covers_the_session() {
|
||||
let base = tmpdir("bounce-range");
|
||||
let script = base.join("cmds.txt");
|
||||
std::fs::write(
|
||||
&script,
|
||||
concat!(
|
||||
"track.new {\"count\":1,\"kind\":\"instrument\",\"name\":\"Keys\"}\n",
|
||||
"midi.new_clip {\"track\":\"Keys\",\"start\":0,\"end\":{\"seconds\":19.2}}\n",
|
||||
"edit.select {\"start\":0,\"end\":19.2}\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let out = base.join("out.wav");
|
||||
let o = run(&["script", script.to_str().unwrap(), "--bounce", out.to_str().unwrap()]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let stdout = String::from_utf8_lossy(&o.stdout).into_owned();
|
||||
let last: Value = serde_json::from_str(stdout.lines().last().unwrap()).unwrap();
|
||||
let secs = last["seconds"].as_f64().unwrap();
|
||||
assert!((secs - 19.2).abs() < 0.01, "bounced {secs} s: {stdout}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! A closed stdout must not abort `run` or `script` before `--bounce` and `--save`.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
fn bin() -> Command {
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
|
||||
cmd.env("SOUNDCRAFT_NO_PREFS", "1");
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Drop the read end so the child's stdout writes fail with `EPIPE`.
|
||||
fn closed_stdout() -> std::process::Stdio {
|
||||
let (reader, writer) = std::io::pipe().expect("pipe");
|
||||
drop(reader);
|
||||
Stdio::from(writer)
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("sc-cli-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_with_closed_stdout_still_bounces_and_saves() {
|
||||
let dir = temp_dir("run");
|
||||
let wav = dir.join("mix.wav");
|
||||
let session = dir.join("Synthetic.scraft");
|
||||
let output = bin()
|
||||
.arg("run")
|
||||
.arg("--cmd")
|
||||
.arg(r#"track.new={"name":"Synthetic","count":1,"format":"stereo"}"#)
|
||||
.arg("--bounce")
|
||||
.arg(&wav)
|
||||
.arg("--save")
|
||||
.arg(&session)
|
||||
.arg("--start")
|
||||
.arg("0")
|
||||
.arg("--end")
|
||||
.arg("0.01")
|
||||
.stdout(closed_stdout())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(wav.is_file(), "bounce was skipped");
|
||||
assert!(session.is_file(), "save was skipped");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_with_closed_stdout_still_bounces_and_saves() {
|
||||
let dir = temp_dir("script");
|
||||
let script = dir.join("edit.scraft.txt");
|
||||
std::fs::write(&script, "track.new {\"name\":\"Synthetic\",\"count\":1,\"format\":\"stereo\"}\n").unwrap();
|
||||
let wav = dir.join("mix.wav");
|
||||
let session = dir.join("Synthetic.scraft");
|
||||
let output = bin()
|
||||
.arg("script")
|
||||
.arg(&script)
|
||||
.arg("--bounce")
|
||||
.arg(&wav)
|
||||
.arg("--save")
|
||||
.arg(&session)
|
||||
.arg("--start")
|
||||
.arg("0")
|
||||
.arg("--end")
|
||||
.arg("0.01")
|
||||
.stdout(closed_stdout())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(wav.is_file());
|
||||
assert!(session.is_file());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_then_save_with_closed_stdout_writes_the_wav() {
|
||||
let dir = temp_dir("bounce");
|
||||
let wav = dir.join("mix.wav");
|
||||
let session = dir.join("Empty.scraft");
|
||||
let output = bin()
|
||||
.arg("run")
|
||||
.arg("--bounce")
|
||||
.arg(&wav)
|
||||
.arg("--save")
|
||||
.arg(&session)
|
||||
.arg("--start")
|
||||
.arg("0")
|
||||
.arg("--end")
|
||||
.arg("0.01")
|
||||
.stdout(closed_stdout())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(wav.is_file());
|
||||
assert!(session.is_file());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_bounce_destination_still_fails() {
|
||||
let dir = temp_dir("bad-bounce");
|
||||
let missing = dir.join("no-such-dir").join("mix.wav");
|
||||
let output = bin()
|
||||
.arg("run")
|
||||
.arg("--bounce")
|
||||
.arg(&missing)
|
||||
.arg("--start")
|
||||
.arg("0")
|
||||
.arg("--end")
|
||||
.arg("0.01")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success(), "a bounce that cannot be written must fail");
|
||||
assert!(!missing.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Unknown options and missing option values must be reported, not silently skipped (#549).
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
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-options-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A 0.1 s mono 48 kHz 16-bit WAV, written by hand so the test needs no fixture.
|
||||
fn tone(dir: &Path) -> PathBuf {
|
||||
let frames: u32 = 4800;
|
||||
let data: Vec<u8> = (0..frames).flat_map(|_| [0x00, 0x10]).collect();
|
||||
let (bytes, rate, ch, bits) = (data.len() as u32, 48_000u32, 1u16, 16u16);
|
||||
let mut wav = Vec::new();
|
||||
wav.extend_from_slice(b"RIFF");
|
||||
wav.extend_from_slice(&(36 + bytes).to_le_bytes());
|
||||
wav.extend_from_slice(b"WAVEfmt ");
|
||||
wav.extend_from_slice(&16u32.to_le_bytes());
|
||||
wav.extend_from_slice(&1u16.to_le_bytes());
|
||||
wav.extend_from_slice(&ch.to_le_bytes());
|
||||
wav.extend_from_slice(&rate.to_le_bytes());
|
||||
wav.extend_from_slice(&(rate * u32::from(ch) * u32::from(bits) / 8).to_le_bytes());
|
||||
wav.extend_from_slice(&(ch * bits / 8).to_le_bytes());
|
||||
wav.extend_from_slice(&bits.to_le_bytes());
|
||||
wav.extend_from_slice(b"data");
|
||||
wav.extend_from_slice(&bytes.to_le_bytes());
|
||||
wav.extend_from_slice(&data);
|
||||
let path = dir.join("tone.wav");
|
||||
std::fs::write(&path, wav).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn sample_format(cli_info: &str) -> String {
|
||||
serde_json::from_str::<serde_json::Value>(cli_info).unwrap()["sample_format"].as_str().unwrap().to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rejects_an_unknown_option_instead_of_writing_the_file() {
|
||||
let dir = tmpdir("convert-unknown");
|
||||
let input = tone(&dir);
|
||||
let out = dir.join("out.wav");
|
||||
for bad in ["--bitdepth", "--normalise", "--bogus"] {
|
||||
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), bad]);
|
||||
assert!(!o.status.success(), "{bad} should fail");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("unknown option {bad}")), "{bad}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(!out.exists(), "{bad} should not write the output");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rejects_an_option_whose_value_is_missing() {
|
||||
let dir = tmpdir("convert-novalue");
|
||||
let input = tone(&dir);
|
||||
let out = dir.join("out.wav");
|
||||
for bad in ["--rate", "--bit-depth"] {
|
||||
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), bad]);
|
||||
assert!(!o.status.success(), "{bad} should fail");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("{bad} needs a value")), "{bad}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(!out.exists(), "{bad} should not write the output");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_accepts_the_inline_value_form() {
|
||||
let dir = tmpdir("convert-inline");
|
||||
let input = tone(&dir);
|
||||
let out = dir.join("out.wav");
|
||||
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), "--bit-depth=32f"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let info = run(&["info", out.to_str().unwrap()]);
|
||||
assert_eq!(sample_format(&String::from_utf8_lossy(&info.stdout)), "Float32");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_still_accepts_the_documented_space_separated_form() {
|
||||
let dir = tmpdir("convert-space");
|
||||
let input = tone(&dir);
|
||||
let out = dir.join("out.wav");
|
||||
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), "--bit-depth", "16"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let info = run(&["info", out.to_str().unwrap()]);
|
||||
assert_eq!(sample_format(&String::from_utf8_lossy(&info.stdout)), "Int16");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_rejects_a_misspelled_or_value_less_bounce() {
|
||||
let dir = tmpdir("run-bounce");
|
||||
let out = dir.join("x.wav");
|
||||
let o = run(&["run", "--cmd", "session.new", "--bounce"]);
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("--bounce needs a value"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
|
||||
let o = run(&["run", "--cmd", "session.new", "--bounces", out.to_str().unwrap()]);
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown option --bounces"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(!out.exists(), "nothing should be bounced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_bounces_with_the_inline_value_form() {
|
||||
let dir = tmpdir("run-inline");
|
||||
let out = dir.join("y.wav");
|
||||
let o = run(&["run", "--cmd", "session.new", &format!("--bounce={}", out.display())]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(out.exists(), "--bounce=PATH should write the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_executes_the_inline_cmd_form() {
|
||||
let o = run(&["run", "--cmd=track.new", "--cmd", "track.new"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
assert_eq!(stdout.lines().filter(|l| l.starts_with("track.new: ")).count(), 2, "both --cmd forms should run: {stdout}");
|
||||
}
|
||||
@@ -15,12 +15,16 @@ path = "src/main.rs"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
soundcraft-engine = { workspace = true }
|
||||
soundcraft-ui-egui = { workspace = true }
|
||||
soundcraft-ui-egui.workspace = true
|
||||
soundcraft-playback = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
soundcraft-model = { workspace = true }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
js-sys = "0.3"
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "CssStyleDeclaration"] }
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
//!
|
||||
//! Differences from the desktop app:
|
||||
//! - no TCP control channel (browsers can't listen on sockets);
|
||||
//! - no native file dialogs: the demo session is loaded on start;
|
||||
//! - no file system: File > Open Session…, Import > Audio…/MIDI… and files dropped on the page are
|
||||
//! read as bytes through the browser's file picker; a session is saved as ONE bundle file
|
||||
//! (`<name>.scraftz`: the `.scraft` and its `Audio Files/`), and every save or export arrives as a
|
||||
//! download (the demo session is still loaded on start);
|
||||
//! - browsers only start audio after a user gesture, so the first click may be needed before
|
||||
//! playback is heard.
|
||||
//!
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! The browser shell: the eframe web runner around [`SoundApp`].
|
||||
|
||||
use soundcraft_engine::Engine;
|
||||
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;
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
@@ -10,6 +14,7 @@ const LOADING_ID: &str = "soundcraft_loading";
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
io::set_deliver(download);
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
|
||||
log::error!("no document");
|
||||
@@ -36,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)))
|
||||
Ok(Box::new(WebShell { app, inbox: Rc::default(), samples: Rc::default() }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -53,18 +58,201 @@ fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
struct WebShell(SoundApp);
|
||||
/// What a file read from the browser is for.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Purpose {
|
||||
Session,
|
||||
Audio,
|
||||
Midi,
|
||||
}
|
||||
|
||||
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", "m4b", "aac", "mp4", "caf", "mkv", "webm"];
|
||||
const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"];
|
||||
|
||||
impl Purpose {
|
||||
/// A dropped file goes where its extension says: sessions open, MIDI and audio import.
|
||||
fn of(name: &str) -> Self {
|
||||
let ext = name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).unwrap_or_default();
|
||||
if SESSION_EXTS.contains(&ext.as_str()) {
|
||||
Self::Session
|
||||
} else if MIDI_EXTS.contains(&ext.as_str()) {
|
||||
Self::Midi
|
||||
} else {
|
||||
Self::Audio
|
||||
}
|
||||
}
|
||||
|
||||
/// The command whose path prompt this replaces in the browser.
|
||||
fn for_command(cmd: &str) -> Option<Self> {
|
||||
match cmd {
|
||||
"session.open" => Some(Self::Session),
|
||||
"file.import_audio" => Some(Self::Audio),
|
||||
"file.import_midi" => Some(Self::Midi),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn filter(self) -> (&'static str, &'static [&'static str]) {
|
||||
match self {
|
||||
Self::Session => ("SoundCraft Session", SESSION_EXTS),
|
||||
Self::Audio => ("Audio", AUDIO_EXTS),
|
||||
Self::Midi => ("MIDI", MIDI_EXTS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Files picked or dropped, read asynchronously (browsers can only read files that way) and
|
||||
/// 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 {
|
||||
/// File > Open Session… / Import > Audio… / MIDI… would show a text box for a path, which the
|
||||
/// browser can't read (#63, #64). Show the browser's file picker instead.
|
||||
fn take_path_prompt(&mut self, ctx: &egui::Context) {
|
||||
let purpose = match &self.app.dialogs.open {
|
||||
Some(Dialog::PathPrompt { cmd, .. }) => Purpose::for_command(cmd),
|
||||
_ => None,
|
||||
};
|
||||
let Some(purpose) = purpose else { return };
|
||||
self.app.dialogs.open = None;
|
||||
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let (label, exts) = purpose.filter();
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter(label, exts).pick_file().await else { return };
|
||||
let bytes = file.read().await;
|
||||
inbox.borrow_mut().push((purpose, file.file_name(), bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
/// Read files dropped on the browser window asynchronously: audio for an instrument track or a Sampler goes to
|
||||
/// that sampler, while other drops are classified by extension and handled on a later frame.
|
||||
fn take_drops(&mut self, ctx: &egui::Context) {
|
||||
// In the browser a dropped file's `path()` is only its name: read its bytes instead.
|
||||
// Audio dropped on an instrument track or a Sampler window goes to its sampler instead.
|
||||
let dropped = self.app.take_dropped_files();
|
||||
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());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.borrow_mut().push((Purpose::of(&name), name, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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(|_| {
|
||||
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(|_| {
|
||||
e.push_undo("Import MIDI", before);
|
||||
format!("Imported {name}")
|
||||
})
|
||||
}
|
||||
};
|
||||
self.app.ui.status = result.unwrap_or_else(|err| err.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.0.logic(ctx);
|
||||
self.take_drops(ctx);
|
||||
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);
|
||||
}
|
||||
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.
|
||||
self.take_path_prompt(ctx);
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
/// Forward raw input to the shared app hook so scripted gestures and host-key protection behave like the desktop
|
||||
/// shell.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(ctx, raw);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
self.app.ui(ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand a saved or exported file to the person as a download named `name` (the engine's delivery
|
||||
/// hook on the web).
|
||||
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("download failed: {e:?}");
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type(match name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).as_deref() {
|
||||
Some("scraftz") => "application/zip",
|
||||
Some("wav") => "audio/wav",
|
||||
Some("aif" | "aiff") => "audio/aiff",
|
||||
Some("flac") => "audio/flac",
|
||||
Some("mid" | "midi") => "audio/midi",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("txt") => "text/plain",
|
||||
Some("json" | "scraft") => "application/json",
|
||||
Some("musicxml") => "application/vnd.recordare.musicxml+xml",
|
||||
_ => "application/octet-stream",
|
||||
});
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
a.style().set_property("display", "none").map_err(js)?;
|
||||
document.body().ok_or("no body")?.append_child(&a).map_err(js)?;
|
||||
a.click();
|
||||
a.remove();
|
||||
// Revoke after the click was dispatched; the download keeps its own reference.
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10,14 +10,22 @@ 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
|
||||
soundcraft-playback.workspace = true
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
|
||||
egui.workspace = true
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "clipboard"] }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
serde_json.workspace = true
|
||||
log.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd.workspace = true
|
||||
@@ -27,4 +35,9 @@ 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"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Per-user settings and automation profile directories.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use soundcraft_ui_egui::docking;
|
||||
|
||||
/// 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 {
|
||||
Mac,
|
||||
Windows,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
Platform::Windows
|
||||
} else {
|
||||
Platform::Other
|
||||
};
|
||||
config_dir_with(platform, |name| std::env::var_os(name), exe_dir)
|
||||
}
|
||||
|
||||
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")),
|
||||
Platform::Other => {
|
||||
env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config"))).map(|c| c.join("soundcraft"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
fn read_ui(path: &std::path::Path, recover_docking: bool) -> Result<Option<soundcraft_ui_egui::UiState>, String> {
|
||||
use std::io::Read as _;
|
||||
let file = match std::fs::File::open(path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(format!("Settings could not be read: {error}")),
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_UI_BYTES + 1).read_to_end(&mut bytes).map_err(|error| format!("Settings could not be read: {error}"))?;
|
||||
if bytes.len() as u64 > MAX_UI_BYTES {
|
||||
return Err("Settings file exceeds the 4 MiB limit".into());
|
||||
}
|
||||
let mut saved: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| format!("Settings could not be restored: {error}"))?;
|
||||
if recover_docking && let Some(fields) = saved.as_object_mut() {
|
||||
for (field, mix) in [("docking", false), ("mix_docking", true)] {
|
||||
let unreadable = fields.get(field).is_some_and(|value| match serde_json::from_value::<docking::Workspace>(value.clone()) {
|
||||
Ok(workspace) => if mix { docking::validate_mix(&workspace) } else { docking::validate(&workspace) }.is_err(),
|
||||
Err(_) => true,
|
||||
});
|
||||
if unreadable {
|
||||
fields.remove(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
let state: soundcraft_ui_egui::UiState = serde_json::from_value(saved).map_err(|error| format!("Settings could not be restored: {error}"))?;
|
||||
docking::validate(&state.docking)?;
|
||||
docking::validate_mix(&state.mix_docking)?;
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
const MAX_UI_BYTES: u64 = 4 * 1024 * 1024;
|
||||
|
||||
/// Write settings atomically. Preserve an unreadable or malformed existing file for recovery.
|
||||
pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Result<(), String> {
|
||||
use std::io::Write as _;
|
||||
// Loading can recover a future layout for this session, but saving must preserve its source.
|
||||
read_ui(path, false)?;
|
||||
docking::validate(&ui.docking)?;
|
||||
docking::validate_mix(&ui.mix_docking)?;
|
||||
let mut saved = ui.clone();
|
||||
saved.plugin_windows.clear();
|
||||
saved.status.clear();
|
||||
let bytes = serde_json::to_vec_pretty(&saved).map_err(|error| error.to_string())?;
|
||||
if bytes.len() as u64 > MAX_UI_BYTES {
|
||||
return Err("Settings exceed the 4 MiB limit".into());
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
||||
}
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|error| error.to_string())?.as_nanos();
|
||||
let temporary = path.with_extension(format!("json.{}.{nonce}.tmp", std::process::id()));
|
||||
let mut file =
|
||||
std::fs::OpenOptions::new().write(true).create_new(true).open(&temporary).map_err(|error| format!("Settings could not be saved: {error}"))?;
|
||||
let written = (|| -> std::io::Result<()> {
|
||||
file.write_all(&bytes)?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&temporary, path)
|
||||
})();
|
||||
if written.is_err() {
|
||||
let _ = std::fs::remove_file(&temporary);
|
||||
}
|
||||
written.map_err(|error| format!("Settings could not be saved: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod config_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_file_reload_preserves_edit_mix_and_hidden_floating_panels() {
|
||||
use soundcraft_ui_egui::{MainWindow, Services, SoundApp, docking::Panel};
|
||||
let directory = std::env::temp_dir().join(format!("soundcraft-dock-prefs-{}", std::process::id()));
|
||||
let path = directory.join("ui.json");
|
||||
let _ = std::fs::remove_dir_all(&directory);
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[80,90,260,400]})).unwrap();
|
||||
app.run("ui.dock", serde_json::json!({"operation":"close","panel":"tracks"})).unwrap();
|
||||
app.ui.theme = soundcraft_ui_egui::theme::ThemeMode::Light;
|
||||
let edit = serde_json::to_value(&app.ui.docking).unwrap();
|
||||
app.ui.window = MainWindow::Mix;
|
||||
app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap();
|
||||
app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[140,150,280,420]})).unwrap();
|
||||
save_ui(&path, &app.ui).unwrap();
|
||||
app.ui = load_ui(&path).unwrap().unwrap();
|
||||
assert_eq!(app.ui.theme, soundcraft_ui_egui::theme::ThemeMode::Light);
|
||||
assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), edit);
|
||||
assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]);
|
||||
app.ui.window = MainWindow::Edit;
|
||||
app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap();
|
||||
assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]);
|
||||
let mut invalid_mix = app.ui.clone();
|
||||
invalid_mix.mix_docking = soundcraft_ui_egui::docking::edit_default();
|
||||
let invalid_bytes = serde_json::to_vec(&invalid_mix).unwrap();
|
||||
std::fs::write(&path, &invalid_bytes).unwrap();
|
||||
let recovered = load_ui(&path).unwrap().unwrap();
|
||||
assert_eq!(recovered.theme, app.ui.theme);
|
||||
assert_eq!(serde_json::to_value(recovered.docking).unwrap(), serde_json::to_value(&app.ui.docking).unwrap());
|
||||
soundcraft_ui_egui::docking::validate_mix(&recovered.mix_docking).unwrap();
|
||||
assert!(save_ui(&path, &app.ui).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), invalid_bytes);
|
||||
std::fs::write(&path, b"damaged settings").unwrap();
|
||||
assert!(load_ui(&path).is_err());
|
||||
assert!(save_ui(&path, &app.ui).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"damaged settings");
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_edit_and_mix_layouts_preserve_other_preferences_and_the_original_file() {
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
let directory = std::env::temp_dir().join(format!("soundcraft-future-dock-prefs-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
let path = directory.join("ui.json");
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[80,90,260,400]})).unwrap();
|
||||
app.ui.theme = soundcraft_ui_egui::theme::ThemeMode::Light;
|
||||
app.ui.narrow_mix = true;
|
||||
for field in ["docking", "mix_docking"] {
|
||||
for invalid in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] {
|
||||
let mut saved = serde_json::to_value(&app.ui).unwrap();
|
||||
saved[field] = invalid;
|
||||
assert!(serde_json::from_value::<soundcraft_ui_egui::UiState>(saved.clone()).is_err());
|
||||
let bytes = serde_json::to_vec(&saved).unwrap();
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let recovered = load_ui(&path).unwrap().unwrap();
|
||||
assert_eq!(recovered.theme, app.ui.theme);
|
||||
assert!(recovered.narrow_mix);
|
||||
soundcraft_ui_egui::docking::validate(&recovered.docking).unwrap();
|
||||
soundcraft_ui_egui::docking::validate_mix(&recovered.mix_docking).unwrap();
|
||||
let other = if field == "docking" { "mix_docking" } else { "docking" };
|
||||
assert_eq!(serde_json::to_value(&recovered).unwrap()[other], saved[other]);
|
||||
assert!(save_ui(&path, &recovered).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_settings_are_rejected_and_preserved() {
|
||||
let directory = std::env::temp_dir().join(format!("soundcraft-bounded-prefs-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
let path = directory.join("ui.json");
|
||||
let bytes = vec![b' '; MAX_UI_BYTES as usize + 1];
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
assert!(load_ui(&path).is_err());
|
||||
assert!(save_ui(&path, &soundcraft_ui_egui::UiState::default()).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
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))), 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"))
|
||||
},
|
||||
None,
|
||||
);
|
||||
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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())
|
||||
},
|
||||
None
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_unset_override_keeps_platform_defaults() {
|
||||
let vars = [("HOME", "/user"), ("APPDATA", "/roaming"), ("XDG_CONFIG_HOME", "/xdg")];
|
||||
assert_eq!(resolve(Platform::Mac, &vars), Some(PathBuf::from("/user/Library/Application Support/SoundCraft")));
|
||||
assert_eq!(resolve(Platform::Windows, &vars), Some(PathBuf::from("/roaming/SoundCraft")));
|
||||
assert_eq!(resolve(Platform::Other, &vars), Some(PathBuf::from("/xdg/soundcraft")));
|
||||
assert_eq!(resolve(Platform::Other, &[("HOME", "/user")]), Some(PathBuf::from("/user/.config/soundcraft")));
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
assert_eq!(resolve(platform, &[]), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[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()), None), Some(PathBuf::from(value)));
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,36 @@
|
||||
//! 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::mpsc::{Receiver, Sender, channel};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_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;
|
||||
/// Requests that may wait for the UI thread. Each connection handles one request at a time, so
|
||||
/// this matches [`MAX_CONNECTIONS`] and makes the transport's bound explicit instead of relying
|
||||
/// on an unbounded channel.
|
||||
const MAX_QUEUED_REQUESTS: usize = MAX_CONNECTIONS;
|
||||
|
||||
/// Bind the loopback control listener and return the request receiver. Port 0 picks a free one and the actual port is
|
||||
/// printed; bind or spawn failures are logged instead of panicking.
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let (tx, rx) = sync_channel::<ControlRequest>(MAX_QUEUED_REQUESTS);
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
@@ -20,42 +41,107 @@ 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
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept until the listener closes, admitting at most [`MAX_CONNECTIONS`] peers; excess peers get one error line.
|
||||
/// Each admitted connection is served on its own thread, releasing its slot on exit.
|
||||
fn accept_loop(listener: TcpListener, tx: SyncSender<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)
|
||||
}
|
||||
|
||||
/// Answer newline-delimited JSON requests for one connection: bound each line, forward it to the UI thread with a 90
|
||||
/// s reply deadline, and stop at EOF, invalid UTF-8 or a closed request queue. Bad JSON still gets an error reply.
|
||||
fn serve(stream: TcpStream, tx: SyncSender<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();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
match tx.try_send(req) {
|
||||
Ok(()) => {
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(90)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
// Refuse rather than grow or block: the UI drains a bounded batch each tick.
|
||||
Err(TrySendError::Full(_)) => json!({"ok": false, "id": id, "error": "control queue full"}),
|
||||
Err(TrySendError::Disconnected(_)) => break,
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(90)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
};
|
||||
@@ -64,3 +150,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) = sync_channel::<ControlRequest>(MAX_QUEUED_REQUESTS);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
|
||||
|
||||
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
|
||||
use eframe::{
|
||||
NativeOptions,
|
||||
egui_wgpu::WgpuSetup,
|
||||
wgpu::{Backends, Dx12Compiler},
|
||||
};
|
||||
|
||||
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
|
||||
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
|
||||
/// Default to DirectX 12 with FXC before creating the wgpu instance. Explicit
|
||||
/// `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choices, read by the caller, take precedence.
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>, compiler_override: Option<Dx12Compiler>) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// eframe's default includes GL. Creating that backend can crash inside AMD's
|
||||
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
|
||||
@@ -12,6 +16,16 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>
|
||||
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
|
||||
// workaround without mutating process environment variables.
|
||||
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
|
||||
// Auto loads whichever dxcompiler.dll the DLL search path finds; SoundCraft ships
|
||||
// none, and without a matching dxil.dll the unsigned-DXIL warning fails wgpu's
|
||||
// indirect-validation pipeline and loses the device before the first frame
|
||||
// (issue #185). FXC uses the compiler that is part of Windows.
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler = compiler_override.unwrap_or(Dx12Compiler::Fxc);
|
||||
log::info!(
|
||||
"Windows graphics: backends={:?}, DX12 compiler={:?}",
|
||||
create.instance_descriptor.backends,
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +35,7 @@ mod tests {
|
||||
|
||||
fn configured_backends(backend_override: Option<Backends>) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure(&mut options, backend_override);
|
||||
configure(&mut options, backend_override, None);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
@@ -38,6 +52,38 @@ mod tests {
|
||||
assert_eq!(configured_backends(None), Backends::DX12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_default_uses_fxc_instead_of_searching_for_dxc() {
|
||||
let mut options = NativeOptions::default();
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// Auto can find an unrelated DXC without its matching DXIL runtime (issue #185).
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler = Dx12Compiler::Auto;
|
||||
}
|
||||
configure(&mut options, None, None);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_compiler_override_is_preserved() {
|
||||
for compiler in [Dx12Compiler::Fxc, Dx12Compiler::Auto, Dx12Compiler::DynamicDxc { dxc_path: "custom/dxcompiler.dll".into() }] {
|
||||
let mut options = NativeOptions::default();
|
||||
configure(&mut options, None, Some(compiler.clone()));
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
match (compiler, create.instance_descriptor.backend_options.dx12.shader_compiler) {
|
||||
(Dx12Compiler::Fxc, Dx12Compiler::Fxc) | (Dx12Compiler::Auto, Dx12Compiler::Auto) => {}
|
||||
(Dx12Compiler::DynamicDxc { dxc_path: expected }, Dx12Compiler::DynamicDxc { dxc_path: actual }) => {
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
(expected, actual) => panic!("compiler override changed from {expected:?} to {actual:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_backend_override_is_preserved() {
|
||||
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
|
||||
|
||||
@@ -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]
|
||||
|
||||
+118
-45
@@ -11,6 +11,7 @@
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod config;
|
||||
mod control_server;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
@@ -19,12 +20,23 @@ mod logging;
|
||||
mod native_menu;
|
||||
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, UiState};
|
||||
use soundcraft_ui_egui::{Services, SoundApp, UiState, dialogs};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
|
||||
/// Sends the title only when it changes, remembered here as X11 and Wayland can't read it back.
|
||||
fn set_title(ctx: &egui::Context, title: String) {
|
||||
let id = egui::Id::unique("window_title");
|
||||
if ctx.data(|d| d.get_temp::<String>(id)).as_deref() != Some(title.as_str()) {
|
||||
ctx.data_mut(|d| d.insert_temp(id, title.clone()));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
/// Per-frame app logic: report audio-thread logs, run the shared app logic with the macOS menu driven around it,
|
||||
/// then handle dropped paths, quit/close requests, window geometry, recent-list persistence and the window title.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// What the realtime audio thread logged is written here, off that thread.
|
||||
if let Some(logger) = self.1 {
|
||||
@@ -34,19 +46,33 @@ 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().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);
|
||||
}
|
||||
if self.0.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
} else if ctx.input(|i| i.viewport().close_requested()) && dialogs::ask_to_save(&mut self.0, "app.quit", &serde_json::json!({})) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
}
|
||||
soundcraft_ui_egui::fit_window_to_screen(ctx);
|
||||
let (inner, maximized) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().maximized.unwrap_or(false)));
|
||||
self.0.ui.window_maximized = maximized;
|
||||
if let Some(r) = inner.filter(|_| !maximized) {
|
||||
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.
|
||||
@@ -60,15 +86,18 @@ impl eframe::App for App {
|
||||
} else {
|
||||
format!("{} — SoundCraft", self.0.engine.session().name)
|
||||
};
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
|
||||
set_title(ctx, title);
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
/// Forward raw input to the shared app hook so scripted gestures and host-key protection behave like the desktop
|
||||
/// shell.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(ctx, 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();
|
||||
@@ -83,12 +112,14 @@ impl eframe::App for App {
|
||||
fn open_path(app: &mut SoundApp, p: &str) {
|
||||
let lower = p.to_ascii_lowercase();
|
||||
let r = if lower.ends_with(".scraft") {
|
||||
app.run("session.open", serde_json::json!({"path": p}))
|
||||
dialogs::run_or_ask(app, "session.open", serde_json::json!({"path": p}))
|
||||
} else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") {
|
||||
app.run("file.import_midi", serde_json::json!({"path": p}))
|
||||
} else {
|
||||
// Decoded in the background so a long file doesn't freeze the window (#597).
|
||||
let at = app.engine.session().edit.selection.start;
|
||||
app.run("file.import_audio", serde_json::json!({"path": p, "at": at}))
|
||||
soundcraft_ui_egui::import::start(app, serde_json::json!({"path": p, "at": at}));
|
||||
Ok(serde_json::Value::Null)
|
||||
};
|
||||
if let Err(e) = r {
|
||||
log::warn!("{p}: {e}");
|
||||
@@ -97,16 +128,7 @@ fn open_path(app: &mut SoundApp, p: &str) {
|
||||
|
||||
/// The per-user settings directory: `ui.json`, `Autosave/`, `Presets/` and `logs/` live here.
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/SoundCraft"))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("SoundCraft"))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join("soundcraft"))
|
||||
}
|
||||
config::config_dir()
|
||||
}
|
||||
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
@@ -122,24 +144,24 @@ fn load_prefs() -> Option<UiState> {
|
||||
if !prefs_enabled() {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(prefs_path()?).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
let path = prefs_path()?;
|
||||
match config::load_ui(&path) {
|
||||
Ok(state) => state,
|
||||
Err(error) => {
|
||||
log::warn!("{error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn save_prefs(ui: &UiState) {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = prefs_path() {
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let mut ui = ui.clone();
|
||||
ui.plugin_windows.clear();
|
||||
ui.status.clear();
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&ui) {
|
||||
let _ = std::fs::write(&p, bytes);
|
||||
}
|
||||
if let Some(path) = prefs_path()
|
||||
&& let Err(error) = config::save_ui(&path, ui)
|
||||
{
|
||||
log::warn!("{error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,19 +174,28 @@ 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())
|
||||
})),
|
||||
pick_folder: Some(Box::new(|start: &str| {
|
||||
rfd::FileDialog::new().set_directory(start).pick_folder().map(|p| p.to_string_lossy().into_owned())
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +210,11 @@ fn app_icon() -> Option<egui::IconData> {
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::SOUNDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// First, so every start-up record (and the engine's panic hook, installed with the first
|
||||
// Engine) is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
@@ -198,7 +234,7 @@ fn main() -> eframe::Result {
|
||||
let files: Vec<String> = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, a)| !a.starts_with("--") && !(i > &0 && args.get(i - 1).is_some_and(|p| p == "--control")))
|
||||
.filter(|(i, a)| !(a.starts_with("--") || (i > &0 && args.get(i - 1).is_some_and(|p| p == "--control"))))
|
||||
.map(|(_, a)| a.clone())
|
||||
.collect();
|
||||
log::info!("SoundCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
|
||||
@@ -215,16 +251,18 @@ fn main() -> eframe::Result {
|
||||
},
|
||||
None => {
|
||||
logger.no_file();
|
||||
log::warn!("no log file: no settings directory (HOME, XDG_CONFIG_HOME or APPDATA is not set)");
|
||||
log::warn!("no log file: no settings directory (profile override is empty or platform configuration is unavailable)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
|
||||
let prefs = load_prefs().unwrap_or_default();
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("SoundCraft")
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_inner_size(sane_window_size(prefs.window_size))
|
||||
.with_maximized(prefs.window_maximized)
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_app_id("ai.storyteller.soundcraft"),
|
||||
@@ -233,20 +271,32 @@ fn main() -> eframe::Result {
|
||||
if let Some(icon) = app_icon() {
|
||||
options.viewport = options.viewport.with_icon(icon);
|
||||
}
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; SOUNDCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
if std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) && std::env::var_os("SOUNDCRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
eframe::run_native(
|
||||
"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.set_startup_update_check(true);
|
||||
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")));
|
||||
if let Some(ui) = load_prefs() {
|
||||
app.ui = ui;
|
||||
}
|
||||
app.extra.ui_scale = prefs.ui_scale;
|
||||
app.ui = prefs;
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
@@ -275,3 +325,26 @@ fn main() -> eframe::Result {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// A saved window size, or the default when the prefs file holds nonsense (NaN, negative, tiny).
|
||||
fn sane_window_size([w, h]: [f32; 2]) -> [f32; 2] {
|
||||
if w.is_finite() && h.is_finite() && w >= 960.0 && h >= 600.0 { [w.min(16_384.0), h.min(16_384.0)] } else { [1600.0, 1000.0] }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::set_title;
|
||||
|
||||
#[test]
|
||||
fn the_title_is_sent_only_when_it_changes() {
|
||||
let ctx = egui::Context::default();
|
||||
let titles = |title: &str| {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| set_title(ui.ctx(), title.to_string()));
|
||||
out.textures_delta.clear();
|
||||
out.viewport_output.values().flat_map(|v| &v.commands).filter(|c| matches!(c, egui::ViewportCommand::Title(_))).count()
|
||||
};
|
||||
assert_eq!(titles("Song — SoundCraft"), 1);
|
||||
assert_eq!(titles("Song — SoundCraft"), 0);
|
||||
assert_eq!(titles("Song * — SoundCraft"), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! macOS system menu bar. All actions use the same menu dispatch as the egui host.
|
||||
use muda::{CheckMenuItem, Menu, MenuEvent, PredefinedMenuItem, Submenu};
|
||||
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::NSApplication;
|
||||
use soundcraft_ui_egui::{SoundApp, menus};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
@@ -36,8 +38,9 @@ impl NativeMenu {
|
||||
app_menu.append(&PredefinedMenuItem::separator())?;
|
||||
app_menu.append(&PredefinedMenuItem::services(None))?;
|
||||
app_menu.append(&PredefinedMenuItem::separator())?;
|
||||
app_menu.append(&PredefinedMenuItem::hide(Some("Hide SoundCraft")))?;
|
||||
app_menu.append(&PredefinedMenuItem::hide_others(None))?;
|
||||
// muda's own Hide items take ⌘H and ⌥⌘H, which are Heal Separation and Copy to Send.
|
||||
app_menu.append(&MenuItem::with_id("hide", "Hide SoundCraft", true, None))?;
|
||||
app_menu.append(&MenuItem::with_id("hide_others", "Hide Others", true, None))?;
|
||||
app_menu.append(&PredefinedMenuItem::show_all(None))?;
|
||||
app_menu.append(&PredefinedMenuItem::separator())?;
|
||||
native.add_item(app, &app_menu, "Quit SoundCraft", "", Some("app.quit".into()))?;
|
||||
@@ -82,7 +85,12 @@ impl NativeMenu {
|
||||
while let Ok(event) = self.events.try_recv() {
|
||||
// A click changes state (and Cocoa flips the item's check mark): sync on the next pass.
|
||||
self.last_sync = None;
|
||||
if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id)
|
||||
if (event.id == "hide" || event.id == "hide_others")
|
||||
&& let Some(mtm) = MainThreadMarker::new()
|
||||
{
|
||||
let ns = NSApplication::sharedApplication(mtm);
|
||||
if event.id == "hide" { ns.hide(None) } else { ns.hideOtherApplications(None) }
|
||||
} else if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id)
|
||||
&& let Some(command) = &entry.command
|
||||
&& menus::item_state(app, &entry.path, Some(command)).0
|
||||
{
|
||||
@@ -105,6 +113,11 @@ impl NativeMenu {
|
||||
self.last_sync = Some(now);
|
||||
for entry in &mut self.entries {
|
||||
let (enabled, checked) = menus::item_state(app, &entry.path, entry.command.as_deref());
|
||||
if let Some(label) = menus::live_label(app, entry.command.as_deref())
|
||||
&& entry.item.text() != label
|
||||
{
|
||||
entry.item.set_text(label);
|
||||
}
|
||||
if enabled != entry.enabled {
|
||||
entry.item.set_enabled(enabled);
|
||||
entry.enabled = enabled;
|
||||
|
||||
@@ -2,36 +2,36 @@
|
||||
"schema": 1,
|
||||
"repo": "storytold/soundcraft",
|
||||
"branch": "main",
|
||||
"head": "caa4872df2aa81a21e401071d2e0d09002658e9e",
|
||||
"head_date": "2026-10-10T00:50:08Z",
|
||||
"commits": 98,
|
||||
"head": "330a6983973dc2043bb1fa0f0ba67fa07ae50685",
|
||||
"head_date": "2026-10-10T18:08:49Z",
|
||||
"commits": 111,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 56,
|
||||
"lines_added": 79132,
|
||||
"lines_deleted": 1983,
|
||||
"prs": 6,
|
||||
"commits": 58,
|
||||
"lines_added": 80505,
|
||||
"lines_deleted": 2222,
|
||||
"binary_added": 23,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T00:08:04Z",
|
||||
"last_commit": "2026-10-10T00:50:08Z"
|
||||
"last_commit": "2026-10-10T18:08:49Z"
|
||||
},
|
||||
{
|
||||
"login": "reoniazx",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 7,
|
||||
"lines_added": 453,
|
||||
"lines_deleted": 62,
|
||||
"prs": 5,
|
||||
"commits": 9,
|
||||
"lines_added": 649,
|
||||
"lines_deleted": 64,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T06:10:03Z",
|
||||
"last_commit": "2026-10-09T15:12:12Z"
|
||||
"last_commit": "2026-10-10T05:15:18Z"
|
||||
},
|
||||
{
|
||||
"login": "switch87",
|
||||
@@ -115,14 +115,14 @@
|
||||
"login": "vbasky",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 4,
|
||||
"lines_added": 829,
|
||||
"lines_deleted": 137,
|
||||
"prs": 10,
|
||||
"commits": 11,
|
||||
"lines_added": 1039,
|
||||
"lines_deleted": 174,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T00:12:26Z",
|
||||
"last_commit": "2026-10-09T22:37:04Z"
|
||||
"last_commit": "2026-10-10T05:22:24Z"
|
||||
},
|
||||
{
|
||||
"login": "DWAA1660",
|
||||
@@ -167,14 +167,27 @@
|
||||
"login": "grovebit",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 198,
|
||||
"lines_deleted": 5,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 218,
|
||||
"lines_deleted": 15,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T21:45:39Z",
|
||||
"last_commit": "2026-10-09T21:45:39Z"
|
||||
"last_commit": "2026-10-10T01:34:26Z"
|
||||
},
|
||||
{
|
||||
"login": "coygeek",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 125,
|
||||
"lines_deleted": 91,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T01:28:07Z",
|
||||
"last_commit": "2026-10-10T01:28:07Z"
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
@@ -183,9 +196,9 @@
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 71,
|
||||
"lines_added": 80906,
|
||||
"lines_deleted": 2305
|
||||
"commits": 73,
|
||||
"lines_added": 82279,
|
||||
"lines_deleted": 2544
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-haiku/5.5",
|
||||
|
||||
@@ -35,3 +35,29 @@ sync_github_name = false
|
||||
[people.salignatmoandal]
|
||||
real_name = "Mawen Salignat-Moandal"
|
||||
sync_github_name = true
|
||||
|
||||
[people.vbasky]
|
||||
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
|
||||
|
||||
[people.mattburlage]
|
||||
display_name = "Matt Smith-Burlage"
|
||||
|
||||
[people.ProdByBuddha]
|
||||
sync_github_name = true
|
||||
|
||||
+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, registry};
|
||||
|
||||
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 the CAF reader 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)]
|
||||
|
||||
+102
-9
@@ -8,18 +8,24 @@
|
||||
//! MP4, CAF, Matroska, ADPCM/A-law/µ-law WAV, ...) through `symphonia`.
|
||||
//! * [`encode`] writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32`, and FLAC (built-in
|
||||
//! 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)]
|
||||
@@ -36,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.
|
||||
@@ -106,6 +115,7 @@ pub enum BitDepth {
|
||||
Int16,
|
||||
Int24,
|
||||
Int32,
|
||||
/// Preserve finite samples, including values above full scale; map NaN to 0 and infinities to ±1.
|
||||
Float32,
|
||||
}
|
||||
|
||||
@@ -127,14 +137,17 @@ impl Default for EncodeOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Size of an ID3v2 tag at the start of `b`, if there is one.
|
||||
/// Size of an ID3v2 tag at the start of `b`, if there is one. The 10-byte footer flag (`0x10`)
|
||||
/// exists only in ID3v2.4; in v2.2/v2.3 that bit is reserved and must not extend the skip.
|
||||
fn id3v2_len(b: &[u8]) -> Option<usize> {
|
||||
if b.get(..3)? != b"ID3" {
|
||||
return None;
|
||||
}
|
||||
let version = *b.get(3)?;
|
||||
let flags = *b.get(5)?;
|
||||
let size = b.get(6..10)?.iter().fold(0usize, |acc, &x| (acc << 7) | usize::from(x & 0x7F));
|
||||
Some(10 + size + if flags & 0x10 != 0 { 10 } else { 0 })
|
||||
let footer = version == 4 && flags & 0x10 != 0;
|
||||
Some(10 + size + if footer { 10 } else { 0 })
|
||||
}
|
||||
|
||||
fn detect_by_magic(b: &[u8], depth: u32) -> Option<FileFormat> {
|
||||
@@ -186,7 +199,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,
|
||||
}
|
||||
@@ -204,16 +217,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +242,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))
|
||||
}
|
||||
@@ -241,9 +254,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +298,87 @@ 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,
|
||||
wav::HeaderExtras { bwf: opts.bwf.as_ref(), mask, force_rf64: 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)
|
||||
}
|
||||
|
||||
/// The header of a 32-bit float WAV holding `frames` frames (little-endian samples follow it). Its
|
||||
/// length doesn't depend on `frames`, so a recorder can rewrite it in place as the file grows; it
|
||||
/// switches to RF64 above 4 GiB.
|
||||
pub fn wav_float_header(channels: usize, sample_rate: u32, frames: u64) -> Result<Vec<u8>> {
|
||||
if channels == 0 || sample_rate == 0 {
|
||||
return Err(AudioError::Encode("a WAV needs channels and a sample rate".into()));
|
||||
}
|
||||
wav::header(channels, sample_rate, (32, true), None, None, frames, false)
|
||||
}
|
||||
|
||||
@@ -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,20 +163,74 @@ impl Quantizer {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) }
|
||||
/// 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(&sanitize_float(s).to_le_bytes()),
|
||||
(true, true) => scratch.extend_from_slice(&sanitize_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(())
|
||||
}
|
||||
|
||||
/// Preserve finite float samples, mapping NaN to 0 and infinities to their sign (±1).
|
||||
fn sanitize_float(x: f32) -> f32 {
|
||||
if x.is_finite() {
|
||||
x
|
||||
} else if x.is_nan() {
|
||||
0.0
|
||||
} else {
|
||||
x.signum()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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))]);
|
||||
}
|
||||
}
|
||||
|
||||
+583
-88
@@ -1,24 +1,68 @@
|
||||
//! Decode path for everything that isn't native PCM WAV/AIFF, backed by `symphonia`.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::io::{Cursor, Seek, SeekFrom};
|
||||
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::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::{FormatOptions, FormatReader};
|
||||
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions};
|
||||
use symphonia::core::formats::probe::{Hint, ProbeOptions, ProbeableFormat, Score, Scoreable};
|
||||
use symphonia::core::formats::{FormatOptions, FormatReader, Track};
|
||||
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ScopedStream};
|
||||
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;
|
||||
|
||||
/// How far [`first_marker`] looks for the first format marker: the same 1 MiB a default symphonia
|
||||
/// probe scans.
|
||||
const MAX_PREFIX_SCAN: usize = 1 << 20;
|
||||
|
||||
/// How many MPEG/ADTS candidates [`first_marker`] may hand to the readers' expensive `Scoreable`
|
||||
/// implementations. A crafted 1 MiB prefix of repeated sync markers otherwise asks for hundreds
|
||||
/// of thousands of scores, each allowed up to symphonia's 16 KiB score depth. Once the budget is
|
||||
/// spent, the scan stops at the next audio candidate: a container-like byte string later in an
|
||||
/// unscored audio payload must not become authoritative. A container marker reached first is still
|
||||
/// checked; an unchecked one the probe selects is refused after probing.
|
||||
const MAX_AUDIO_SCORE_ATTEMPTS: usize = 256;
|
||||
|
||||
/// A per-scan budget for `Scoreable::score` calls. Taking a slot consumes it even when the score
|
||||
/// rejects the candidate, so a stream of false syncs cannot multiply the work.
|
||||
struct ScoreBudget {
|
||||
used: usize,
|
||||
}
|
||||
|
||||
impl ScoreBudget {
|
||||
/// Start a scan with no expensive score attempts spent.
|
||||
fn new() -> Self {
|
||||
Self { used: 0 }
|
||||
}
|
||||
|
||||
/// Spend one score attempt, returning false once the per-scan budget is exhausted.
|
||||
fn take(&mut self) -> bool {
|
||||
if self.exhausted() {
|
||||
return false;
|
||||
}
|
||||
self.used = self.used.saturating_add(1);
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether no attempt is left for this scan.
|
||||
fn exhausted(&self) -> bool {
|
||||
self.used >= MAX_AUDIO_SCORE_ATTEMPTS
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +76,346 @@ 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 {
|
||||
/// Decoder options for every compressed format. Gapless playback used to be a
|
||||
/// `FormatOptions` flag in symphonia 0.5; in 0.6 the decoder option (on by default) trims the
|
||||
/// encoder delay and padding that the demuxer reports on each packet.
|
||||
fn decoder_options() -> AudioDecoderOptions {
|
||||
AudioDecoderOptions::default().gapless(true)
|
||||
}
|
||||
|
||||
/// The first byte after any ID3v2 tags at the start of `b`. Symphonia's tag reader allocates
|
||||
/// whatever a frame claims, so the tags are skipped, not read: they are not used here.
|
||||
fn skip_id3v2(b: &[u8]) -> usize {
|
||||
let mut at = 0usize;
|
||||
loop {
|
||||
match b.get(at..).and_then(crate::id3v2_len) {
|
||||
Some(n) if n > 0 => at = at.saturating_add(n),
|
||||
_ => return at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first format marker the probe can reach from `start`, within its 1 MiB scan limit.
|
||||
#[derive(Clone, Copy)]
|
||||
enum FirstMarker {
|
||||
Flac(usize),
|
||||
Caf(usize),
|
||||
Mp4(usize),
|
||||
Wav(usize),
|
||||
/// MPEG audio, Ogg, Matroska or AIFF: formats with no separate container size check.
|
||||
Audio,
|
||||
}
|
||||
|
||||
/// What the preflight container checks covered before the probe built a reader.
|
||||
enum Precheck {
|
||||
/// A zero-frame FLAC: returned without a reader, because Symphonia 0.6's reader needs a first
|
||||
/// audio frame to resynchronize to.
|
||||
Empty(AudioInfo),
|
||||
/// The size checks ran for the container the probe reports under this short name.
|
||||
Checked(&'static str),
|
||||
/// No container size check was needed.
|
||||
Unchecked,
|
||||
}
|
||||
|
||||
/// 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"];
|
||||
|
||||
/// The container markers to check for the family `detected` names. `None` is a raw audio stream
|
||||
/// (MP3, Ogg, AIFF), whose payload may contain container-like bytes: the unrestricted scan below
|
||||
/// finds a container only when one starts the stream, before any audio marker.
|
||||
fn container_markers(detected: FileFormat) -> Option<&'static [&'static [u8; 4]]> {
|
||||
match detected {
|
||||
// RF64 and BW64 are the >4 GiB RIFF forms.
|
||||
FileFormat::Wav => Some(&[b"RIFF", b"RF64", b"BW64"]),
|
||||
FileFormat::Flac => Some(&[b"fLaC"]),
|
||||
FileFormat::Caf => Some(&[b"caff"]),
|
||||
// MP4/M4A put `ftyp` after the first atom's size field.
|
||||
FileFormat::Aac | FileFormat::Alac => Some(&[b"ftyp"]),
|
||||
// Unknown files may carry junk before the container; symphonia's probe scans for markers.
|
||||
FileFormat::Other => Some(&[b"caff", b"ftyp", b"fLaC", b"RIFF"]),
|
||||
FileFormat::Aiff | FileFormat::Mp3 | FileFormat::Ogg => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe short name a container marker stands for.
|
||||
fn expected_probe_format(marker: &[u8; 4]) -> &'static str {
|
||||
match marker {
|
||||
b"caff" => "caf",
|
||||
// The isomp4 reader reads the `ftyp` atom.
|
||||
b"ftyp" => "isomp4",
|
||||
b"fLaC" => "flac",
|
||||
// RIFF, RF64 and BW64.
|
||||
_ => "wave",
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the first marker of `markers` 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_container_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_PREFIX_SCAN));
|
||||
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
|
||||
}
|
||||
|
||||
/// The scan variant for a marker found by [`find_container_marker`], with the probe short name its
|
||||
/// size check stands for. `ftyp` sits four bytes into its atom, so the MP4 check starts there.
|
||||
fn marker_kind(at: usize, marker: &[u8; 4]) -> (FirstMarker, &'static str) {
|
||||
match marker {
|
||||
b"fLaC" => (FirstMarker::Flac(at), expected_probe_format(marker)),
|
||||
b"caff" => (FirstMarker::Caf(at), expected_probe_format(marker)),
|
||||
b"ftyp" => (FirstMarker::Mp4(at.saturating_sub(4)), expected_probe_format(marker)),
|
||||
_ => (FirstMarker::Wav(at), expected_probe_format(marker)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe short name a container scan variant stands for.
|
||||
fn marker_name(marker: FirstMarker) -> &'static str {
|
||||
match marker {
|
||||
FirstMarker::Flac(_) => "flac",
|
||||
FirstMarker::Caf(_) => "caf",
|
||||
FirstMarker::Mp4(_) => "isomp4",
|
||||
FirstMarker::Wav(_) => "wave",
|
||||
FirstMarker::Audio => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `rest` starts with one of `R`'s exact registered probe markers.
|
||||
fn has_probe_marker<R: ProbeableFormat<'static>>(rest: &[u8]) -> bool {
|
||||
R::probe_data().iter().any(|data| data.spec.markers.iter().any(|marker| rest.starts_with(marker)))
|
||||
}
|
||||
|
||||
/// Score `R` at `at` exactly as symphonia's private probe does: `Supported` wins, EOF and every
|
||||
/// other scoring error mean "not this format", while a real I/O error aborts the probe.
|
||||
fn scores_supported<R: Scoreable>(stream: &mut MediaSourceStream<'_>, at: usize, depth: u16) -> Result<bool> {
|
||||
stream.seek(SeekFrom::Start(u64::try_from(at).unwrap_or(u64::MAX)))?;
|
||||
match R::score(ScopedStream::new(stream, u64::from(depth))) {
|
||||
Ok(Score::Supported(_)) => Ok(true),
|
||||
Err(SymError::IoError(e)) if e.kind() != std::io::ErrorKind::UnexpectedEof => Err(AudioError::Malformed(e.to_string())),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `b[at..]` is an MPEG/ADTS candidate the probe would actually select: an exact
|
||||
/// `probe_data()` marker and a `Supported` score from the same reader. A loose `0xFFEx` pair is
|
||||
/// not enough: `0xFFE0` is neither a registered marker nor a valid frame, so it must not stop the
|
||||
/// scan before a later FLAC/CAF/MP4 candidate.
|
||||
fn audio_sync_at<'a>(b: &'a [u8], at: usize, stream: &mut Option<MediaSourceStream<'a>>, budget: &mut ScoreBudget) -> Result<bool> {
|
||||
let rest = b.get(at..).unwrap_or_default();
|
||||
let adts = has_probe_marker::<symphonia::default::formats::AdtsReader<'static>>(rest);
|
||||
let mpa = has_probe_marker::<symphonia::default::formats::MpaReader<'static>>(rest);
|
||||
if !adts && !mpa {
|
||||
return Ok(false);
|
||||
}
|
||||
if stream.is_none() {
|
||||
*stream = Some(MediaSourceStream::new(Box::new(Cursor::new(b)), MediaSourceStreamOptions::default()));
|
||||
}
|
||||
let Some(mss) = stream.as_mut() else { return Ok(false) };
|
||||
let depth = ProbeOptions::default().max_score_depth;
|
||||
if adts && budget.take() && scores_supported::<symphonia::default::formats::AdtsReader<'static>>(mss, at, depth)? {
|
||||
return Ok(true);
|
||||
}
|
||||
if mpa && budget.take() && scores_supported::<symphonia::default::formats::MpaReader<'static>>(mss, at, depth)? {
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Find the first marker that can start a container or audio stream. Symphonia's `Probe::probe`
|
||||
/// constructs the reader itself and exposes no pre-construction hook, while checking every magic
|
||||
/// in the scan window would validate bytes the probe never reaches (for example a valid MP3's
|
||||
/// audio payload). Stopping at the first marker avoids that: it is the candidate the probe selects
|
||||
/// for FLAC, CAF, MP4, Ogg and Matroska, and for WAV/AIFF once their full form is verified below.
|
||||
/// MPEG/ADTS candidates are checked with the readers' own `probe_data()`/`Scoreable` APIs, so a
|
||||
/// false sync byte pair cannot stop the scan. If that scoring budget runs out, the scan stops at
|
||||
/// the next audio candidate instead of trusting a container-like byte string later in the unscored
|
||||
/// audio payload. A container marker reached before any such candidate is still preflighted; if
|
||||
/// the probe selects a size-checked container the scan did not check, `open` refuses it.
|
||||
///
|
||||
/// WAV candidates include RF64/BW64 so `check_info` can run, although symphonia 0.6 registers only
|
||||
/// the RIFF marker. Native PCM RF64/BW64 is parsed by `wav::parse` before this path; a foreign
|
||||
/// RF64/BW64 stream therefore remains unsupported by the probe rather than misdetected.
|
||||
fn first_marker(b: &[u8], start: usize) -> Result<Option<FirstMarker>> {
|
||||
first_marker_with_budget(b, start, &mut ScoreBudget::new())
|
||||
}
|
||||
|
||||
/// [`first_marker`] with an explicit score budget, so a test can prove a prefix crowded with
|
||||
/// false sync markers performs only the bounded number of reader scores.
|
||||
fn first_marker_with_budget(b: &[u8], start: usize, budget: &mut ScoreBudget) -> Result<Option<FirstMarker>> {
|
||||
let end = b.len().min(start.saturating_add(MAX_PREFIX_SCAN));
|
||||
let mut at = start;
|
||||
let mut audio_probe = None;
|
||||
while at.saturating_add(4) <= end {
|
||||
let rest = b.get(at..).unwrap_or_default();
|
||||
if rest.get(..4) == Some(b"fLaC".as_slice()) {
|
||||
return Ok(Some(FirstMarker::Flac(at)));
|
||||
}
|
||||
if rest.get(..4) == Some(b"caff".as_slice()) {
|
||||
return Ok(Some(FirstMarker::Caf(at)));
|
||||
}
|
||||
if rest.get(..4) == Some(b"OggS".as_slice()) || rest.get(..4) == Some(b"\x1A\x45\xDF\xA3".as_slice()) || crate::aiff::is_aiff(rest) {
|
||||
return Ok(Some(FirstMarker::Audio));
|
||||
}
|
||||
if crate::wav::is_wav(rest) {
|
||||
return Ok(Some(FirstMarker::Wav(at)));
|
||||
}
|
||||
if let (Some(&b0), Some(&b1)) = (b.get(at), b.get(at + 1))
|
||||
&& b0 == 0xFF
|
||||
&& b1 & 0xE0 == 0xE0
|
||||
{
|
||||
// A candidate after the cap cannot be scored. Stop rather than let a container-like
|
||||
// byte string later in an unscored audio payload become preflight-authoritative; the
|
||||
// probe will select the reader, and `open` refuses an unchecked size-checked one.
|
||||
if budget.exhausted() {
|
||||
return Ok(None);
|
||||
}
|
||||
if audio_sync_at(b, at, &mut audio_probe, budget)? {
|
||||
return Ok(Some(FirstMarker::Audio));
|
||||
}
|
||||
}
|
||||
if rest.get(4..8) == Some(b"ftyp".as_slice()) {
|
||||
return Ok(Some(FirstMarker::Mp4(at)));
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Run the early size checks on the container the probe can select. `start` is the first byte
|
||||
/// after leading ID3v2 tags. A zero-frame FLAC is returned as an empty stream because Symphonia
|
||||
/// 0.6's reader requires a first audio frame to resynchronize to.
|
||||
fn precheck(b: &[u8], start: usize, detected: FileFormat) -> Result<Precheck> {
|
||||
let any_marker = |b: &[u8], start: usize| -> Result<Option<(FirstMarker, &'static str)>> {
|
||||
Ok(match first_marker(b, start)? {
|
||||
// Stop at the first marker of any kind, so container magic incidental to an audio
|
||||
// payload is not mistaken for the stream's container.
|
||||
Some(FirstMarker::Audio) | None => None,
|
||||
Some(marker) => Some((marker, marker_name(marker))),
|
||||
})
|
||||
};
|
||||
let found = match container_markers(detected) {
|
||||
// Follow the detection: check the first marker of the family it names, so a misleading
|
||||
// marker cannot let a differently sized container reach the probe unchecked. A detection
|
||||
// is only a hint, though: when the stream holds none of its markers (a FLAC behind a
|
||||
// `.mp3` name), the probe can still select another container, which is checked here.
|
||||
Some(markers) => match find_container_marker(b, start, markers) {
|
||||
Some((at, marker)) => Some(marker_kind(at, marker)),
|
||||
None => any_marker(b, start)?,
|
||||
},
|
||||
None => any_marker(b, start)?,
|
||||
};
|
||||
let Some((marker, expected)) = found else { return Ok(Precheck::Unchecked) };
|
||||
match marker {
|
||||
FirstMarker::Flac(at) => {
|
||||
crate::flac::check_metadata(b, at)?;
|
||||
if let Some(info) = crate::flac::empty_stream_info(b, at) {
|
||||
return Ok(Precheck::Empty(info));
|
||||
}
|
||||
Ok(Precheck::Checked(expected))
|
||||
}
|
||||
FirstMarker::Caf(at) => {
|
||||
crate::caf::check(b, at)?;
|
||||
Ok(Precheck::Checked(expected))
|
||||
}
|
||||
FirstMarker::Mp4(at) => {
|
||||
crate::mp4::check(b, at)?;
|
||||
Ok(Precheck::Checked(expected))
|
||||
}
|
||||
FirstMarker::Wav(at) => {
|
||||
crate::wav::check_info(b, at)?;
|
||||
Ok(Precheck::Checked(expected))
|
||||
}
|
||||
FirstMarker::Audio => Ok(Precheck::Unchecked),
|
||||
}
|
||||
}
|
||||
|
||||
/// Skip leading ID3v2 tags, run the container size preflight, then probe with symphonia; a zero-frame FLAC found by
|
||||
/// the preflight comes back as [`Opened::Empty`] without probing. A size-checked container is refused when probing
|
||||
/// selects a different reader or one that was never checked, so incidental payload magic cannot bypass those limits.
|
||||
fn open(bytes: &[u8], detected: FileFormat) -> Result<Opened> {
|
||||
let start = skip_id3v2(bytes);
|
||||
if start > 0 && start >= bytes.len() {
|
||||
return Err(AudioError::Malformed("ID3v2 tag extends past the end of the file".into()));
|
||||
}
|
||||
let checked = precheck(bytes, start, detected)?;
|
||||
if let Precheck::Empty(info) = checked {
|
||||
return Ok(Opened::Empty(info));
|
||||
}
|
||||
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
|
||||
// Seek (rather than pre-positioning the cursor) so the stream's absolute position is `start`
|
||||
// too: container offsets such as MP4's `stco` are absolute file offsets.
|
||||
mss.seek(SeekFrom::Start(u64::try_from(start).unwrap_or(u64::MAX)))?;
|
||||
let reader: Box<dyn FormatReader> =
|
||||
symphonia::default::get_probe().probe(&Hint::new(), mss, FormatOptions::default(), MetadataOptions::default()).map_err(map_err)?;
|
||||
let probed = reader.format_info().short_name;
|
||||
match checked {
|
||||
// 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.
|
||||
Precheck::Checked(expected) if SIZE_CHECKED_PROBE_FORMATS.contains(&probed) && probed != expected => {
|
||||
return Err(AudioError::Malformed(format!("the probed container ({probed}) is not the one whose size checks ran")));
|
||||
}
|
||||
Precheck::Unchecked if SIZE_CHECKED_PROBE_FORMATS.contains(&probed) => {
|
||||
return Err(AudioError::Malformed(format!("the probed container ({probed}) was not size-checked")));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Opened::Reader(reader))
|
||||
}
|
||||
|
||||
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 +427,21 @@ fn sample_format_of(p: &CodecParameters) -> SampleFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio parameters of `track`, if it carries any.
|
||||
fn audio_params(track: &Track) -> Option<&AudioCodecParameters> {
|
||||
track.codec_params.as_ref()?.audio()
|
||||
}
|
||||
|
||||
/// 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
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.find(|t| audio_params(t).is_some_and(|p| p.codec != CODEC_ID_NULL_AUDIO))
|
||||
.ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
|
||||
let p = &track.codec_params;
|
||||
let p = audio_params(track).ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
|
||||
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,72 +450,105 @@ 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,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// A demux error ends the decode; it is only an error when nothing was decoded yet.
|
||||
fn demux_failure(frames: u64, e: SymError) -> Result<()> {
|
||||
if frames == 0 {
|
||||
return Err(map_err(e));
|
||||
}
|
||||
log::warn!("stopping decode after demux error: {e}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Note a corrupt packet (the `errors`-th); a stream that never decodes is malformed.
|
||||
fn corrupt_packet(errors: usize, frames: u64, msg: &str) -> Result<()> {
|
||||
log::debug!("skipping corrupt packet: {msg}");
|
||||
if errors > 1000 && frames == 0 {
|
||||
return Err(AudioError::Malformed(format!("no decodable packets: {msg}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The decoded stream is authoritative for the channel count: record it and allocate the planes.
|
||||
fn init_planar(info: &mut AudioInfo, ch: usize) -> Result<Vec<Vec<f32>>> {
|
||||
info.channels = u16::try_from(ch).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
|
||||
Ok((0..ch).map(|_| Vec::new()).collect())
|
||||
}
|
||||
|
||||
/// Append one interleaved block to the planes, replacing non-finite samples with silence.
|
||||
fn append_interleaved(planar: &mut [Vec<f32>], interleaved: &[f32], ch: usize) {
|
||||
for frame in interleaved.chunks_exact(ch) {
|
||||
for (dst, &s) in planar.iter_mut().zip(frame) {
|
||||
dst.push(if s.is_finite() { s } else { 0.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let track = reader.tracks().iter().find(|t| t.id == track_id).ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
|
||||
if let Some(n) = track.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")));
|
||||
}
|
||||
}
|
||||
let params = reader
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.id == track_id)
|
||||
.map(|t| t.codec_params.clone())
|
||||
.ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
|
||||
let mut decoder = symphonia::default::get_codecs().make(¶ms, &DecoderOptions::default()).map_err(map_err)?;
|
||||
let params = audio_params(track).cloned().ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
|
||||
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, &decoder_options()).map_err(map_err)?;
|
||||
|
||||
let mut planar: Vec<Vec<f32>> = Vec::new();
|
||||
let mut interleaved: 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) => {
|
||||
if frames == 0 {
|
||||
return Err(map_err(e));
|
||||
}
|
||||
log::warn!("stopping decode after demux error: {e}");
|
||||
demux_failure(frames, e)?;
|
||||
break;
|
||||
}
|
||||
};
|
||||
if packet.track_id() != track_id {
|
||||
if packet.track_id != track_id {
|
||||
continue;
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
Ok(d) => d,
|
||||
Err(SymError::DecodeError(m)) => {
|
||||
errors += 1;
|
||||
log::debug!("skipping corrupt packet: {m}");
|
||||
if errors > 1000 && frames == 0 {
|
||||
return Err(AudioError::Malformed(format!("no decodable packets: {m}")));
|
||||
}
|
||||
corrupt_packet(errors, frames, m)?;
|
||||
continue;
|
||||
}
|
||||
Err(SymError::IoError(_)) => break,
|
||||
Err(e) => return Err(map_err(e)),
|
||||
};
|
||||
let spec = *decoded.spec();
|
||||
let ch = spec.channels.count();
|
||||
// The interleaved copy below is laid out by planes: use their count so a decoder whose
|
||||
// channel spec disagrees with its planes cannot trip the copy's length assertion.
|
||||
let ch = decoded.num_planes();
|
||||
if ch == 0 {
|
||||
continue;
|
||||
}
|
||||
if planar.is_empty() {
|
||||
// The decoded stream is authoritative for the channel count.
|
||||
info.channels = u16::try_from(ch).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
|
||||
planar = (0..ch).map(|_| Vec::new()).collect();
|
||||
planar = init_planar(&mut info, ch)?;
|
||||
}
|
||||
let n = decoded.frames() as u64;
|
||||
frames = frames.saturating_add(n);
|
||||
@@ -183,17 +558,16 @@ 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 });
|
||||
}
|
||||
let needed = usize::try_from(n)
|
||||
.ok()
|
||||
.and_then(|n| n.checked_mul(ch))
|
||||
.ok_or_else(|| AudioError::TooLarge("decoded audio exceeds the 4 GiB limit".into()))?;
|
||||
if interleaved.len() < needed {
|
||||
interleaved.try_reserve(needed - interleaved.len()).map_err(|_| AudioError::TooLarge("cannot allocate decoded audio".into()))?;
|
||||
interleaved.resize(needed, 0.0);
|
||||
}
|
||||
decoded.copy_to_slice_interleaved(&mut interleaved[..needed]);
|
||||
append_interleaved(&mut planar, &interleaved[..needed], ch);
|
||||
}
|
||||
if info.channels == 0 {
|
||||
return Err(AudioError::Malformed("stream contains no audio".into()));
|
||||
@@ -206,18 +580,139 @@ 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))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use symphonia::core::codecs::audio::well_known::{
|
||||
CODEC_ID_AAC, CODEC_ID_ADPCM_MS, CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP3, CODEC_ID_PCM_F32LE, CODEC_ID_PCM_F64LE, CODEC_ID_PCM_S16LE,
|
||||
CODEC_ID_VORBIS,
|
||||
};
|
||||
|
||||
/// Build codec parameters for a test, optionally declaring a bit depth.
|
||||
fn params(codec: AudioCodecId, bits_per_sample: Option<u32>) -> AudioCodecParameters {
|
||||
let mut params = AudioCodecParameters::new();
|
||||
params.for_codec(codec);
|
||||
if let Some(bits) = bits_per_sample {
|
||||
params.with_bits_per_sample(bits);
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
/// Codec ids map to their reported formats, while a format detected from the container wins over the codec.
|
||||
#[test]
|
||||
fn codecs_map_to_the_reported_formats() {
|
||||
assert_eq!(format_from_codec(CODEC_ID_FLAC, FileFormat::Other), FileFormat::Flac);
|
||||
assert_eq!(format_from_codec(CODEC_ID_MP3, FileFormat::Other), FileFormat::Mp3);
|
||||
assert_eq!(format_from_codec(CODEC_ID_AAC, FileFormat::Other), FileFormat::Aac);
|
||||
assert_eq!(format_from_codec(CODEC_ID_ALAC, FileFormat::Other), FileFormat::Alac);
|
||||
assert_eq!(format_from_codec(CODEC_ID_VORBIS, FileFormat::Other), FileFormat::Ogg);
|
||||
// The container detected from the bytes wins over the codec.
|
||||
assert_eq!(format_from_codec(CODEC_ID_ALAC, FileFormat::Aac), FileFormat::Aac);
|
||||
}
|
||||
|
||||
/// PCM and FLAC keep their declared bit depth; lossy codecs report `Compressed` regardless of their decoded
|
||||
/// width.
|
||||
#[test]
|
||||
fn sample_formats_keep_their_depth_and_lossy_marker() {
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_PCM_S16LE, Some(16))), SampleFormat::Int16);
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_FLAC, Some(24))), SampleFormat::Int24);
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_FLAC, Some(8))), SampleFormat::Int8);
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_FLAC, Some(32))), SampleFormat::Int32);
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_PCM_F32LE, Some(32))), SampleFormat::Float32);
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_PCM_F64LE, Some(64))), SampleFormat::Float64);
|
||||
// Lossy codecs report no fixed bit depth, whatever their decoded sample format is.
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_AAC, Some(16))), SampleFormat::Compressed);
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_ADPCM_MS, Some(4))), SampleFormat::Compressed);
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_ALAC, None)), SampleFormat::Compressed);
|
||||
}
|
||||
|
||||
/// Decoder options keep gapless trimming enabled so the encoder delay and padding reported by the demuxer are
|
||||
/// removed.
|
||||
#[test]
|
||||
fn gapless_decoding_stays_enabled() {
|
||||
assert!(decoder_options().gapless);
|
||||
}
|
||||
|
||||
/// A complete MPEG frame header is a candidate; loose or unregistered sync pairs are rejected before scoring,
|
||||
/// while a registered but truncated header is rejected by the reader's score after consuming a budget slot.
|
||||
#[test]
|
||||
fn only_the_readers_own_audio_markers_are_candidates() {
|
||||
/// Whether the preflight would score `b` as an audio candidate at offset 0.
|
||||
fn candidate(b: &[u8]) -> bool {
|
||||
let mut stream = None;
|
||||
let mut budget = ScoreBudget::new();
|
||||
matches!(audio_sync_at(b, 0, &mut stream, &mut budget), Ok(true))
|
||||
}
|
||||
let mut mp3_frame = vec![0u8; 417];
|
||||
mp3_frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz
|
||||
assert!(candidate(&[&mp3_frame[..], &mp3_frame].concat()));
|
||||
for false_sync in [vec![0xFF, 0xE0, 0x00], vec![0xFF, 0xF0, 0x00], vec![0xFF, 0xFB, 0xF0, 0x00]] {
|
||||
assert!(!candidate(&false_sync), "{false_sync:?} is not a probe candidate");
|
||||
}
|
||||
}
|
||||
|
||||
/// A prefix made of false MPEG sync markers must not ask the readers to score every byte
|
||||
/// position: the per-scan budget caps the expensive work. A cheap container marker reached
|
||||
/// before the next audio candidate is still found, but once the cap is spent an audio
|
||||
/// candidate stops the scan so later payload magic cannot be preflight-authoritative.
|
||||
#[test]
|
||||
fn a_prefix_crowded_with_sync_markers_scores_only_the_budget() {
|
||||
let mut crowded = vec![0u8; 1 << 16];
|
||||
for pair in crowded.as_chunks_mut::<2>().0 {
|
||||
pair.copy_from_slice(&[0xFF, 0xE2]);
|
||||
}
|
||||
let mut budget = ScoreBudget::new();
|
||||
assert!(matches!(first_marker_with_budget(&crowded, 0, &mut budget), Ok(None)));
|
||||
assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS, "no candidate after the cap is scored");
|
||||
|
||||
// At the cap boundary, with no later audio candidate, the cheap marker is still found.
|
||||
let mut at_cap: Vec<u8> = (0..MAX_AUDIO_SCORE_ATTEMPTS).flat_map(|_| [0xFFu8, 0xE2]).collect();
|
||||
at_cap.extend_from_slice(b"fLaC");
|
||||
let mut budget = ScoreBudget::new();
|
||||
assert!(
|
||||
matches!(first_marker_with_budget(&at_cap, 0, &mut budget), Ok(Some(FirstMarker::Flac(_)))),
|
||||
"a container marker reached before the next audio candidate is still found"
|
||||
);
|
||||
assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS);
|
||||
|
||||
// One more false candidate between the cap and the magic makes the scan stop unchecked:
|
||||
// the same magic could instead sit in a valid audio payload.
|
||||
let mut after_candidate = at_cap[..2 * MAX_AUDIO_SCORE_ATTEMPTS].to_vec();
|
||||
after_candidate.extend_from_slice(&[0xFF, 0xE2]);
|
||||
after_candidate.extend_from_slice(b"fLaC");
|
||||
let mut budget = ScoreBudget::new();
|
||||
assert!(matches!(first_marker_with_budget(&after_candidate, 0, &mut budget), Ok(None)), "an audio candidate after the cap stops the scan");
|
||||
assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS);
|
||||
}
|
||||
|
||||
/// ID3v2 sizes are read in their 7-bit syncsafe form; consecutive tags are skipped by their declared sizes, and
|
||||
/// the v2.3 reserved footer bit does not extend a tag.
|
||||
#[test]
|
||||
fn leading_id3_tags_are_skipped_by_their_declared_size() {
|
||||
let tag = [&b"ID3\x04\0\0\0\0\0\x05"[..], b"hello"].concat();
|
||||
assert_eq!(skip_id3v2(&[&tag[..], &tag[..]].concat()), 30);
|
||||
assert_eq!(skip_id3v2(b"fLaC"), 0);
|
||||
// The 10-byte footer flag exists only in v2.4; the same bit in v2.3 is reserved.
|
||||
assert_eq!(crate::id3v2_len(b"ID3\x03\0\x10\0\0\0\0"), Some(10));
|
||||
assert_eq!(crate::id3v2_len(b"ID3\x04\0\x10\0\0\0\0"), Some(20));
|
||||
}
|
||||
}
|
||||
|
||||
+267
-62
@@ -1,14 +1,17 @@
|
||||
//! 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;
|
||||
const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE;
|
||||
/// Tail of the KSDATAFORMAT_SUBTYPE_* GUIDs (bytes 2..16); bytes 0..2 hold the format tag.
|
||||
const GUID_TAIL: [u8; 14] = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71];
|
||||
/// Tail of the Ambisonic subtype GUIDs {0000000x-0721-11D3-8644-C8C1CA000000}: PCM (x = 1) and float (x = 3) as well.
|
||||
const AMBISONIC_TAIL: [u8; 14] = [0x00, 0x00, 0x21, 0x07, 0xD3, 0x11, 0x86, 0x44, 0xC8, 0xC1, 0xCA, 0x00, 0x00, 0x00];
|
||||
const BEXT_MIN_LEN: usize = 602;
|
||||
|
||||
/// Parsed WAV layout (no sample data decoded yet).
|
||||
@@ -49,13 +52,25 @@ fn parse_fmt(c: &[u8]) -> Result<Fmt> {
|
||||
fmt.valid_bits = fmt.bits;
|
||||
if fmt.tag == WAVE_FORMAT_EXTENSIBLE {
|
||||
// cbSize(2) validBits(2) channelMask(4) subFormat GUID(16)
|
||||
if let (Some(valid), Some(sub)) = (le_u16(c, 18), le_u16(c, 24)) {
|
||||
if valid != 0 {
|
||||
fmt.valid_bits = valid;
|
||||
}
|
||||
fmt.tag = sub;
|
||||
} else {
|
||||
let (Some(cb_size), Some(valid), Some(sub)) = (le_u16(c, 16), le_u16(c, 18), le_u16(c, 24)) else {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into()));
|
||||
};
|
||||
let Some(tail) = c.get(26..40) else {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into()));
|
||||
};
|
||||
if cb_size < 22 {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE cbSize is below 22".into()));
|
||||
}
|
||||
if valid > fmt.bits {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE valid bits exceed the container size".into()));
|
||||
}
|
||||
if valid != 0 {
|
||||
fmt.valid_bits = valid;
|
||||
}
|
||||
// A subtype outside the KSDATAFORMAT_SUBTYPE_* family is not PCM/float, whatever its first two bytes say:
|
||||
// keep the extensible tag so the file goes to the foreign decoder.
|
||||
if tail == GUID_TAIL || tail == AMBISONIC_TAIL {
|
||||
fmt.tag = sub;
|
||||
}
|
||||
}
|
||||
Ok(fmt)
|
||||
@@ -83,11 +98,125 @@ 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
|
||||
}
|
||||
|
||||
/// A `data` chunk header as found in the file, for [`data_chunk_len`].
|
||||
struct DataChunk {
|
||||
body: usize,
|
||||
size32: u32,
|
||||
is_rf64: bool,
|
||||
remaining: usize,
|
||||
declared: usize,
|
||||
}
|
||||
|
||||
/// Bytes of audio a `data` chunk holds: leniently, whatever is present when the size is wrong or
|
||||
/// the recording was never finalised.
|
||||
fn data_chunk_len(b: &[u8], c: DataChunk) -> usize {
|
||||
let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;
|
||||
let empty = c.size32 == 0 && !c.is_rf64 && finished && only_chunks_follow(b, c.body);
|
||||
if c.size32 == 0 && !c.is_rf64 && !empty { c.remaining } else { c.declared.min(c.remaining) }
|
||||
}
|
||||
|
||||
/// The sample layout of a `fmt` chunk, `None` for a format we do not read natively.
|
||||
fn pcm_layout(fmt: &Fmt) -> Result<Option<(PcmKind, SampleFormat)>> {
|
||||
Ok(Some(match (fmt.tag, fmt.bits) {
|
||||
(WAVE_FORMAT_PCM, bits @ 1..=32) => {
|
||||
let bytes = usize::from(bits.div_ceil(8));
|
||||
let sf = match fmt.valid_bits.min(bits) {
|
||||
0..=8 => SampleFormat::Int8,
|
||||
9..=16 => SampleFormat::Int16,
|
||||
17..=24 => SampleFormat::Int24,
|
||||
_ => SampleFormat::Int32,
|
||||
};
|
||||
(PcmKind::Int { bytes, big_endian: false, unsigned: bytes == 1 }, sf)
|
||||
}
|
||||
(WAVE_FORMAT_IEEE_FLOAT, 32) => (PcmKind::Float { bytes: 4, big_endian: false }, SampleFormat::Float32),
|
||||
(WAVE_FORMAT_IEEE_FLOAT, 64) => (PcmKind::Float { bytes: 8, big_endian: false }, SampleFormat::Float64),
|
||||
(WAVE_FORMAT_PCM | WAVE_FORMAT_IEEE_FLOAT, bits) => {
|
||||
return Err(AudioError::Unsupported(format!("WAV format {} with {bits} bits per sample", fmt.tag)));
|
||||
}
|
||||
_ => return Ok(None),
|
||||
}))
|
||||
}
|
||||
|
||||
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 +245,10 @@ 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 len = data_chunk_len(b, DataChunk { body, size32, is_rf64, remaining, declared });
|
||||
if data.is_none() {
|
||||
data = Some((body, len));
|
||||
}
|
||||
@@ -151,24 +283,7 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
|
||||
return Err(AudioError::Malformed("WAV declares a zero sample rate".into()));
|
||||
}
|
||||
let channels = usize::from(fmt.channels);
|
||||
let (kind, sample_format) = match (fmt.tag, fmt.bits) {
|
||||
(WAVE_FORMAT_PCM, bits @ 1..=32) => {
|
||||
let bytes = usize::from(bits.div_ceil(8));
|
||||
let sf = match fmt.valid_bits.min(bits) {
|
||||
0..=8 => SampleFormat::Int8,
|
||||
9..=16 => SampleFormat::Int16,
|
||||
17..=24 => SampleFormat::Int24,
|
||||
_ => SampleFormat::Int32,
|
||||
};
|
||||
(PcmKind::Int { bytes, big_endian: false, unsigned: bytes == 1 }, sf)
|
||||
}
|
||||
(WAVE_FORMAT_IEEE_FLOAT, 32) => (PcmKind::Float { bytes: 4, big_endian: false }, SampleFormat::Float32),
|
||||
(WAVE_FORMAT_IEEE_FLOAT, 64) => (PcmKind::Float { bytes: 8, big_endian: false }, SampleFormat::Float64),
|
||||
(WAVE_FORMAT_PCM | WAVE_FORMAT_IEEE_FLOAT, bits) => {
|
||||
return Err(AudioError::Unsupported(format!("WAV format {} with {bits} bits per sample", fmt.tag)));
|
||||
}
|
||||
_ => return Ok(WavParse::Foreign { bwf }),
|
||||
};
|
||||
let Some((kind, sample_format)) = pcm_layout(&fmt)? else { return Ok(WavParse::Foreign { bwf }) };
|
||||
let block = kind.bytes().checked_mul(channels).ok_or_else(|| AudioError::Malformed("block size overflow".into()))?;
|
||||
if usize::from(fmt.block_align) != block {
|
||||
log::debug!("WAV block_align {} disagrees with computed {block}; using computed", fmt.block_align);
|
||||
@@ -255,6 +370,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,
|
||||
}
|
||||
|
||||
/// The optional header parts of a WAV file: BWF metadata, speaker mask, and whether to force RF64.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct HeaderExtras<'a> {
|
||||
pub(crate) bwf: Option<&'a BwfInfo>,
|
||||
pub(crate) mask: Option<u32>,
|
||||
pub(crate) force_rf64: bool,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Writer<W> {
|
||||
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool, extras: HeaderExtras<'_>) -> Result<Self> {
|
||||
let HeaderExtras { bwf, mask, force_rf64 } = extras;
|
||||
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,24 +477,38 @@ 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 block = bytes.checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block 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")))?;
|
||||
let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, HeaderExtras { 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.
|
||||
pub(crate) fn header(
|
||||
channels: usize,
|
||||
sample_rate: u32,
|
||||
(bits, is_float): (u16, bool),
|
||||
bwf: Option<&BwfInfo>,
|
||||
mask: Option<u32>,
|
||||
frames: u64,
|
||||
force_rf64: bool,
|
||||
) -> Result<Vec<u8>> {
|
||||
let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
|
||||
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
|
||||
let data_len = block.checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
|
||||
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
|
||||
|
||||
let mut fmt = Vec::with_capacity(40);
|
||||
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
|
||||
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
|
||||
fmt.extend_from_slice(&buf.sample_rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&buf.sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
|
||||
fmt.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
|
||||
fmt.extend_from_slice(&block_align.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
if extensible {
|
||||
@@ -308,7 +530,10 @@ pub(crate) fn encode(
|
||||
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 + 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 {
|
||||
@@ -316,9 +541,9 @@ pub(crate) fn encode(
|
||||
head.extend_from_slice(&u32::MAX.to_le_bytes());
|
||||
head.extend_from_slice(b"WAVE");
|
||||
let mut ds64 = Vec::with_capacity(rf64_body_len);
|
||||
ds64.extend_from_slice(&(total as u64).to_le_bytes());
|
||||
ds64.extend_from_slice(&(data_len as u64).to_le_bytes());
|
||||
ds64.extend_from_slice(&(frames as u64).to_le_bytes());
|
||||
ds64.extend_from_slice(&total.to_le_bytes());
|
||||
ds64.extend_from_slice(&data_len.to_le_bytes());
|
||||
ds64.extend_from_slice(&frames.to_le_bytes());
|
||||
ds64.extend_from_slice(&0u32.to_le_bytes());
|
||||
push_chunk(&mut head, b"ds64", &ds64);
|
||||
} else {
|
||||
@@ -330,25 +555,5 @@ pub(crate) fn encode(
|
||||
head.extend_from_slice(&meta);
|
||||
head.extend_from_slice(b"data");
|
||||
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
|
||||
|
||||
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)
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileEncoder, FileFormat, SampleFormat, decode, encode};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn encoded(audio: &AudioBuffer, options: &EncodeOptions, streaming: bool) -> Vec<u8> {
|
||||
if !streaming {
|
||||
return encode(audio, options).unwrap();
|
||||
}
|
||||
let mut writer = FileEncoder::new(Cursor::new(Vec::new()), options, audio.num_channels(), audio.sample_rate, None).unwrap();
|
||||
// Cross the writer's 4096-frame scratch boundary, then write a short final block.
|
||||
for start in (0..audio.frames()).step_by(4097) {
|
||||
let end = (start + 4097).min(audio.frames());
|
||||
let block: Vec<Vec<f32>> = audio.channels.iter().map(|channel| channel[start..end].to_vec()).collect();
|
||||
writer.write(&block, end - start).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn assert_float_headroom(streaming: bool) {
|
||||
let samples = [
|
||||
-f32::MAX,
|
||||
-2.0,
|
||||
-1.25,
|
||||
-1.0,
|
||||
-0.5,
|
||||
-f32::MIN_POSITIVE,
|
||||
-f32::from_bits(1),
|
||||
-0.0,
|
||||
0.0,
|
||||
f32::from_bits(1),
|
||||
f32::MIN_POSITIVE,
|
||||
0.5,
|
||||
1.0,
|
||||
1.25,
|
||||
2.0,
|
||||
f32::MAX,
|
||||
];
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff] {
|
||||
for dither in [false, true] {
|
||||
for channels in [1, 2] {
|
||||
let audio = AudioBuffer {
|
||||
sample_rate: 48_000,
|
||||
channels: (0..channels).map(|c| samples.iter().copied().cycle().skip(c).take(4103).collect()).collect(),
|
||||
};
|
||||
let options = EncodeOptions { format, bit_depth: BitDepth::Float32, dither, bwf: None };
|
||||
let bytes = encoded(&audio, &options, streaming);
|
||||
let pcm: Vec<u8> =
|
||||
audio.interleaved().iter().flat_map(|v| if format == FileFormat::Wav { v.to_le_bytes() } else { v.to_be_bytes() }).collect();
|
||||
assert!(bytes.ends_with(&pcm), "{format:?}, dither={dither}: stored sample bytes changed");
|
||||
let (info, decoded) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(info.sample_format, SampleFormat::Float32);
|
||||
assert_eq!((decoded.sample_rate, decoded.num_channels(), decoded.frames()), (48_000, channels, 4103));
|
||||
for (channel, (actual, expected)) in decoded.channels.iter().zip(&audio.channels).enumerate() {
|
||||
for (frame, (actual, expected)) in actual.iter().zip(expected).enumerate() {
|
||||
assert_eq!(actual.to_bits(), expected.to_bits(), "{format:?}, dither={dither}, channel={channel}, frame={frame}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_file_float32_preserves_finite_headroom() {
|
||||
assert_float_headroom(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_float32_preserves_finite_headroom() {
|
||||
assert_float_headroom(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float32_keeps_non_finite_sample_handling() {
|
||||
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![f32::NAN, f32::NEG_INFINITY, f32::INFINITY]] };
|
||||
let expected = [0.0_f32, -1.0, 1.0];
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff] {
|
||||
for streaming in [false, true] {
|
||||
let options = EncodeOptions { format, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = encoded(&audio, &options, streaming);
|
||||
// Inspect stored samples too: the decoder itself replaces non-finite values.
|
||||
let pcm: Vec<u8> = expected.iter().flat_map(|v| if format == FileFormat::Wav { v.to_le_bytes() } else { v.to_be_bytes() }).collect();
|
||||
assert!(bytes.ends_with(&pcm), "{format:?}, streaming={streaming}");
|
||||
assert_eq!(decode(&bytes, None).unwrap().1.channels[0], expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_pcm_still_clips_at_full_scale() {
|
||||
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![-2.0, -1.25, -1.0, -0.5, 0.0, 0.5, 1.0, 1.25, 2.0]] };
|
||||
for (depth, max) in [(BitDepth::Int16, 32767.0 / 32768.0), (BitDepth::Int24, 8_388_607.0 / 8_388_608.0), (BitDepth::Int32, 1.0)] {
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
for streaming in [false, true] {
|
||||
let options = EncodeOptions { format, bit_depth: depth, dither: false, bwf: None };
|
||||
let bytes = encoded(&audio, &options, streaming);
|
||||
let (_, decoded) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(decoded.channels[0], [-1.0, -1.0, -1.0, -0.5, 0.0, 0.5, max, max, max], "{format:?} {depth:?}, streaming={streaming}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
mod encode_format;
|
||||
mod float_headroom;
|
||||
mod malformed;
|
||||
mod roundtrip;
|
||||
mod stream;
|
||||
mod wav_empty_data;
|
||||
@@ -0,0 +1,746 @@
|
||||
//! Hostile inputs must produce `Err` (or a best-effort `Ok`), never a panic.
|
||||
|
||||
use proptest::prelude::*;
|
||||
use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, probe};
|
||||
|
||||
fn small(format: FileFormat, depth: BitDepth, channels: usize) -> Vec<u8> {
|
||||
let mut b = AudioBuffer::new(44_100, channels, 300);
|
||||
for ch in &mut b.channels {
|
||||
for (i, s) in ch.iter_mut().enumerate() {
|
||||
*s = ((i as f32) * 0.1).sin() * 0.5;
|
||||
}
|
||||
}
|
||||
encode(&b, &EncodeOptions { format, bit_depth: depth, dither: false, bwf: None }).unwrap()
|
||||
}
|
||||
|
||||
/// An ID3v2.4 header whose syncsafe size field declares `size` bytes of tag body.
|
||||
fn id3_header(size: usize) -> [u8; 10] {
|
||||
let size = size as u32;
|
||||
[b'I', b'D', b'3', 4, 0, 0, ((size >> 21) & 0x7F) as u8, ((size >> 14) & 0x7F) as u8, ((size >> 7) & 0x7F) as u8, (size & 0x7F) as u8]
|
||||
}
|
||||
|
||||
/// Wrap `body` in an ID3v2.4 tag.
|
||||
fn id3_tag(body: &[u8]) -> Vec<u8> {
|
||||
[&id3_header(body.len())[..], body].concat()
|
||||
}
|
||||
|
||||
/// Little helper for building RIFF files by hand.
|
||||
fn riff(chunks: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
|
||||
let mut body = b"WAVE".to_vec();
|
||||
for (id, data) in chunks {
|
||||
body.extend_from_slice(*id);
|
||||
body.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
body.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
body.push(0);
|
||||
}
|
||||
}
|
||||
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 fmt(tag: u16, channels: u16, sr: u32, bits: u16) -> Vec<u8> {
|
||||
let align = channels * bits.div_ceil(8);
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(&tag.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(&bits.to_le_bytes());
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_truncation_is_handled() {
|
||||
let files = [
|
||||
small(FileFormat::Wav, BitDepth::Int16, 2),
|
||||
small(FileFormat::Wav, BitDepth::Float32, 6),
|
||||
small(FileFormat::Aiff, BitDepth::Int24, 1),
|
||||
small(FileFormat::Aiff, BitDepth::Float32, 2),
|
||||
small(FileFormat::Flac, BitDepth::Int16, 2),
|
||||
small(FileFormat::Flac, BitDepth::Int24, 1),
|
||||
];
|
||||
for file in &files {
|
||||
for n in 0..file.len() {
|
||||
let cut = &file[..n];
|
||||
let _ = probe(cut, None);
|
||||
if let Ok((info, buf)) = decode(cut, None) {
|
||||
assert_eq!(buf.num_channels(), info.channels as usize);
|
||||
assert_eq!(buf.frames() as u64, info.frames);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_data_chunk_yields_partial_audio() {
|
||||
let file = small(FileFormat::Wav, BitDepth::Int16, 2);
|
||||
let (info, buf) = decode(&file[..file.len() - 400], None).unwrap();
|
||||
assert_eq!(info.frames, 200);
|
||||
assert_eq!(buf.frames(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_single_byte_corruption_is_handled() {
|
||||
for file in [small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Aiff, BitDepth::Int16, 2), small(FileFormat::Flac, BitDepth::Int16, 1)]
|
||||
{
|
||||
for i in 0..file.len().min(200) {
|
||||
for v in [0x00, 0xFF, 0x7F, 0x80] {
|
||||
let mut f = file.clone();
|
||||
f[i] = v;
|
||||
let _ = probe(&f, None);
|
||||
let _ = decode(&f, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_channels_is_malformed() {
|
||||
let wav = riff(&[(b"fmt ", fmt(1, 0, 44_100, 16)), (b"data", vec![0; 16])]);
|
||||
assert!(matches!(decode(&wav, None), Err(AudioError::Malformed(_))));
|
||||
assert!(probe(&wav, None).is_err());
|
||||
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
|
||||
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
aiff[comm + 8] = 0;
|
||||
aiff[comm + 9] = 0;
|
||||
assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_sample_rate_is_malformed() {
|
||||
let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]);
|
||||
assert!(decode(&wav, None).is_err());
|
||||
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
|
||||
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
for b in &mut aiff[comm + 16..comm + 26] {
|
||||
*b = 0;
|
||||
}
|
||||
assert!(decode(&aiff, None).is_err());
|
||||
// Infinite exponent.
|
||||
aiff[comm + 16] = 0x7F;
|
||||
aiff[comm + 17] = 0xFF;
|
||||
assert!(decode(&aiff, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absurd_chunk_sizes() {
|
||||
// fmt chunk claims 4 GiB.
|
||||
let mut wav = riff(&[(b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
|
||||
wav[16..20].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(decode(&wav, None).is_err());
|
||||
// data chunk claims 4 GiB: lenient read of what's there.
|
||||
let mut wav = riff(&[(b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
|
||||
let d = wav.windows(4).position(|w| w == b"data").unwrap();
|
||||
wav[d + 4..d + 8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let (info, buf) = decode(&wav, None).unwrap();
|
||||
assert_eq!((info.frames, buf.frames()), (4, 4));
|
||||
// An unknown chunk with an absurd size before fmt.
|
||||
let mut wav = riff(&[(b"junk", vec![0; 4]), (b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
|
||||
wav[16..20].copy_from_slice(&0xFFFF_FFF0u32.to_le_bytes());
|
||||
assert!(decode(&wav, None).is_err());
|
||||
// RF64 with a ds64 claiming an exabyte of data.
|
||||
let mut ds64 = Vec::new();
|
||||
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
|
||||
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
|
||||
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
|
||||
ds64.extend_from_slice(&0u32.to_le_bytes());
|
||||
let mut rf = riff(&[(b"ds64", ds64), (b"fmt ", fmt(1, 1, 8_000, 8)), (b"data", vec![0x80; 10])]);
|
||||
rf[..4].copy_from_slice(b"RF64");
|
||||
let d = rf.windows(4).rposition(|w| w == b"data").unwrap();
|
||||
rf[d + 4..d + 8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let (info, buf) = decode(&rf, None).unwrap();
|
||||
assert_eq!((info.frames, buf.frames()), (10, 10));
|
||||
// AIFF COMM claims more frames than present; SSND offset beyond the end.
|
||||
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
|
||||
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
aiff[comm + 10..comm + 14].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(decode(&aiff, None).unwrap().1.frames(), 300);
|
||||
let ssnd = aiff.windows(4).position(|w| w == b"SSND").unwrap();
|
||||
aiff[ssnd + 8..ssnd + 12].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
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);
|
||||
}
|
||||
|
||||
/// Container magic inside an MP3 frame payload must not change detection or make probing reject the stream.
|
||||
#[test]
|
||||
fn a_valid_mp3_with_incidental_container_magic_still_probes() {
|
||||
let mut frame = vec![0u8; 417];
|
||||
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz, stereo
|
||||
let mut f: Vec<u8> = (0..8).flat_map(|_| frame.clone()).collect();
|
||||
let at = 417 * 2 + 10; // Inside frame 2's payload, leaving every frame header intact.
|
||||
f[at..at + 8].copy_from_slice(b"caff\0\x01\0\0");
|
||||
f[at + 8..at + 12].copy_from_slice(b"kuki");
|
||||
f[at + 12..at + 20].copy_from_slice(&(1u64 << 50).to_be_bytes());
|
||||
assert_eq!(detect_format(&f, None), FileFormat::Mp3);
|
||||
let info = probe(&f, None).expect("valid MP3 with incidental container magic");
|
||||
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2));
|
||||
}
|
||||
|
||||
/// Symphonia 0.6's CAF/FLAC/MP4 `Scoreable` implementations are unconditional
|
||||
/// (`Supported(255)`), so a container magic before the first audio frame *is* the reader the probe
|
||||
/// selects; there is no API to ask the probe to skip it. The preflight must therefore keep
|
||||
/// refusing an oversized CAF cookie instead of ignoring the marker and playing the MPEG frames
|
||||
/// behind it (which would also let a real malformed container bypass the size guard).
|
||||
#[test]
|
||||
fn a_container_marker_before_the_first_audio_frame_is_the_probed_candidate() {
|
||||
let mut frame = vec![0u8; 417];
|
||||
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz, stereo
|
||||
let mp3: Vec<u8> = (0..8).flat_map(|_| frame.clone()).collect();
|
||||
let malformed = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"kuki", 1i64 << 50, vec![0; 8])]);
|
||||
for f in [[vec![0u8], malformed.clone(), mp3.clone()].concat(), [vec![0u8; 32], malformed.clone(), mp3.clone()].concat()] {
|
||||
// The hint still names the raw stream, but the probe sees the CAF marker first.
|
||||
assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3);
|
||||
assert!(matches!(probe(&f, Some("mp3")), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
assert!(matches!(decode(&f, Some("mp3")), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
// Plain leading junk without format magic is tolerated, as before.
|
||||
let f = [vec![0u8; 32], mp3].concat();
|
||||
assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3);
|
||||
let info = probe(&f, Some("mp3")).expect("junk before a valid MP3");
|
||||
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2));
|
||||
}
|
||||
|
||||
/// More false `0xFFEx` pairs than the preflight's scoring budget must not make a later container
|
||||
/// magic inside the valid audio payload authoritative. Past the cap the scan is unchecked and the
|
||||
/// probe selects the MPEG reader at the first real frame.
|
||||
#[test]
|
||||
fn an_exhausted_audio_score_budget_does_not_reject_the_mp3_behind_it() {
|
||||
let mut frame = vec![0u8; 417];
|
||||
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
|
||||
let mut audio: Vec<u8> = (0..8).flat_map(|_| frame.clone()).collect();
|
||||
let at = 417 * 2 + 10; // Inside frame 2's payload, leaving every frame header intact.
|
||||
audio[at..at + 8].copy_from_slice(b"caff\0\x01\0\0");
|
||||
audio[at + 8..at + 12].copy_from_slice(b"kuki");
|
||||
audio[at + 12..at + 20].copy_from_slice(&(1u64 << 50).to_be_bytes());
|
||||
let false_syncs: Vec<u8> = (0..256).flat_map(|_| [0xFFu8, 0xE2]).collect();
|
||||
let f = [false_syncs, audio].concat();
|
||||
assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3);
|
||||
let info = probe(&f, Some("mp3")).expect("valid MP3 after an exhausted audio score budget");
|
||||
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2));
|
||||
let (decoded, buf) = decode(&f, Some("mp3")).expect("decode the same MP3");
|
||||
assert_eq!((decoded.format, decoded.sample_rate, decoded.channels), (FileFormat::Mp3, 44_100, 2));
|
||||
assert_eq!(decoded.frames, buf.frames() as u64);
|
||||
}
|
||||
|
||||
/// Past the score budget the preflight does not run, so if the probe then selects a container
|
||||
/// whose reader allocates from sizes in the file, the post-probe refusal still applies.
|
||||
#[test]
|
||||
fn a_container_selected_without_its_size_check_is_refused() {
|
||||
let flac = small(FileFormat::Flac, BitDepth::Int16, 1);
|
||||
// Exactly the cap of false candidates, then one more that the reader rejects (invalid
|
||||
// bit-rate), so the preflight stops unchecked before reaching the FLAC marker behind it.
|
||||
let mut false_syncs: Vec<u8> = (0..256).flat_map(|_| [0xFFu8, 0xE2]).collect();
|
||||
false_syncs.extend_from_slice(&[0xFF, 0xFB, 0xF0, 0x00]);
|
||||
let f = [false_syncs, flac].concat();
|
||||
assert_eq!(detect_format(&f, None), FileFormat::Mp3);
|
||||
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("was not size-checked")));
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("was not size-checked")));
|
||||
}
|
||||
|
||||
/// A zero-frame FLAC after arbitrary bytes or ID3 tags is still detected and reported as an empty stream, not a
|
||||
/// decode failure.
|
||||
#[test]
|
||||
fn zero_frame_flac_after_a_prefix_is_still_empty() {
|
||||
let flac =
|
||||
encode(&AudioBuffer::new(48_000, 1, 0), &EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, dither: false, bwf: None })
|
||||
.unwrap();
|
||||
let tag = id3_tag(b"tag");
|
||||
for (f, detected) in [
|
||||
([vec![0x7A; 64], flac.clone()].concat(), FileFormat::Other),
|
||||
([tag.clone(), vec![0x7A; 16], flac.clone()].concat(), FileFormat::Mp3),
|
||||
([tag.clone(), tag, flac.clone()].concat(), FileFormat::Mp3),
|
||||
] {
|
||||
assert_eq!(detect_format(&f, None), detected);
|
||||
let info = probe(&f, None).expect("empty FLAC after a prefix");
|
||||
assert_eq!((info.format, info.sample_rate, info.channels, info.frames), (FileFormat::Flac, 48_000, 1, 0));
|
||||
assert_eq!(decode(&f, None).unwrap().1.frames(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one MPEG-4 AAC-LC ADTS frame at 48 kHz stereo around `payload`, with the 13-bit length covering the 7-byte
|
||||
/// header.
|
||||
fn adts_frame(payload: &[u8]) -> Vec<u8> {
|
||||
// MPEG-4 AAC-LC, 48 kHz, stereo; the 13-bit frame length includes this 7-byte header.
|
||||
let len = 7 + payload.len();
|
||||
let mut h = [0xFF, 0xF1, 0x4C, 0x00, 0x00, 0x1F, 0xFC];
|
||||
h[3] = 0x80 | ((len >> 11) & 0x03) as u8; // stereo channel config low bits
|
||||
h[4] = ((len >> 3) & 0xFF) as u8;
|
||||
h[5] = (((len & 0x07) as u8) << 5) | 0x1F;
|
||||
[&h[..], payload].concat()
|
||||
}
|
||||
|
||||
/// False MPEG sync prefixes must not stop a following zero-frame FLAC from probing as empty.
|
||||
#[test]
|
||||
fn zero_frame_flac_after_a_false_mpeg_sync_is_still_empty() {
|
||||
let flac =
|
||||
encode(&AudioBuffer::new(48_000, 1, 0), &EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, dither: false, bwf: None })
|
||||
.unwrap();
|
||||
for junk in [vec![0xFF, 0xE0, 0x00], vec![0xFF, 0xF0, 0x00], vec![0xFF, 0xFB, 0xF0, 0x00]] {
|
||||
let f = [junk, flac.clone()].concat();
|
||||
let info = probe(&f, None).expect("empty FLAC after a false sync");
|
||||
assert_eq!((info.format, info.sample_rate, info.channels, info.frames), (FileFormat::Flac, 48_000, 1, 0));
|
||||
assert_eq!(decode(&f, None).unwrap().1.frames(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A false MPEG sync prefix must not let malformed CAF or MP4 size fields bypass the container preflight.
|
||||
#[test]
|
||||
fn a_false_sync_does_not_bypass_container_checks() {
|
||||
let bad_caf = caf(&[
|
||||
caf_desc(b"aac ", 0, 1024, 2, 0),
|
||||
(b"pakt", 32, packet_table(1, 1024, &[0x82, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0])),
|
||||
(b"data", -1, vec![0; 64]),
|
||||
]);
|
||||
assert!(matches!(probe(&bad_caf, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
let f = [vec![0xFF, 0xE0, 0x00], bad_caf].concat();
|
||||
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 pcm = sample_entry(b"sowt", 0, &[], &[]);
|
||||
let bad_mp4 = mp4(480, &pcm, |kind, mut a| {
|
||||
if kind == b"stsz" {
|
||||
a[12..16].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
}
|
||||
a
|
||||
});
|
||||
assert!(probe(&bad_mp4, None).is_err());
|
||||
let f = [vec![0xFF, 0xE0, 0x00], bad_mp4].concat();
|
||||
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")));
|
||||
}
|
||||
|
||||
/// Container magic inside a valid ADTS payload must not override AAC detection or make probing fail.
|
||||
#[test]
|
||||
fn a_valid_adts_stream_with_incidental_container_magic_still_probes() {
|
||||
let payload = [0u8; 400];
|
||||
let mut frames: Vec<Vec<u8>> = (0..8).map(|_| adts_frame(&payload)).collect();
|
||||
let at = 100; // Inside frame 2's payload, leaving every frame header intact.
|
||||
frames[2][7 + at..7 + at + 12].copy_from_slice(b"caff\0\x01\0\0kuki");
|
||||
let f: Vec<u8> = frames.concat();
|
||||
assert_eq!(detect_format(&f, None), FileFormat::Aac);
|
||||
let info = probe(&f, None).expect("valid ADTS with incidental container magic");
|
||||
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Aac, 48_000, 2));
|
||||
}
|
||||
|
||||
/// A later valid CAF must not excuse an earlier malformed container that the preflight or probe rejects.
|
||||
#[test]
|
||||
fn an_earlier_malformed_container_is_not_excused_by_a_later_valid_one() {
|
||||
let malformed = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"kuki", 1i64 << 50, vec![0; 8])]);
|
||||
let pcm: Vec<u8> = (0..32i16).flat_map(|i| (i * 50).to_be_bytes()).collect();
|
||||
let data = [vec![0; 4], pcm].concat();
|
||||
let valid = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"data", data.len() as i64, data)]);
|
||||
let f = [vec![0u8; 32], malformed, valid].concat();
|
||||
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")));
|
||||
}
|
||||
|
||||
/// Truncated ID3 bodies, declared tag sizes beyond the file, and a complete tag with no audio after it are malformed
|
||||
/// for both probe and decode.
|
||||
#[test]
|
||||
fn truncated_or_oversized_id3_tags_are_malformed() {
|
||||
let cases = vec![[&id3_header(100)[..], b"xx"].concat(), id3_header(0x0FFF_FFFF).to_vec(), id3_tag(b"tag")];
|
||||
for f in cases {
|
||||
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(_))));
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[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))];
|
||||
for _ in 0..200_000 {
|
||||
chunks.push((b"pad ", Vec::new()));
|
||||
}
|
||||
let wav = riff(&chunks);
|
||||
assert!(decode(&wav, None).is_err()); // no data chunk within the scan limit
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_built_wav_variants() {
|
||||
// 8-bit unsigned.
|
||||
let wav = riff(&[(b"fmt ", fmt(1, 1, 8_000, 8)), (b"data", vec![0x80, 0xFF, 0x00])]);
|
||||
let (info, buf) = decode(&wav, None).unwrap();
|
||||
assert_eq!(info.sample_format, SampleFormat::Int8);
|
||||
assert_eq!(buf.channels[0], vec![0.0, 127.0 / 128.0, -1.0]);
|
||||
// 64-bit float, with NaN replaced by silence.
|
||||
let mut data = Vec::new();
|
||||
for v in [0.25f64, -0.5, f64::NAN] {
|
||||
data.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
let wav = riff(&[(b"fmt ", fmt(3, 1, 8_000, 64)), (b"data", data)]);
|
||||
let (info, buf) = decode(&wav, None).unwrap();
|
||||
assert_eq!(info.sample_format, SampleFormat::Float64);
|
||||
assert_eq!(buf.channels[0], vec![0.25, -0.5, 0.0]);
|
||||
// Unsupported PCM width.
|
||||
let wav = riff(&[(b"fmt ", fmt(3, 1, 8_000, 16)), (b"data", vec![0; 4])]);
|
||||
assert!(matches!(decode(&wav, None), Err(AudioError::Unsupported(_))));
|
||||
// No data chunk / no fmt chunk.
|
||||
assert!(decode(&riff(&[(b"fmt ", fmt(1, 1, 8_000, 16))]), None).is_err());
|
||||
assert!(decode(&riff(&[(b"data", vec![0; 4])]), None).is_err());
|
||||
// Short fmt chunk.
|
||||
assert!(decode(&riff(&[(b"fmt ", vec![1, 0, 1]), (b"data", vec![0; 4])]), None).is_err());
|
||||
// Unknown codec tag goes to symphonia and fails cleanly.
|
||||
let wav = riff(&[(b"fmt ", fmt(0x1234, 1, 8_000, 16)), (b"data", vec![0; 4])]);
|
||||
assert!(decode(&wav, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_sowt_little_endian() {
|
||||
let be = small(FileFormat::Aiff, BitDepth::Int16, 2);
|
||||
let (_, expect) = decode(&be, None).unwrap();
|
||||
// Rebuild as AIFC/sowt by hand.
|
||||
let comm = be.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
let ssnd = be.windows(4).position(|w| w == b"SSND").unwrap();
|
||||
let mut comm_body = be[comm + 8..comm + 26].to_vec();
|
||||
comm_body.extend_from_slice(b"sowt");
|
||||
comm_body.extend_from_slice(&[0, 0]); // empty pstring, padded
|
||||
let mut ssnd_body = be[ssnd + 8..].to_vec();
|
||||
for pair in ssnd_body[8..].as_chunks_mut::<2>().0 {
|
||||
pair.swap(0, 1);
|
||||
}
|
||||
let mut body = b"AIFC".to_vec();
|
||||
for (id, data) in [(b"COMM", &comm_body), (b"SSND", &ssnd_body)] {
|
||||
body.extend_from_slice(id);
|
||||
body.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
body.extend_from_slice(data);
|
||||
}
|
||||
let mut f = b"FORM".to_vec();
|
||||
f.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
f.extend_from_slice(&body);
|
||||
let (info, got) = decode(&f, None).unwrap();
|
||||
assert_eq!(info.sample_format, SampleFormat::Int16);
|
||||
assert_eq!(got, expect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_format_by_magic_and_hint() {
|
||||
assert_eq!(detect_format(&small(FileFormat::Wav, BitDepth::Int16, 1), Some("mp3")), FileFormat::Wav);
|
||||
assert_eq!(detect_format(&small(FileFormat::Aiff, BitDepth::Int16, 1), None), FileFormat::Aiff);
|
||||
assert_eq!(detect_format(&small(FileFormat::Flac, BitDepth::Int16, 1), None), FileFormat::Flac);
|
||||
assert_eq!(detect_format(b"OggS\0\x02rest", None), FileFormat::Ogg);
|
||||
assert_eq!(detect_format(b"caff\0\x01\0\0", None), FileFormat::Caf);
|
||||
assert_eq!(detect_format(b"ID3\x04\0\0\0\0\0\x00", None), FileFormat::Mp3);
|
||||
let mut id3_flac = b"ID3\x04\0\0\0\0\0\x02xx".to_vec();
|
||||
id3_flac.extend_from_slice(b"fLaC");
|
||||
assert_eq!(detect_format(&id3_flac, None), FileFormat::Flac);
|
||||
assert_eq!(detect_format(&[0xFF, 0xFB, 0x90, 0x00], None), FileFormat::Mp3);
|
||||
assert_eq!(detect_format(&[0xFF, 0xF1, 0x50, 0x80], None), FileFormat::Aac);
|
||||
let mut m4a = b"\0\0\0\x20ftypM4A ".to_vec();
|
||||
m4a.extend_from_slice(b"....stsd\0\0\0\0\0\0\0\x01\0\0\0\x24alac");
|
||||
assert_eq!(detect_format(&m4a, None), FileFormat::Alac);
|
||||
assert_eq!(detect_format(b"\0\0\0\x20ftypM4A ", None), FileFormat::Aac);
|
||||
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);
|
||||
assert_eq!(detect_format(b"", None), FileFormat::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_in_compressed_containers_errors() {
|
||||
let garbage: Vec<u8> = (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
|
||||
for hint in [None, Some("mp3"), Some("ogg"), Some("flac"), Some("m4a"), Some("caf"), Some("aac"), Some("mkv"), Some("wav"), Some("aiff")] {
|
||||
assert!(decode(&garbage, hint).is_err(), "{hint:?}");
|
||||
assert!(probe(&garbage, hint).is_err(), "{hint:?}");
|
||||
}
|
||||
for magic in [&b"fLaC"[..], b"OggS", b"caff", b"ID3\x03\0\0\0\0\0\0", &[0xFF, 0xFB, 0x90, 0x00], b"\0\0\0\x20ftypM4A "] {
|
||||
let mut f = magic.to_vec();
|
||||
f.extend_from_slice(&garbage);
|
||||
let _ = probe(&f, None);
|
||||
assert!(decode(&f, None).map(|(_, b)| b.frames()).unwrap_or(0) < 1_000_000);
|
||||
}
|
||||
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() })]
|
||||
|
||||
#[test]
|
||||
fn random_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..2048), hint in 0usize..6) {
|
||||
let hint = [None, Some("wav"), Some("aiff"), Some("flac"), Some("mp3"), Some("ogg")][hint];
|
||||
let _ = detect_format(&bytes, hint);
|
||||
let _ = probe(&bytes, hint);
|
||||
let _ = decode(&bytes, hint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bodies_behind_valid_headers_never_panic(body in proptest::collection::vec(any::<u8>(), 0..1024), which in 0usize..4) {
|
||||
let mut f = match which {
|
||||
0 => b"RIFF\xff\xff\xff\xffWAVE".to_vec(),
|
||||
1 => b"RF64\xff\xff\xff\xffWAVE".to_vec(),
|
||||
2 => b"FORM\xff\xff\xff\xffAIFC".to_vec(),
|
||||
_ => b"FORM\xff\xff\xff\xffAIFF".to_vec(),
|
||||
};
|
||||
f.extend_from_slice(&body);
|
||||
let _ = probe(&f, None);
|
||||
let _ = decode(&f, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_audio_roundtrips_through_flac(samples in proptest::collection::vec(-1.2f32..1.2, 1..3000), channels in 1usize..4) {
|
||||
let buf = AudioBuffer::from_interleaved(48_000, channels, &samples);
|
||||
prop_assume!(buf.frames() > 0);
|
||||
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int24, dither: false, bwf: None };
|
||||
let (_, flac) = decode(&encode(&buf, &opts).unwrap(), None).unwrap();
|
||||
let (_, wav) = decode(&encode(&buf, &EncodeOptions { format: FileFormat::Wav, ..opts }).unwrap(), None).unwrap();
|
||||
prop_assert_eq!(flac, wav);
|
||||
}
|
||||
}
|
||||
|
||||
/// An extensible `fmt ` chunk: cbSize, valid bits, mask, then the 16-byte subtype GUID.
|
||||
fn extensible_fmt(cb_size: u16, valid_bits: u16, guid: &[u8]) -> Vec<u8> {
|
||||
let mut f = fmt(0xFFFE, 1, 44_100, 16);
|
||||
f.extend_from_slice(&cb_size.to_le_bytes());
|
||||
f.extend_from_slice(&valid_bits.to_le_bytes());
|
||||
f.extend_from_slice(&4u32.to_le_bytes());
|
||||
f.extend_from_slice(guid);
|
||||
f
|
||||
}
|
||||
|
||||
const PCM_GUID: [u8; 16] = [1, 0, 0, 0, 0, 0, 0x10, 0, 0x80, 0, 0, 0xAA, 0, 0x38, 0x9B, 0x71];
|
||||
|
||||
#[test]
|
||||
fn extensible_fmt_is_checked_in_full() {
|
||||
let data = vec![0u8; 8];
|
||||
let good = riff(&[(b"fmt ", extensible_fmt(22, 16, &PCM_GUID)), (b"data", data.clone())]);
|
||||
assert_eq!(probe(&good, None).unwrap().sample_format, SampleFormat::Int16);
|
||||
// Subtype GUID cut short after the format code.
|
||||
let cut = riff(&[(b"fmt ", extensible_fmt(22, 16, &PCM_GUID[..4])), (b"data", data.clone())]);
|
||||
assert!(matches!(probe(&cut, None), Err(AudioError::Malformed(_))));
|
||||
assert!(matches!(decode(&cut, None), Err(AudioError::Malformed(_))));
|
||||
// cbSize too small for the extension.
|
||||
let small_cb = riff(&[(b"fmt ", extensible_fmt(0, 16, &PCM_GUID)), (b"data", data.clone())]);
|
||||
assert!(matches!(probe(&small_cb, None), Err(AudioError::Malformed(_))));
|
||||
// More valid bits than the 16-bit container holds.
|
||||
let wide = riff(&[(b"fmt ", extensible_fmt(22, 24, &PCM_GUID)), (b"data", data.clone())]);
|
||||
assert!(matches!(probe(&wide, None), Err(AudioError::Malformed(_))));
|
||||
// PCM-like prefix but a foreign GUID tail is not read as native PCM.
|
||||
let mut foreign = PCM_GUID;
|
||||
foreign[15] ^= 0xFF;
|
||||
let foreign = riff(&[(b"fmt ", extensible_fmt(22, 16, &foreign)), (b"data", data)]);
|
||||
assert!(!matches!(probe(&foreign, None), Ok(i) if i.sample_format == SampleFormat::Int16));
|
||||
// The Ambisonic PCM subtype is PCM.
|
||||
let ambi = [1, 0, 0, 0, 0x21, 0x07, 0xD3, 0x11, 0x86, 0x44, 0xC8, 0xC1, 0xCA, 0, 0, 0];
|
||||
let ambi = riff(&[(b"fmt ", extensible_fmt(22, 16, &ambi)), (b"data", vec![0u8; 8])]);
|
||||
assert_eq!(probe(&ambi, None).unwrap().sample_format, SampleFormat::Int16);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use soundcraft_audio_io::{
|
||||
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe,
|
||||
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe, wav_float_header,
|
||||
};
|
||||
|
||||
fn sine(sr: u32, channels: usize, frames: usize) -> AudioBuffer {
|
||||
@@ -205,7 +205,7 @@ fn encode_rejects_bad_buffers_and_formats() {
|
||||
for f in [FileFormat::Mp3, FileFormat::Ogg, FileFormat::Aac, FileFormat::Alac, FileFormat::Caf, FileFormat::Other] {
|
||||
assert!(encode(&sine(48_000, 1, 10), &EncodeOptions { format: f, ..EncodeOptions::default() }).is_err());
|
||||
}
|
||||
// Non-finite and out-of-range samples are clamped, not errors.
|
||||
// Non-finite samples are sanitized; only integer output clips finite samples.
|
||||
let mut b = AudioBuffer::new(48_000, 1, 4);
|
||||
b.channels[0] = vec![f32::NAN, f32::INFINITY, -5.0, 0.5];
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
@@ -217,7 +217,7 @@ fn encode_rejects_bad_buffers_and_formats() {
|
||||
let (_, out) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(out.channels[0][0], 0.0);
|
||||
assert!(out.channels[0][1] > 0.99 && out.channels[0][1] <= 1.0);
|
||||
assert_eq!(out.channels[0][2], -1.0);
|
||||
assert_eq!(out.channels[0][2], if depth == BitDepth::Float32 { -5.0 } else { -1.0 });
|
||||
}
|
||||
}
|
||||
// Ragged buffers encode the common length.
|
||||
@@ -268,3 +268,51 @@ fn multichannel_wav_speaker_masks() {
|
||||
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(2), &o16, 0x3).unwrap();
|
||||
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_wav_header_is_rewritten_in_place_as_the_file_grows() {
|
||||
let take = sine(48_000, 2, 1_000);
|
||||
let mut file = wav_float_header(2, 48_000, 0).unwrap();
|
||||
let len = file.len();
|
||||
file.extend(take.interleaved().iter().flat_map(|s| s.to_le_bytes()));
|
||||
assert_eq!(decode(&file, None).unwrap().1.frames(), 1_000);
|
||||
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 600).unwrap());
|
||||
assert_eq!(decode(&file, None).unwrap().1.frames(), 600);
|
||||
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 1_000).unwrap());
|
||||
assert_eq!(file, encode(&take, &EncodeOptions { bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap());
|
||||
let big = wav_float_header(2, 48_000, 600_000_000).unwrap();
|
||||
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);
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
//! Hostile inputs must produce `Err` (or a best-effort `Ok`), never a panic.
|
||||
|
||||
use proptest::prelude::*;
|
||||
use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, probe};
|
||||
|
||||
fn small(format: FileFormat, depth: BitDepth, channels: usize) -> Vec<u8> {
|
||||
let mut b = AudioBuffer::new(44_100, channels, 300);
|
||||
for ch in &mut b.channels {
|
||||
for (i, s) in ch.iter_mut().enumerate() {
|
||||
*s = ((i as f32) * 0.1).sin() * 0.5;
|
||||
}
|
||||
}
|
||||
encode(&b, &EncodeOptions { format, bit_depth: depth, dither: false, bwf: None }).unwrap()
|
||||
}
|
||||
|
||||
/// Little helper for building RIFF files by hand.
|
||||
fn riff(chunks: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
|
||||
let mut body = b"WAVE".to_vec();
|
||||
for (id, data) in chunks {
|
||||
body.extend_from_slice(*id);
|
||||
body.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
body.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
body.push(0);
|
||||
}
|
||||
}
|
||||
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 fmt(tag: u16, channels: u16, sr: u32, bits: u16) -> Vec<u8> {
|
||||
let align = channels * bits.div_ceil(8);
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(&tag.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(&bits.to_le_bytes());
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_truncation_is_handled() {
|
||||
let files = [
|
||||
small(FileFormat::Wav, BitDepth::Int16, 2),
|
||||
small(FileFormat::Wav, BitDepth::Float32, 6),
|
||||
small(FileFormat::Aiff, BitDepth::Int24, 1),
|
||||
small(FileFormat::Aiff, BitDepth::Float32, 2),
|
||||
small(FileFormat::Flac, BitDepth::Int16, 2),
|
||||
small(FileFormat::Flac, BitDepth::Int24, 1),
|
||||
];
|
||||
for file in &files {
|
||||
for n in 0..file.len() {
|
||||
let cut = &file[..n];
|
||||
let _ = probe(cut, None);
|
||||
if let Ok((info, buf)) = decode(cut, None) {
|
||||
assert_eq!(buf.num_channels(), info.channels as usize);
|
||||
assert_eq!(buf.frames() as u64, info.frames);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_data_chunk_yields_partial_audio() {
|
||||
let file = small(FileFormat::Wav, BitDepth::Int16, 2);
|
||||
let (info, buf) = decode(&file[..file.len() - 400], None).unwrap();
|
||||
assert_eq!(info.frames, 200);
|
||||
assert_eq!(buf.frames(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_single_byte_corruption_is_handled() {
|
||||
for file in [small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Aiff, BitDepth::Int16, 2), small(FileFormat::Flac, BitDepth::Int16, 1)]
|
||||
{
|
||||
for i in 0..file.len().min(200) {
|
||||
for v in [0x00, 0xFF, 0x7F, 0x80] {
|
||||
let mut f = file.clone();
|
||||
f[i] = v;
|
||||
let _ = probe(&f, None);
|
||||
let _ = decode(&f, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_channels_is_malformed() {
|
||||
let wav = riff(&[(b"fmt ", fmt(1, 0, 44_100, 16)), (b"data", vec![0; 16])]);
|
||||
assert!(matches!(decode(&wav, None), Err(AudioError::Malformed(_))));
|
||||
assert!(probe(&wav, None).is_err());
|
||||
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
|
||||
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
aiff[comm + 8] = 0;
|
||||
aiff[comm + 9] = 0;
|
||||
assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_sample_rate_is_malformed() {
|
||||
let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]);
|
||||
assert!(decode(&wav, None).is_err());
|
||||
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
|
||||
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
for b in &mut aiff[comm + 16..comm + 26] {
|
||||
*b = 0;
|
||||
}
|
||||
assert!(decode(&aiff, None).is_err());
|
||||
// Infinite exponent.
|
||||
aiff[comm + 16] = 0x7F;
|
||||
aiff[comm + 17] = 0xFF;
|
||||
assert!(decode(&aiff, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absurd_chunk_sizes() {
|
||||
// fmt chunk claims 4 GiB.
|
||||
let mut wav = riff(&[(b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
|
||||
wav[16..20].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(decode(&wav, None).is_err());
|
||||
// data chunk claims 4 GiB: lenient read of what's there.
|
||||
let mut wav = riff(&[(b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
|
||||
let d = wav.windows(4).position(|w| w == b"data").unwrap();
|
||||
wav[d + 4..d + 8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let (info, buf) = decode(&wav, None).unwrap();
|
||||
assert_eq!((info.frames, buf.frames()), (4, 4));
|
||||
// An unknown chunk with an absurd size before fmt.
|
||||
let mut wav = riff(&[(b"junk", vec![0; 4]), (b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
|
||||
wav[16..20].copy_from_slice(&0xFFFF_FFF0u32.to_le_bytes());
|
||||
assert!(decode(&wav, None).is_err());
|
||||
// RF64 with a ds64 claiming an exabyte of data.
|
||||
let mut ds64 = Vec::new();
|
||||
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
|
||||
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
|
||||
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
|
||||
ds64.extend_from_slice(&0u32.to_le_bytes());
|
||||
let mut rf = riff(&[(b"ds64", ds64), (b"fmt ", fmt(1, 1, 8_000, 8)), (b"data", vec![0x80; 10])]);
|
||||
rf[..4].copy_from_slice(b"RF64");
|
||||
let d = rf.windows(4).rposition(|w| w == b"data").unwrap();
|
||||
rf[d + 4..d + 8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let (info, buf) = decode(&rf, None).unwrap();
|
||||
assert_eq!((info.frames, buf.frames()), (10, 10));
|
||||
// AIFF COMM claims more frames than present; SSND offset beyond the end.
|
||||
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
|
||||
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
aiff[comm + 10..comm + 14].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(decode(&aiff, None).unwrap().1.frames(), 300);
|
||||
let ssnd = aiff.windows(4).position(|w| w == b"SSND").unwrap();
|
||||
aiff[ssnd + 8..ssnd + 12].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(decode(&aiff, None).unwrap().1.frames(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_tiny_chunks_terminate() {
|
||||
let mut chunks: Vec<(&[u8; 4], Vec<u8>)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))];
|
||||
for _ in 0..200_000 {
|
||||
chunks.push((b"pad ", Vec::new()));
|
||||
}
|
||||
let wav = riff(&chunks);
|
||||
assert!(decode(&wav, None).is_err()); // no data chunk within the scan limit
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_built_wav_variants() {
|
||||
// 8-bit unsigned.
|
||||
let wav = riff(&[(b"fmt ", fmt(1, 1, 8_000, 8)), (b"data", vec![0x80, 0xFF, 0x00])]);
|
||||
let (info, buf) = decode(&wav, None).unwrap();
|
||||
assert_eq!(info.sample_format, SampleFormat::Int8);
|
||||
assert_eq!(buf.channels[0], vec![0.0, 127.0 / 128.0, -1.0]);
|
||||
// 64-bit float, with NaN replaced by silence.
|
||||
let mut data = Vec::new();
|
||||
for v in [0.25f64, -0.5, f64::NAN] {
|
||||
data.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
let wav = riff(&[(b"fmt ", fmt(3, 1, 8_000, 64)), (b"data", data)]);
|
||||
let (info, buf) = decode(&wav, None).unwrap();
|
||||
assert_eq!(info.sample_format, SampleFormat::Float64);
|
||||
assert_eq!(buf.channels[0], vec![0.25, -0.5, 0.0]);
|
||||
// Unsupported PCM width.
|
||||
let wav = riff(&[(b"fmt ", fmt(3, 1, 8_000, 16)), (b"data", vec![0; 4])]);
|
||||
assert!(matches!(decode(&wav, None), Err(AudioError::Unsupported(_))));
|
||||
// No data chunk / no fmt chunk.
|
||||
assert!(decode(&riff(&[(b"fmt ", fmt(1, 1, 8_000, 16))]), None).is_err());
|
||||
assert!(decode(&riff(&[(b"data", vec![0; 4])]), None).is_err());
|
||||
// Short fmt chunk.
|
||||
assert!(decode(&riff(&[(b"fmt ", vec![1, 0, 1]), (b"data", vec![0; 4])]), None).is_err());
|
||||
// Unknown codec tag goes to symphonia and fails cleanly.
|
||||
let wav = riff(&[(b"fmt ", fmt(0x1234, 1, 8_000, 16)), (b"data", vec![0; 4])]);
|
||||
assert!(decode(&wav, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_sowt_little_endian() {
|
||||
let be = small(FileFormat::Aiff, BitDepth::Int16, 2);
|
||||
let (_, expect) = decode(&be, None).unwrap();
|
||||
// Rebuild as AIFC/sowt by hand.
|
||||
let comm = be.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
let ssnd = be.windows(4).position(|w| w == b"SSND").unwrap();
|
||||
let mut comm_body = be[comm + 8..comm + 26].to_vec();
|
||||
comm_body.extend_from_slice(b"sowt");
|
||||
comm_body.extend_from_slice(&[0, 0]); // empty pstring, padded
|
||||
let mut ssnd_body = be[ssnd + 8..].to_vec();
|
||||
for pair in ssnd_body[8..].as_chunks_mut::<2>().0 {
|
||||
pair.swap(0, 1);
|
||||
}
|
||||
let mut body = b"AIFC".to_vec();
|
||||
for (id, data) in [(b"COMM", &comm_body), (b"SSND", &ssnd_body)] {
|
||||
body.extend_from_slice(id);
|
||||
body.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
body.extend_from_slice(data);
|
||||
}
|
||||
let mut f = b"FORM".to_vec();
|
||||
f.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
f.extend_from_slice(&body);
|
||||
let (info, got) = decode(&f, None).unwrap();
|
||||
assert_eq!(info.sample_format, SampleFormat::Int16);
|
||||
assert_eq!(got, expect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_format_by_magic_and_hint() {
|
||||
assert_eq!(detect_format(&small(FileFormat::Wav, BitDepth::Int16, 1), Some("mp3")), FileFormat::Wav);
|
||||
assert_eq!(detect_format(&small(FileFormat::Aiff, BitDepth::Int16, 1), None), FileFormat::Aiff);
|
||||
assert_eq!(detect_format(&small(FileFormat::Flac, BitDepth::Int16, 1), None), FileFormat::Flac);
|
||||
assert_eq!(detect_format(b"OggS\0\x02rest", None), FileFormat::Ogg);
|
||||
assert_eq!(detect_format(b"caff\0\x01\0\0", None), FileFormat::Caf);
|
||||
assert_eq!(detect_format(b"ID3\x04\0\0\0\0\0\x00", None), FileFormat::Mp3);
|
||||
let mut id3_flac = b"ID3\x04\0\0\0\0\0\x02xx".to_vec();
|
||||
id3_flac.extend_from_slice(b"fLaC");
|
||||
assert_eq!(detect_format(&id3_flac, None), FileFormat::Flac);
|
||||
assert_eq!(detect_format(&[0xFF, 0xFB, 0x90, 0x00], None), FileFormat::Mp3);
|
||||
assert_eq!(detect_format(&[0xFF, 0xF1, 0x50, 0x80], None), FileFormat::Aac);
|
||||
let mut m4a = b"\0\0\0\x20ftypM4A ".to_vec();
|
||||
m4a.extend_from_slice(b"....stsd\0\0\0\0\0\0\0\x01\0\0\0\x24alac");
|
||||
assert_eq!(detect_format(&m4a, None), FileFormat::Alac);
|
||||
assert_eq!(detect_format(b"\0\0\0\x20ftypM4A ", None), FileFormat::Aac);
|
||||
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("flac")), FileFormat::Flac);
|
||||
assert_eq!(detect_format(b"zz", Some("xyz")), FileFormat::Other);
|
||||
assert_eq!(detect_format(b"zz", None), FileFormat::Other);
|
||||
assert_eq!(detect_format(b"", None), FileFormat::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_in_compressed_containers_errors() {
|
||||
let garbage: Vec<u8> = (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
|
||||
for hint in [None, Some("mp3"), Some("ogg"), Some("flac"), Some("m4a"), Some("caf"), Some("aac"), Some("mkv"), Some("wav"), Some("aiff")] {
|
||||
assert!(decode(&garbage, hint).is_err(), "{hint:?}");
|
||||
assert!(probe(&garbage, hint).is_err(), "{hint:?}");
|
||||
}
|
||||
for magic in [&b"fLaC"[..], b"OggS", b"caff", b"ID3\x03\0\0\0\0\0\0", &[0xFF, 0xFB, 0x90, 0x00], b"\0\0\0\x20ftypM4A "] {
|
||||
let mut f = magic.to_vec();
|
||||
f.extend_from_slice(&garbage);
|
||||
let _ = probe(&f, None);
|
||||
assert!(decode(&f, None).map(|(_, b)| b.frames()).unwrap_or(0) < 1_000_000);
|
||||
}
|
||||
assert!(decode(&[], None).is_err());
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
|
||||
|
||||
#[test]
|
||||
fn random_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..2048), hint in 0usize..6) {
|
||||
let hint = [None, Some("wav"), Some("aiff"), Some("flac"), Some("mp3"), Some("ogg")][hint];
|
||||
let _ = detect_format(&bytes, hint);
|
||||
let _ = probe(&bytes, hint);
|
||||
let _ = decode(&bytes, hint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bodies_behind_valid_headers_never_panic(body in proptest::collection::vec(any::<u8>(), 0..1024), which in 0usize..4) {
|
||||
let mut f = match which {
|
||||
0 => b"RIFF\xff\xff\xff\xffWAVE".to_vec(),
|
||||
1 => b"RF64\xff\xff\xff\xffWAVE".to_vec(),
|
||||
2 => b"FORM\xff\xff\xff\xffAIFC".to_vec(),
|
||||
_ => b"FORM\xff\xff\xff\xffAIFF".to_vec(),
|
||||
};
|
||||
f.extend_from_slice(&body);
|
||||
let _ = probe(&f, None);
|
||||
let _ = decode(&f, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_audio_roundtrips_through_flac(samples in proptest::collection::vec(-1.2f32..1.2, 1..3000), channels in 1usize..4) {
|
||||
let buf = AudioBuffer::from_interleaved(48_000, channels, &samples);
|
||||
prop_assume!(buf.frames() > 0);
|
||||
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int24, dither: false, bwf: None };
|
||||
let (_, flac) = decode(&encode(&buf, &opts).unwrap(), None).unwrap();
|
||||
let (_, wav) = decode(&encode(&buf, &EncodeOptions { format: FileFormat::Wav, ..opts }).unwrap(), None).unwrap();
|
||||
prop_assert_eq!(flac, wav);
|
||||
}
|
||||
}
|
||||
+103
-20
@@ -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::net::{TcpStream, ToSocketAddrs};
|
||||
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
|
||||
}
|
||||
@@ -82,46 +97,67 @@ impl Remote {
|
||||
if self.conn.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let sock = self.addr.parse().map_err(|e| format!("{}: {e}", self.addr))?;
|
||||
let s =
|
||||
TcpStream::connect_timeout(&sock, Duration::from_millis(800)).map_err(|e| format!("cannot reach SoundCraft at {}: {e}", self.addr))?;
|
||||
let socks: Vec<_> = self.addr.to_socket_addrs().map_err(|e| format!("{}: {e}", self.addr))?.collect();
|
||||
let mut last = "no addresses found".to_string();
|
||||
let mut stream = None;
|
||||
for sock in &socks {
|
||||
match TcpStream::connect_timeout(sock, Duration::from_millis(800)) {
|
||||
Ok(s) => {
|
||||
stream = Some(s);
|
||||
break;
|
||||
}
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
}
|
||||
let s = stream.ok_or_else(|| format!("cannot reach SoundCraft at {}: {last}", self.addr))?;
|
||||
s.set_read_timeout(Some(Duration::from_secs(120))).map_err(|e| e.to_string())?;
|
||||
let r = s.try_clone().map_err(|e| e.to_string())?;
|
||||
self.conn = Some((s, BufReader::new(r)));
|
||||
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 +188,51 @@ mod tests {
|
||||
let mut r = Remote::new("127.0.0.1:1");
|
||||
assert!(r.call("session.inspect", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_resolves_hostnames() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let mut r = Remote::new(&format!("localhost:{port}"));
|
||||
assert_eq!(r.connect(), Ok(()));
|
||||
}
|
||||
|
||||
/// 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(
|
||||
@@ -54,7 +55,12 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
&[],
|
||||
),
|
||||
tool("open_session", "Open a .scraft session file.", json!({"path": {"type": "string"}}), &["path"]),
|
||||
tool("save_session", "Save the session (path required the first time).", json!({"path": {"type": "string"}}), &[]),
|
||||
tool(
|
||||
"save_session",
|
||||
"Save the session (path required the first time; replace: true to save over another existing file).",
|
||||
json!({"path": {"type": "string"}, "replace": {"type": "boolean"}}),
|
||||
&[],
|
||||
),
|
||||
tool(
|
||||
"import_audio",
|
||||
"Import an audio file (WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF) onto a new or existing track.",
|
||||
@@ -127,11 +133,23 @@ impl<B: Backend> Server<B> {
|
||||
}
|
||||
"batch" => {
|
||||
let keep = args.get("keep_going").and_then(Value::as_bool).unwrap_or(false);
|
||||
let calls = args.get("calls").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
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) {
|
||||
let cmd = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
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;
|
||||
if !keep {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let r = self.exec(cmd, c.get("params").cloned().unwrap_or(json!({})));
|
||||
match r {
|
||||
Ok(v) => out.push(json!({"command": cmd, "ok": true, "result": v})),
|
||||
@@ -144,7 +162,9 @@ impl<B: Backend> Server<B> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed && !keep { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) }
|
||||
// Any failure sets isError so agents that only check that flag notice;
|
||||
// keep_going still runs the rest and the payload lists every call.
|
||||
if failed { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) }
|
||||
}
|
||||
"inspect_session" => self.backend.call("session.inspect", json!({"detail": a("detail")})),
|
||||
"new_session" => self.exec("session.new", args.clone()),
|
||||
|
||||
@@ -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);
|
||||
@@ -135,3 +137,71 @@ fn errors_are_reported_not_panics() {
|
||||
let r = rpc(&mut s, 2, "tools/call", json!({"name": "screenshot", "arguments": {}}));
|
||||
assert_eq!(r["result"]["isError"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_keep_going_still_sets_is_error_on_partial_failure() {
|
||||
let mut s = Server::new(Headless::new(Engine::default()));
|
||||
let r = rpc(
|
||||
&mut s,
|
||||
1,
|
||||
"tools/call",
|
||||
json!({"name": "batch", "arguments": {"keep_going": true, "calls": [
|
||||
{"command": "track.new", "params": {"count": 1, "name": "Kept"}},
|
||||
{"command": "mix.volume", "params": {"track": "Nope", "db": -6}},
|
||||
{"command": "track.new", "params": {"count": 1, "name": "After"}},
|
||||
]}}),
|
||||
);
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
let text = r["result"]["content"][0]["text"].as_str().unwrap();
|
||||
let results: Value = serde_json::from_str(text).unwrap();
|
||||
let arr = results.as_array().unwrap();
|
||||
assert_eq!(arr.len(), 3, "{results}");
|
||||
assert_eq!(arr[0]["ok"], true);
|
||||
assert_eq!(arr[1]["ok"], false);
|
||||
assert_eq!(arr[2]["ok"], true);
|
||||
let sess = session(&mut s);
|
||||
assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "Kept"));
|
||||
assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "After"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_rejects_non_array_calls() {
|
||||
let mut s = Server::new(Headless::new(Engine::default()));
|
||||
let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": {"command": "track.new"}}}));
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
let text = r["result"]["content"][0]["text"].as_str().unwrap();
|
||||
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.as_slice(), write.as_slice()).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.as_slice(), write.as_slice()).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
|
||||
|
||||
+441
-36
@@ -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).
|
||||
@@ -71,8 +75,9 @@ unsafe fn read_cstr(p: *const c_char) -> Option<String> {
|
||||
}
|
||||
|
||||
/// Reads a fixed-size `char[N]` name buffer, stopping at the first NUL or the end of the array.
|
||||
/// `c_char` is `i8` on x86 and `u8` on ARM/RISC-V Linux; the byte reinterpretation is the same for both.
|
||||
fn read_name(buf: &[c_char]) -> String {
|
||||
let bytes: Vec<u8> = buf.iter().take_while(|c| **c != 0).map(|c| *c as u8).collect();
|
||||
let bytes: Vec<u8> = buf.iter().take_while(|c| **c != 0).map(|c| u8::from_ne_bytes(c.to_ne_bytes())).collect();
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
@@ -274,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)
|
||||
@@ -309,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)>>,
|
||||
@@ -327,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)),
|
||||
}
|
||||
}
|
||||
@@ -407,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
|
||||
}
|
||||
|
||||
@@ -578,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;
|
||||
@@ -695,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>,
|
||||
@@ -714,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()),
|
||||
}
|
||||
}
|
||||
@@ -730,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 {
|
||||
@@ -754,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(());
|
||||
}
|
||||
@@ -761,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);
|
||||
@@ -793,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)
|
||||
@@ -802,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.
|
||||
@@ -814,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
|
||||
@@ -835,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);
|
||||
@@ -871,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;
|
||||
@@ -1011,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) };
|
||||
@@ -1085,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> {
|
||||
@@ -1185,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()));
|
||||
@@ -1218,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),
|
||||
@@ -1231,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()
|
||||
@@ -1263,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +102,35 @@ fn note_mode(p: Option<NotePort>) -> NoteMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy `n` frames from `start` of the host buffers into the plugin's input `port` (mono sources
|
||||
/// fill every port channel).
|
||||
fn feed_input(inputs: &mut PortBuffers, port: usize, io: &[Vec<f32>], ch: usize, (start, n): (usize, usize)) {
|
||||
for c in 0..inputs.channels(port) {
|
||||
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
|
||||
if let (Some(src), Some(dst)) = (src, inputs.channel_mut(port, c).and_then(|d| d.get_mut(..n))) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy `n` frames of the plugin's output `port` into the host buffers from `start`; a mono host
|
||||
/// buffer takes the average of a stereo port.
|
||||
fn read_output(outputs: &PortBuffers, port: usize, io: &mut [Vec<f32>], ch: usize, (start, n): (usize, usize)) {
|
||||
let oc = outputs.channels(port);
|
||||
for (c, dst) in io.iter_mut().take(ch).enumerate() {
|
||||
let Some(dst) = dst.get_mut(start..start + n) else { continue };
|
||||
if ch == 1 && oc >= 2 {
|
||||
if let (Some(l), Some(r)) = (outputs.channel(port, 0), outputs.channel(port, 1)) {
|
||||
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
|
||||
*d = 0.5 * (a + b);
|
||||
}
|
||||
}
|
||||
} else if let Some(src) = outputs.channel(port, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for ClapPlugin {
|
||||
fn info(&self) -> &'static PluginInfo {
|
||||
self.info
|
||||
@@ -108,13 +142,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)) {
|
||||
@@ -177,34 +226,17 @@ impl Plugin for ClapPlugin {
|
||||
let end = u32::try_from(start + n).unwrap_or(u32::MAX);
|
||||
// Feed the main input port (mono sources fill every port channel).
|
||||
if let Some(p) = self.in_main {
|
||||
for c in 0..self.inputs.channels(p) {
|
||||
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
|
||||
if let (Some(src), Some(dst)) = (src, self.inputs.channel_mut(p, c).and_then(|d| d.get_mut(..n))) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
feed_input(&mut self.inputs, p, io, ch, (start, n));
|
||||
}
|
||||
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) {
|
||||
(Ok(_), Some(p)) => {
|
||||
let oc = self.outputs.channels(p);
|
||||
for (c, dst) in io.iter_mut().take(ch).enumerate() {
|
||||
let Some(dst) = dst.get_mut(start..start + n) else { continue };
|
||||
if ch == 1 && oc >= 2 {
|
||||
if let (Some(l), Some(r)) = (self.outputs.channel(p, 0), self.outputs.channel(p, 1)) {
|
||||
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
|
||||
*d = 0.5 * (a + b);
|
||||
}
|
||||
}
|
||||
} else if let Some(src) = self.outputs.channel(p, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
read_output(&self.outputs, p, io, ch, (start, n));
|
||||
}
|
||||
(Err(e), _) => log::debug!("{}: {e}", self.info.id),
|
||||
_ => {}
|
||||
@@ -217,6 +249,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 +284,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 +298,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 +321,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`]).
|
||||
@@ -309,7 +352,6 @@ impl PluginEditor for ClapEditor {
|
||||
|
||||
fn idle(&mut self) -> Vec<(String, f32)> {
|
||||
let raw = self.link.idle();
|
||||
let _ = self.link.host().dirty.swap(false, Ordering::Relaxed);
|
||||
let mut out: Vec<(String, f32)> = Vec::new();
|
||||
for (cid, v) in raw {
|
||||
let Some(i) = self.ids.iter().position(|&x| x == cid) else { continue };
|
||||
@@ -323,4 +365,8 @@ impl PluginEditor for ClapEditor {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn state_changed(&mut self) -> bool {
|
||||
self.link.host().dirty.swap(false, Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::OnceLock;
|
||||
pub fn fixture_dir() -> &'static Path {
|
||||
static DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixture/Cargo.toml");
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../clap-host/tests/fixture/Cargo.toml");
|
||||
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("clap-fixture-target");
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let st = Command::new(cargo)
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# A tiny CLAP plugin used only by soundcraft-clap-host's integration tests. Not part of the
|
||||
# A tiny CLAP plugin used only by SoundCraft's integration tests. Not part of the
|
||||
# SoundCraft workspace (the empty [workspace] table makes it its own root); the test builds it
|
||||
# with `cargo build` and loads the resulting library. Never shipped.
|
||||
[package]
|
||||
|
||||
@@ -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,10 +179,71 @@ 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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill every output channel with 1.0 when all ports have the wide layout, else -1.0.
|
||||
unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize) {
|
||||
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 } };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the host-visible parameter edit and an ignorable note event onto the output queue.
|
||||
unsafe fn push_gui_edit(pr: &clap_process) {
|
||||
let e = clap_event_param_value {
|
||||
header: clap_event_header {
|
||||
size: std::mem::size_of::<clap_event_param_value>() as u32,
|
||||
time: 0,
|
||||
space_id: CLAP_CORE_EVENT_SPACE_ID,
|
||||
type_: CLAP_EVENT_PARAM_VALUE,
|
||||
flags: 0,
|
||||
},
|
||||
param_id: 7,
|
||||
cookie: std::ptr::null_mut(),
|
||||
note_id: -1,
|
||||
port_index: -1,
|
||||
channel: -1,
|
||||
key: -1,
|
||||
value: 0.25,
|
||||
};
|
||||
let out = unsafe { &*pr.out_events };
|
||||
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &e.header) });
|
||||
// Other event types are accepted and ignored by the host.
|
||||
let note = clap_event_note {
|
||||
header: clap_event_header {
|
||||
size: std::mem::size_of::<clap_event_note>() as u32,
|
||||
time: 0,
|
||||
space_id: CLAP_CORE_EVENT_SPACE_ID,
|
||||
type_: CLAP_EVENT_NOTE_END,
|
||||
flags: 0,
|
||||
},
|
||||
note_id: -1,
|
||||
port_index: 0,
|
||||
channel: 0,
|
||||
key: 60,
|
||||
velocity: 0.0,
|
||||
};
|
||||
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, ¬e.header) });
|
||||
}
|
||||
|
||||
unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) -> clap_process_status {
|
||||
let f = unsafe { fx(p) };
|
||||
let pr = unsafe { &*pr };
|
||||
@@ -161,6 +251,10 @@ 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 {
|
||||
unsafe { fill_wide(pr, ports, channels, n) };
|
||||
return CLAP_PROCESS_CONTINUE;
|
||||
}
|
||||
if pr.audio_outputs_count != 1 {
|
||||
return CLAP_PROCESS_ERROR;
|
||||
}
|
||||
@@ -179,40 +273,7 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
|
||||
if f.gui_edit_pending {
|
||||
f.gui_edit_pending = false;
|
||||
f.gain = 0.25;
|
||||
let e = clap_event_param_value {
|
||||
header: clap_event_header {
|
||||
size: std::mem::size_of::<clap_event_param_value>() as u32,
|
||||
time: 0,
|
||||
space_id: CLAP_CORE_EVENT_SPACE_ID,
|
||||
type_: CLAP_EVENT_PARAM_VALUE,
|
||||
flags: 0,
|
||||
},
|
||||
param_id: 7,
|
||||
cookie: std::ptr::null_mut(),
|
||||
note_id: -1,
|
||||
port_index: -1,
|
||||
channel: -1,
|
||||
key: -1,
|
||||
value: 0.25,
|
||||
};
|
||||
let out = unsafe { &*pr.out_events };
|
||||
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &e.header) });
|
||||
// Other event types are accepted and ignored by the host.
|
||||
let note = clap_event_note {
|
||||
header: clap_event_header {
|
||||
size: std::mem::size_of::<clap_event_note>() as u32,
|
||||
time: 0,
|
||||
space_id: CLAP_CORE_EVENT_SPACE_ID,
|
||||
type_: CLAP_EVENT_NOTE_END,
|
||||
flags: 0,
|
||||
},
|
||||
note_id: -1,
|
||||
port_index: 0,
|
||||
channel: 0,
|
||||
key: 60,
|
||||
velocity: 0.0,
|
||||
};
|
||||
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, ¬e.header) });
|
||||
unsafe { push_gui_edit(pr) };
|
||||
}
|
||||
let mut next = 0u32;
|
||||
let mut last_time = 0u32;
|
||||
@@ -317,10 +378,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 +568,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 +614,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;
|
||||
+122
-5
@@ -1,7 +1,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
//! SoundCraft DSP.
|
||||
//!
|
||||
//! Realtime plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utilities and two
|
||||
//! Realtime plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utilities and three
|
||||
//! instruments), metering (peak, RMS, ITU-R BS.1770 loudness), spectrum analysis and offline
|
||||
//! AudioSuite-style whole-buffer processing.
|
||||
//!
|
||||
@@ -28,6 +28,8 @@ pub use offline::FadeShape;
|
||||
pub use osc::Waveform;
|
||||
pub use plugins::eq::{eq1_response, eq7_response};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Plugin category, used to group the insert menu.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Category {
|
||||
@@ -167,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
|
||||
@@ -193,15 +213,28 @@ 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) {}
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// The previous sample is dropped here, so hosts keep their own reference to it (the
|
||||
/// session's source pool does) to keep deallocation off the audio thread.
|
||||
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), 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.
|
||||
@@ -220,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
|
||||
@@ -235,9 +272,67 @@ pub trait PluginEditor: Send {
|
||||
/// user closed, and returns parameter edits made in the editor as `(param id, value)` in
|
||||
/// SoundCraft units (ids and ranges of [`PluginInfo::params`]).
|
||||
fn idle(&mut self) -> Vec<(String, f32)>;
|
||||
/// True once after the plugin said its state changed in a way its parameters don't show
|
||||
/// (CLAP `state.mark_dirty`), so the session stores the new state.
|
||||
fn state_changed(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
/// 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
|
||||
/// instrument converts rates as it plays). Implementations must be cheap to read from the audio
|
||||
/// thread: no locks, no I/O.
|
||||
pub trait SampleSource: Send + Sync {
|
||||
/// Sample rate of the audio in Hz.
|
||||
fn sample_rate(&self) -> f32;
|
||||
fn num_channels(&self) -> usize;
|
||||
/// One channel's frames (`None` past the last channel).
|
||||
fn channel(&self, ch: usize) -> Option<&[f32]>;
|
||||
/// Frames per channel (the shortest channel, so every index below it is readable).
|
||||
fn frames(&self) -> usize {
|
||||
(0..self.num_channels()).filter_map(|c| self.channel(c)).map(<[f32]>::len).min().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A sample held in memory (tests, generated audio).
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct SampleBuffer {
|
||||
pub sample_rate: f32,
|
||||
pub channels: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
impl SampleSource for SampleBuffer {
|
||||
fn sample_rate(&self) -> f32 {
|
||||
self.sample_rate
|
||||
}
|
||||
fn num_channels(&self) -> usize {
|
||||
self.channels.len()
|
||||
}
|
||||
fn channel(&self, ch: usize) -> Option<&[f32]> {
|
||||
self.channels.get(ch).map(Vec::as_slice)
|
||||
}
|
||||
}
|
||||
|
||||
/// The plugin registry.
|
||||
@@ -258,6 +353,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;
|
||||
|
||||
@@ -298,4 +398,21 @@ mod trait_default_tests {
|
||||
assert!(p.editor().is_none(), "{}", info.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_sampler_takes_samples() {
|
||||
for info in plugins() {
|
||||
let mut p = create(info.id).unwrap();
|
||||
let s: Arc<dyn SampleSource> = Arc::new(SampleBuffer { sample_rate: 48_000.0, channels: vec![vec![0.0; 16]] });
|
||||
assert_eq!(p.set_sample(Some(s)), info.id == "sampler", "{}", info.id);
|
||||
assert_eq!(p.set_sample(None), info.id == "sampler", "{}", info.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_buffer_frames_is_the_shortest_channel() {
|
||||
let s = SampleBuffer { sample_rate: 44_100.0, channels: vec![vec![0.0; 10], vec![0.0; 7]] };
|
||||
assert_eq!(s.frames(), 7);
|
||||
assert_eq!(SampleBuffer::default().frames(), 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]
|
||||
|
||||
+456
-144
@@ -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) });
|
||||
@@ -215,16 +226,95 @@ fn resample_ratio(ch: &[Vec<f32>], ratio: f64, out_len: usize) -> Vec<Vec<f32>>
|
||||
ch.iter().map(|c| resample_channel(c, ratio, out_len, &table)).collect()
|
||||
}
|
||||
|
||||
/// Most kernel weights a [`Polyphase`] bank may hold (16 MiB of f32).
|
||||
const MAX_POLYPHASE_WEIGHTS: usize = 1 << 22;
|
||||
|
||||
/// The windowed-sinc kernel of [`resample_channel`] precomputed for every phase of a rational
|
||||
/// rate change `from` → `to` (reduced to `step` / `phases`). Output sample `n` reads input time
|
||||
/// `n * step / phases`: whole part `i`, phase `p`. Its taps are input samples `i + first ..`, one
|
||||
/// weight each, so every output is one dot product over contiguous memory instead of a table
|
||||
/// lookup and interpolation per tap. The weights are the ones `resample_channel` computes, taps
|
||||
/// beyond the kernel's half width weigh 0, and taps are summed in the same order.
|
||||
struct Polyphase {
|
||||
phases: u64,
|
||||
step: u64,
|
||||
/// Offset of the first tap from `i`.
|
||||
first: i64,
|
||||
taps: usize,
|
||||
/// `phases * taps` weights, phase-major.
|
||||
weights: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Polyphase {
|
||||
/// `None` when the bank would be too large (rates with no small common ratio); the caller
|
||||
/// then falls back to [`resample_channel`].
|
||||
fn new(from: u32, to: u32, table: &SincTable) -> Option<Polyphase> {
|
||||
let g = gcd(from, to);
|
||||
let (step, phases) = (u64::from(from / g), u64::from(to / g));
|
||||
let hw = table.half_width;
|
||||
let first = (-hw).floor() as i64;
|
||||
let last = hw.ceil() as i64 + 1;
|
||||
let taps = usize::try_from(last - first + 1).ok()?;
|
||||
if usize::try_from(phases).ok()?.checked_mul(taps)? > MAX_POLYPHASE_WEIGHTS {
|
||||
return None;
|
||||
}
|
||||
let mut weights = Vec::with_capacity(taps * phases as usize);
|
||||
for p in 0..phases {
|
||||
let frac = p as f64 / phases as f64;
|
||||
for k in first..first + taps as i64 {
|
||||
let d = frac - k as f64;
|
||||
weights.push(if d.abs() <= hw { table.at(d) } else { 0.0 });
|
||||
}
|
||||
}
|
||||
Some(Polyphase { phases, step, first, taps, weights })
|
||||
}
|
||||
|
||||
fn run(&self, x: &[f32], out_len: usize) -> Vec<f32> {
|
||||
let len = x.len() as i64;
|
||||
let mut out = Vec::with_capacity(out_len);
|
||||
for n in 0..out_len as u64 {
|
||||
let Some(pos) = n.checked_mul(self.step) else { break };
|
||||
let (i, p) = ((pos / self.phases) as i64, (pos % self.phases) as usize);
|
||||
let w = self.weights.get(p * self.taps..(p + 1) * self.taps).unwrap_or_default();
|
||||
let start = i + self.first;
|
||||
let acc = match usize::try_from(start).ok().and_then(|s| x.get(s..s + self.taps)) {
|
||||
// Inside the signal: a plain dot product.
|
||||
Some(xs) => xs.iter().zip(w).fold(0.0f32, |a, (v, k)| a + v * k),
|
||||
// At the edges, taps outside the signal are skipped, as `resample_channel` does.
|
||||
None => (0..self.taps as i64)
|
||||
.filter(|k| (0..len).contains(&(start + k)))
|
||||
.fold(0.0f32, |a, k| a + x.get((start + k) as usize).copied().unwrap_or(0.0) * w.get(k as usize).copied().unwrap_or(0.0)),
|
||||
};
|
||||
out.push(acc);
|
||||
}
|
||||
out.resize(out_len, 0.0);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn gcd(mut a: u32, mut b: u32) -> u32 {
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
a.max(1)
|
||||
}
|
||||
|
||||
/// High-quality windowed-sinc sample-rate conversion from `from` Hz to `to` Hz.
|
||||
pub fn resample(ch: &[Vec<f32>], from: u32, to: u32) -> Vec<Vec<f32>> {
|
||||
if from == 0 || to == 0 || from == to {
|
||||
return ch.to_vec();
|
||||
}
|
||||
let ratio = f64::from(to) / f64::from(from);
|
||||
let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 };
|
||||
let table = SincTable::new(cutoff);
|
||||
let bank = Polyphase::new(from, to, &table);
|
||||
ch.iter()
|
||||
.map(|c| {
|
||||
let out_len = ((c.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES);
|
||||
resample_ratio(std::slice::from_ref(c), ratio, out_len).into_iter().next().unwrap_or_default()
|
||||
match &bank {
|
||||
Some(b) => b.run(c, out_len),
|
||||
None => resample_channel(c, ratio, out_len, &table),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -232,6 +322,77 @@ pub fn resample(ch: &[Vec<f32>], from: u32, to: u32) -> Vec<Vec<f32>> {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Time stretch (WSOLA) and pitch shift
|
||||
|
||||
/// Start position in the guide signal whose `hop` samples best match the natural continuation at
|
||||
/// `natural`, searched within `tol` of `nominal` (coarse stride 4, then refined +-3).
|
||||
fn best_start(g: &impl Fn(i64) -> f32, nominal: i64, natural: i64, tol: i64, hop: usize) -> i64 {
|
||||
// Similarity of candidate start `p` with the natural continuation (stride 2).
|
||||
let score = |p: i64| -> f32 {
|
||||
let (mut xy, mut yy) = (0.0f32, 1e-9f32);
|
||||
let mut i = 0i64;
|
||||
while i < hop as i64 {
|
||||
let a = g(natural + i);
|
||||
let b = g(p + i);
|
||||
xy += a * b;
|
||||
yy += b * b;
|
||||
i += 2;
|
||||
}
|
||||
xy / yy.sqrt()
|
||||
};
|
||||
let lo = (nominal - tol).max(0);
|
||||
let hi = nominal + tol;
|
||||
let mut best = nominal.max(0);
|
||||
let mut best_s = f32::NEG_INFINITY;
|
||||
let mut p = lo;
|
||||
while p <= hi {
|
||||
let s = score(p);
|
||||
if s > best_s {
|
||||
best_s = s;
|
||||
best = p;
|
||||
}
|
||||
p += 4;
|
||||
}
|
||||
let (rlo, rhi) = ((best - 3).max(lo), (best + 3).min(hi));
|
||||
for p in rlo..=rhi {
|
||||
let s = score(p);
|
||||
if s > best_s {
|
||||
best_s = s;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Add one windowed frame of every channel, read from `at.0`, into `out` at `at.1`, and the window
|
||||
/// into `wsum`.
|
||||
fn overlap_add(ch: &[Vec<f32>], out: &mut [Vec<f32>], wsum: &mut [f32], window: &[f32], at: (usize, usize)) {
|
||||
let (in_pos, out_pos) = at;
|
||||
for (c, o) in ch.iter().zip(out.iter_mut()) {
|
||||
for (i, w) in window.iter().enumerate() {
|
||||
let v = c.get(in_pos + i).copied().unwrap_or(0.0);
|
||||
if let Some(d) = o.get_mut(out_pos + i) {
|
||||
*d += v * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, w) in window.iter().enumerate() {
|
||||
if let Some(d) = wsum.get_mut(out_pos + i) {
|
||||
*d += w;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Divide out the accumulated window weights and cut each channel to `out_len`.
|
||||
fn normalise(out: &mut [Vec<f32>], wsum: &[f32], out_len: usize) {
|
||||
for o in out.iter_mut() {
|
||||
for (s, w) in o.iter_mut().zip(wsum) {
|
||||
if *w > 1e-3 {
|
||||
*s /= w;
|
||||
}
|
||||
}
|
||||
o.truncate(out_len);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pitch-preserving time stretch by WSOLA (waveform-similarity overlap-add).
|
||||
/// `ratio` = output length / input length, clamped to 0.1 ..= 10. All channels share the same
|
||||
/// segment choices so the stereo image stays coherent.
|
||||
@@ -264,72 +425,12 @@ pub fn time_stretch(ch: &[Vec<f32>], ratio: f64, sample_rate: f32) -> Vec<Vec<f3
|
||||
if out_pos >= out_len {
|
||||
break;
|
||||
}
|
||||
let in_pos = if k == 0 {
|
||||
0
|
||||
} else {
|
||||
let nominal = (out_pos as f64 / ratio).round() as i64;
|
||||
let natural = prev_in + hop as i64;
|
||||
// Similarity of candidate start `p` with the natural continuation (stride 2).
|
||||
let score = |p: i64| -> f32 {
|
||||
let (mut xy, mut yy) = (0.0f32, 1e-9f32);
|
||||
let mut i = 0i64;
|
||||
while i < hop as i64 {
|
||||
let a = g(natural + i);
|
||||
let b = g(p + i);
|
||||
xy += a * b;
|
||||
yy += b * b;
|
||||
i += 2;
|
||||
}
|
||||
xy / yy.sqrt()
|
||||
};
|
||||
let lo = (nominal - tol).max(0);
|
||||
let hi = nominal + tol;
|
||||
let mut best = nominal.max(0);
|
||||
let mut best_s = f32::NEG_INFINITY;
|
||||
let mut p = lo;
|
||||
while p <= hi {
|
||||
let s = score(p);
|
||||
if s > best_s {
|
||||
best_s = s;
|
||||
best = p;
|
||||
}
|
||||
p += 4;
|
||||
}
|
||||
let (rlo, rhi) = ((best - 3).max(lo), (best + 3).min(hi));
|
||||
for p in rlo..=rhi {
|
||||
let s = score(p);
|
||||
if s > best_s {
|
||||
best_s = s;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
best
|
||||
};
|
||||
for (c, o) in ch.iter().zip(out.iter_mut()) {
|
||||
for (i, w) in window.iter().enumerate() {
|
||||
let src = in_pos as usize + i;
|
||||
let v = c.get(src).copied().unwrap_or(0.0);
|
||||
if let Some(d) = o.get_mut(out_pos + i) {
|
||||
*d += v * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, w) in window.iter().enumerate() {
|
||||
if let Some(d) = wsum.get_mut(out_pos + i) {
|
||||
*d += w;
|
||||
}
|
||||
}
|
||||
let in_pos = if k == 0 { 0 } else { best_start(&g, (out_pos as f64 / ratio).round() as i64, prev_in + hop as i64, tol, hop) };
|
||||
overlap_add(ch, &mut out, &mut wsum, &window, (in_pos as usize, out_pos));
|
||||
prev_in = in_pos;
|
||||
k += 1;
|
||||
}
|
||||
for o in out.iter_mut() {
|
||||
for (s, w) in o.iter_mut().zip(&wsum) {
|
||||
if *w > 1e-3 {
|
||||
*s /= w;
|
||||
}
|
||||
}
|
||||
o.truncate(out_len);
|
||||
}
|
||||
normalise(&mut out, &wsum, out_len);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -341,7 +442,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 {
|
||||
@@ -355,6 +463,78 @@ pub fn pitch_shift(ch: &[Vec<f32>], semitones: f32, sample_rate: f32) -> Vec<Vec
|
||||
out
|
||||
}
|
||||
|
||||
/// Which way [`speed_ramp`] ramps the playback speed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SpeedRampChange {
|
||||
/// From normal speed down to a complete stop.
|
||||
SlowDown,
|
||||
/// From a complete stop up to normal speed.
|
||||
SpeedUp,
|
||||
}
|
||||
|
||||
/// How much of the input [`speed_ramp`] plays, and how long the result is.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SpeedRampSelection {
|
||||
/// The result is as long as the input. Two thirds of the input are played: the first two
|
||||
/// thirds for Slow Down, the last two thirds for Speed Up, so each joins its neighbour
|
||||
/// without a jump.
|
||||
FitTo,
|
||||
/// All of the input is played, and the result is 150 % of its length.
|
||||
Extend,
|
||||
}
|
||||
|
||||
/// Plays `ch` with the speed ramping between normal speed and a complete stop, so the pitch follows
|
||||
/// the speed. The speed is `1 - u²` for Slow Down and `1 - (1 - u)²` for Speed Up, where `u`
|
||||
/// runs from 0 to 1 over the result; both curves average 2/3 of normal speed. With `fades`, the
|
||||
/// gain follows the same curve: a fade-out for Slow Down and a fade-in for Speed Up. Every channel
|
||||
/// is read at the same positions, and a channel shorter than the others is silent past its end.
|
||||
pub fn speed_ramp(ch: &[Vec<f32>], change: SpeedRampChange, selection: SpeedRampSelection, fades: bool) -> Vec<Vec<f32>> {
|
||||
let len = max_len(ch) as f64;
|
||||
let (total, first, played) = match selection {
|
||||
SpeedRampSelection::Extend => ((len * 1.5).round(), 0.0, len),
|
||||
SpeedRampSelection::FitTo => {
|
||||
let played = len * 2.0 / 3.0;
|
||||
(len, if change == SpeedRampChange::SlowDown { 0.0 } else { len - played }, played)
|
||||
}
|
||||
};
|
||||
let out_len = (total as usize).min(MAX_OFFLINE_SAMPLES);
|
||||
let table = SincTable::new(0.97);
|
||||
let hw = table.half_width;
|
||||
// The fraction of the played input reached at `u`, and the speed there (0 to 1).
|
||||
let at = |u: f64| -> (f64, f64) {
|
||||
match change {
|
||||
SpeedRampChange::SlowDown => ((3.0 * u - u * u * u) / 2.0, 1.0 - u * u),
|
||||
SpeedRampChange::SpeedUp => {
|
||||
let r = 1.0 - u;
|
||||
(1.0 - (3.0 * r - r * r * r) / 2.0, 1.0 - r * r)
|
||||
}
|
||||
}
|
||||
};
|
||||
// The interpolation reads the first or last sample of the input for taps before or after it.
|
||||
let last = (len as i64 - 1).max(0);
|
||||
ch.iter()
|
||||
.map(|x| {
|
||||
(0..out_len)
|
||||
.map(|n| {
|
||||
let (pos, speed) = at(n as f64 / total);
|
||||
let t = first + played * pos;
|
||||
let lo = (t - hw).ceil() as i64;
|
||||
let hi = (t + hw).floor() as i64;
|
||||
let mut acc = 0.0f32;
|
||||
let mut j = lo;
|
||||
while j <= hi {
|
||||
if let Some(&v) = x.get(j.clamp(0, last) as usize) {
|
||||
acc += v * table.at(t - j as f64);
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if fades { acc * speed as f32 } else { acc }
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Generators and analysis
|
||||
|
||||
@@ -371,111 +551,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
|
||||
@@ -542,6 +824,36 @@ pub fn peak_db(ch: &[Vec<f32>]) -> f32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The polyphase bank gives what the per-tap kernel gives (it only moves where the weights
|
||||
/// are computed), at common rate pairs, both directions, and at the signal's edges.
|
||||
#[test]
|
||||
fn polyphase_resample_matches_the_per_tap_kernel() {
|
||||
let x: Vec<f32> = (0..3_000).map(|i| (i as f32 * 0.071).sin() * 0.6 + (i as f32 * 0.53).sin() * 0.3).collect();
|
||||
let pairs = [(44_100, 48_000), (48_000, 44_100), (48_000, 96_000), (96_000, 48_000), (22_050, 48_000), (8_000, 44_100), (48_000, 22_050)];
|
||||
for (from, to) in pairs {
|
||||
let ratio = f64::from(to) / f64::from(from);
|
||||
let out_len = ((x.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES);
|
||||
let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 };
|
||||
let table = SincTable::new(cutoff);
|
||||
let reference = resample_channel(&x, ratio, out_len, &table);
|
||||
let bank = Polyphase::new(from, to, &table).expect("small common ratio");
|
||||
let fast = bank.run(&x, out_len);
|
||||
assert_eq!(fast.len(), reference.len(), "{from}->{to}");
|
||||
let worst = fast.iter().zip(&reference).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(worst < 1e-5, "{from}->{to}: worst difference {worst}");
|
||||
assert_eq!(resample(std::slice::from_ref(&x), from, to)[0], fast, "resample uses the bank");
|
||||
}
|
||||
// Signals shorter than the kernel are all edge.
|
||||
let short = [0.5f32, -0.25, 0.75];
|
||||
let table = SincTable::new(0.97);
|
||||
let reference = resample_channel(&short, 48_000.0 / 44_100.0, 3, &table);
|
||||
let fast = Polyphase::new(44_100, 48_000, &table).expect("bank").run(&short, 3);
|
||||
assert!(fast.iter().zip(&reference).all(|(a, b)| (a - b).abs() < 1e-6), "{fast:?} {reference:?}");
|
||||
// Rates with no small common ratio fall back to the per-tap kernel.
|
||||
assert!(Polyphase::new(44_100, 96_001, &table).is_none());
|
||||
assert_eq!(resample(&[short.to_vec()], 44_100, 96_001)[0].len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fade_shapes_endpoints() {
|
||||
for s in FadeShape::ALL {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
//! Auto Pan: moves a signal between the left and right channels with an LFO or an envelope.
|
||||
|
||||
use std::f32::consts::{FRAC_PI_4, PI, SQRT_2, TAU};
|
||||
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
|
||||
use crate::util::{Smoother, frames_in, ms_to_samples, sane_channels, sane_sr, time_coef};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Transport, Unit, db_to_gain, gain_to_db};
|
||||
|
||||
const SOURCE_CHOICES: &[&str] = &["LFO", "Envelope"];
|
||||
const PATH_CHOICES: &[&str] = &["Left to Right", "Right to Left"];
|
||||
const WAVEFORM_CHOICES: &[&str] = &["Sine", "Triangle", "Square", "Saw Up", "Saw Down", "Half Sine", "4 Step Triangle"];
|
||||
const TRIGGER_CHOICES: &[&str] = &["Free", "Single", "Beat Clock", "Envelope", "Manual"];
|
||||
const DURATION_CHOICES: &[&str] = &["16th Note", "8th Note", "1 Beat", "2 Beats", "1 Bar", "2 Bars", "4 Bars"];
|
||||
|
||||
const SOURCE_ENVELOPE: usize = 1;
|
||||
const PATH_RIGHT_TO_LEFT: usize = 1;
|
||||
const TRIGGER_FREE: usize = 0;
|
||||
const TRIGGER_SINGLE: usize = 1;
|
||||
const TRIGGER_BEAT_CLOCK: usize = 2;
|
||||
const TRIGGER_ENVELOPE: usize = 3;
|
||||
const TRIGGER_MANUAL: usize = 4;
|
||||
|
||||
static AUTOPAN_PARAMS: [ParamInfo; 14] = [
|
||||
lin("output", "Output", -24.0, 12.0, 0.0, Unit::Db),
|
||||
lin("width", "Width", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
lin("manual", "Manual", -100.0, 100.0, 0.0, Unit::Percent),
|
||||
choice("source", "Source", SOURCE_CHOICES, 0),
|
||||
choice("path", "Path", PATH_CHOICES, 0),
|
||||
log("rate", "Rate (BPM)", 1.0, 960.0, 120.0, Unit::None),
|
||||
choice("waveform", "Waveform", WAVEFORM_CHOICES, 0),
|
||||
choice("trigger", "Trigger", TRIGGER_CHOICES, 0),
|
||||
toggle("retrigger", "Manual Trigger", false),
|
||||
toggle("link_tempo", "Link to Tempo", false),
|
||||
choice("duration", "Duration", DURATION_CHOICES, 4),
|
||||
lin("threshold", "Threshold", -60.0, -1.0, -30.0, Unit::Db),
|
||||
log("attack", "Attack", 0.1, 100.0, 5.0, Unit::Ms),
|
||||
log("release", "Release", 5.0, 2000.0, 150.0, Unit::Ms),
|
||||
];
|
||||
|
||||
pub static AUTOPAN_INFO: PluginInfo = PluginInfo {
|
||||
id: "auto_pan",
|
||||
name: "Auto Pan",
|
||||
short_name: "Auto Pan",
|
||||
category: Category::Modulation,
|
||||
params: &AUTOPAN_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// The LFO shape at `t` in 0..=1 of a cycle, from -1 (the start of the path) to 1 (its end).
|
||||
fn wave_at(waveform: usize, t: f32) -> f32 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let tri = if t < 0.5 { 4.0 * t - 1.0 } else { 3.0 - 4.0 * t };
|
||||
match waveform {
|
||||
1 => tri,
|
||||
2 => {
|
||||
if t < 0.5 {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
3 => 2.0 * t - 1.0,
|
||||
4 => 1.0 - 2.0 * t,
|
||||
5 => 2.0 * (PI * t).sin() - 1.0,
|
||||
6 => {
|
||||
let step = ((tri + 1.0) * 2.0).floor().clamp(0.0, 3.0);
|
||||
step * 2.0 / 3.0 - 1.0
|
||||
}
|
||||
_ => -(TAU * t).cos(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Length of one Duration choice in quarter notes, for a bar of `bar` quarter notes.
|
||||
fn duration_quarters(duration: usize, bar: f64) -> f64 {
|
||||
match duration {
|
||||
0 => 0.25,
|
||||
1 => 0.5,
|
||||
2 => 1.0,
|
||||
3 => 2.0,
|
||||
5 => 2.0 * bar,
|
||||
6 => 4.0 * bar,
|
||||
_ => bar,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of bars in a bar-based Duration choice.
|
||||
fn duration_bars(duration: usize) -> Option<i64> {
|
||||
match duration {
|
||||
4 => Some(1),
|
||||
5 => Some(2),
|
||||
6 => Some(4),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the balance of the first two channels with a constant-power law: as the pan position
|
||||
/// moves right the left channel's gain falls and the right's rises, and each channel stays on its
|
||||
/// own side. The pan position is `manual · (1 - width) + modulator · width`, where the modulator is
|
||||
/// the LFO or the envelope follower in -1..=1. At the center both gains are 1, so a stereo signal
|
||||
/// passes unchanged; a signal that is the same on both channels pans across the field at a
|
||||
/// constant level.
|
||||
pub struct AutoPan {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
ch: usize,
|
||||
transport: Transport,
|
||||
/// Samples processed since the last `set_transport`, for hosts that call it less than once a block.
|
||||
since_transport: u64,
|
||||
was_playing: bool,
|
||||
/// LFO phase in cycles, in `f64`: at the slowest rates an `f32` step is below half an ULP.
|
||||
phase: f64,
|
||||
/// A Single, Envelope or Manual trigger has finished its cycle and holds the last position.
|
||||
holding: bool,
|
||||
env: f32,
|
||||
armed: bool,
|
||||
/// Samples the envelope has spent below the threshold since the last Envelope trigger. The
|
||||
/// trigger re-arms once this reaches the Release time, so a longer Release spaces triggers out.
|
||||
below: u64,
|
||||
last_retrigger: bool,
|
||||
last_trigger: usize,
|
||||
output: Smoother,
|
||||
width: Smoother,
|
||||
manual: Smoother,
|
||||
/// Short declicking smoother on the pan position.
|
||||
position: Smoother,
|
||||
/// The next sample starts the position at its target instead of gliding to it.
|
||||
snap_position: bool,
|
||||
}
|
||||
|
||||
impl Default for AutoPan {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AutoPan {
|
||||
pub fn new() -> Self {
|
||||
let mut a = Self {
|
||||
p: Params::new(&AUTOPAN_PARAMS),
|
||||
sr: 48_000.0,
|
||||
ch: 0,
|
||||
transport: Transport::default(),
|
||||
since_transport: 0,
|
||||
was_playing: false,
|
||||
phase: 0.0,
|
||||
holding: false,
|
||||
env: 0.0,
|
||||
armed: true,
|
||||
below: 0,
|
||||
last_retrigger: false,
|
||||
last_trigger: TRIGGER_FREE,
|
||||
output: Smoother::new(1.0),
|
||||
width: Smoother::new(1.0),
|
||||
manual: Smoother::new(0.0),
|
||||
position: Smoother::new(0.0),
|
||||
snap_position: true,
|
||||
};
|
||||
a.update();
|
||||
a.restart();
|
||||
a
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.output.set(db_to_gain(self.p.v(0)));
|
||||
self.width.set(self.p.v(1) / 100.0);
|
||||
self.manual.set(self.p.v(2) / 100.0);
|
||||
let trigger = self.p.choice(7);
|
||||
if trigger != self.last_trigger {
|
||||
self.last_trigger = trigger;
|
||||
self.restart();
|
||||
}
|
||||
let retrigger = self.p.on(8);
|
||||
if retrigger != self.last_retrigger {
|
||||
self.last_retrigger = retrigger;
|
||||
if trigger == TRIGGER_MANUAL {
|
||||
self.phase = 0.0;
|
||||
self.holding = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the LFO back at the start of the path. Single runs one cycle from there; Envelope and
|
||||
/// Manual wait at the start for their first trigger.
|
||||
fn restart(&mut self) {
|
||||
self.phase = 0.0;
|
||||
self.holding = matches!(self.p.choice(7), TRIGGER_ENVELOPE | TRIGGER_MANUAL);
|
||||
self.armed = true;
|
||||
self.below = 0;
|
||||
}
|
||||
|
||||
/// LFO frequency in cycles per second.
|
||||
fn lfo_hz(&self) -> f64 {
|
||||
let tempo = if self.p.on(9) && self.transport.tempo.is_finite() && self.transport.tempo > 0.0 {
|
||||
self.transport.tempo
|
||||
} else {
|
||||
f64::from(self.p.v(5))
|
||||
};
|
||||
tempo / 60.0 / duration_quarters(self.p.choice(10), self.bar_quarters())
|
||||
}
|
||||
|
||||
fn bar_quarters(&self) -> f64 {
|
||||
let (num, den) = (self.transport.numerator, self.transport.denominator);
|
||||
if num == 0 || den == 0 { 4.0 } else { (f64::from(num) * 4.0 / f64::from(den)).clamp(0.25, 64.0) }
|
||||
}
|
||||
|
||||
/// The LFO phase at `n` samples into the block when Beat Clock follows a playing transport.
|
||||
/// Beat Clock restarts the LFO at the start of every Duration and runs it at Rate in between,
|
||||
/// so a Rate slower than the session tempo plays only part of each cycle before the restart.
|
||||
fn beat_clock_phase(&self, n: usize, hz: f64) -> Option<f64> {
|
||||
let t = &self.transport;
|
||||
if !(t.playing && t.tempo.is_finite() && t.tempo > 0.0 && t.quarters.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let duration = self.p.choice(10);
|
||||
let bar = self.bar_quarters();
|
||||
let dur = duration_quarters(duration, bar);
|
||||
let elapsed = self.since_transport.saturating_add(n as u64) as f64;
|
||||
let quarters = t.quarters + elapsed * t.tempo / 60.0 / f64::from(self.sr);
|
||||
// Bar-based cycles start on the bar line, so a change of time signature does not shift them;
|
||||
// note and beat cycles follow the song position in quarter notes.
|
||||
let into = match duration_bars(duration) {
|
||||
Some(bars) => {
|
||||
let in_bar = if t.bar_start.is_finite() { quarters - t.bar_start } else { quarters };
|
||||
((t.bar.rem_euclid(bars) as f64 + in_bar / bar) / bars as f64).rem_euclid(1.0)
|
||||
}
|
||||
None => (quarters / dur).rem_euclid(1.0),
|
||||
};
|
||||
// Cycles of the LFO per Duration: 1 when its rate matches the session tempo.
|
||||
let ratio = hz * dur * 60.0 / t.tempo;
|
||||
Some((into * ratio).rem_euclid(1.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for AutoPan {
|
||||
param_plumbing!(AUTOPAN_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.ch = sane_channels(channels);
|
||||
for s in [&mut self.output, &mut self.width, &mut self.manual] {
|
||||
s.set_time(20.0, self.sr);
|
||||
}
|
||||
self.position.set_time(2.0, self.sr);
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.restart();
|
||||
self.env = 0.0;
|
||||
for s in [&mut self.output, &mut self.width, &mut self.manual] {
|
||||
s.snap();
|
||||
}
|
||||
self.snap_position = true;
|
||||
}
|
||||
|
||||
fn set_transport(&mut self, transport: &Transport) {
|
||||
self.transport = *transport;
|
||||
self.since_transport = 0;
|
||||
if transport.playing && !self.was_playing && self.p.choice(7) == TRIGGER_SINGLE {
|
||||
self.restart();
|
||||
}
|
||||
self.was_playing = transport.playing;
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.ch.min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let hz = self.lfo_hz();
|
||||
let inc = if hz.is_finite() { (hz / f64::from(self.sr)).clamp(0.0, 0.5) } else { 0.0 };
|
||||
let waveform = self.p.choice(6);
|
||||
let trigger = self.p.choice(7);
|
||||
let envelope_source = self.p.choice(3) == SOURCE_ENVELOPE;
|
||||
let sign = if self.p.choice(4) == PATH_RIGHT_TO_LEFT { -1.0 } else { 1.0 };
|
||||
let threshold_db = self.p.v(11);
|
||||
let threshold = db_to_gain(threshold_db);
|
||||
let attack = time_coef(self.p.v(12), self.sr);
|
||||
let release = time_coef(self.p.v(13), self.sr);
|
||||
let release_samples = ms_to_samples(self.p.v(13), self.sr) as u64;
|
||||
for n in 0..frames {
|
||||
let sample = |io: &[Vec<f32>], c: usize| io.get(c).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let left = sample(io, 0);
|
||||
let right = if ch >= 2 { sample(io, 1) } else { left };
|
||||
// The louder channel drives the follower, so anti-phase material still has a level.
|
||||
let level = left.abs().max(right.abs());
|
||||
let k = if level > self.env { attack } else { release };
|
||||
self.env = level + (self.env - level) * k;
|
||||
if self.env < 1.0e-12 {
|
||||
self.env = 0.0;
|
||||
}
|
||||
if trigger == TRIGGER_ENVELOPE {
|
||||
if self.armed && self.env >= threshold {
|
||||
self.armed = false;
|
||||
self.phase = 0.0;
|
||||
self.holding = false;
|
||||
self.below = 0;
|
||||
} else if !self.armed {
|
||||
if self.env < threshold {
|
||||
self.below = self.below.saturating_add(1);
|
||||
self.armed = self.below >= release_samples;
|
||||
} else {
|
||||
self.below = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let lfo_phase = match (trigger == TRIGGER_BEAT_CLOCK).then(|| self.beat_clock_phase(n, hz)).flatten() {
|
||||
Some(phase) => phase,
|
||||
None if self.holding => self.phase,
|
||||
None => {
|
||||
let current = self.phase;
|
||||
self.phase += inc;
|
||||
if self.phase >= 1.0 {
|
||||
if matches!(trigger, TRIGGER_FREE | TRIGGER_BEAT_CLOCK) {
|
||||
self.phase -= 1.0;
|
||||
} else {
|
||||
self.phase = 1.0;
|
||||
self.holding = true;
|
||||
}
|
||||
}
|
||||
current
|
||||
}
|
||||
};
|
||||
let modulator = if envelope_source {
|
||||
let above = (gain_to_db(self.env) - threshold_db) / -threshold_db;
|
||||
2.0 * above.clamp(0.0, 1.0) - 1.0
|
||||
} else {
|
||||
wave_at(waveform, lfo_phase as f32)
|
||||
};
|
||||
let width = self.width.next();
|
||||
let manual = self.manual.next();
|
||||
self.position.set(manual * (1.0 - width) + sign * modulator * width);
|
||||
if self.snap_position {
|
||||
self.snap_position = false;
|
||||
self.position.snap();
|
||||
}
|
||||
let position = self.position.next();
|
||||
let out = self.output.next();
|
||||
if ch >= 2 {
|
||||
// Constant power: sin² + cos² = 1 across the field, and both gains are 1 at the center.
|
||||
let theta = (position.clamp(-1.0, 1.0) + 1.0) * FRAC_PI_4;
|
||||
let (l, r) = (theta.cos().max(0.0), theta.sin().max(0.0));
|
||||
if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
|
||||
*x = left * l * SQRT_2 * out;
|
||||
}
|
||||
if let Some(x) = io.get_mut(1).and_then(|b| b.get_mut(n)) {
|
||||
*x = right * r * SQRT_2 * out;
|
||||
}
|
||||
for c in 2..ch {
|
||||
if let Some(x) = io.get_mut(c).and_then(|b| b.get_mut(n)) {
|
||||
*x = if x.is_finite() { *x * out } else { 0.0 };
|
||||
}
|
||||
}
|
||||
} else if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
|
||||
*x = left * out;
|
||||
}
|
||||
}
|
||||
self.since_transport = self.since_transport.saturating_add(frames as u64);
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,65 @@ impl Default for DrumSynth {
|
||||
}
|
||||
}
|
||||
|
||||
/// One output sample of a sounding voice of `kind`, advancing its envelopes.
|
||||
fn voice_sample(v: &mut DrumVoice, kind: Kind, sr: f32, clap_gap: usize) -> f32 {
|
||||
match kind {
|
||||
Kind::Kick | Kind::Tom(_) => {
|
||||
v.pitch_env *= v.pitch_coef;
|
||||
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
|
||||
v.phase += f / sr;
|
||||
v.phase -= v.phase.floor();
|
||||
let tone = (v.phase * TAU).sin() * v.amp;
|
||||
let click = v.filt.process_sample(v.rng.bipolar()) * v.noise_amp;
|
||||
v.amp *= v.amp_coef;
|
||||
v.noise_amp *= v.noise_coef;
|
||||
tone + click
|
||||
}
|
||||
Kind::Snare => {
|
||||
v.pitch_env *= v.pitch_coef;
|
||||
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
|
||||
v.phase += f / sr;
|
||||
v.phase -= v.phase.floor();
|
||||
let tone = (v.phase * TAU).sin() * v.amp * 0.6;
|
||||
let n = v.filt2.process_sample(v.filt.process_sample(v.rng.bipolar())) * v.noise_amp * 0.7;
|
||||
v.amp *= v.amp_coef;
|
||||
v.noise_amp *= v.noise_coef;
|
||||
tone + n
|
||||
}
|
||||
Kind::Clap => {
|
||||
// Three quick bursts then a diffuse tail.
|
||||
let burst = if v.t < clap_gap * 3 {
|
||||
if v.t.is_multiple_of(clap_gap) {
|
||||
v.amp = 1.0;
|
||||
}
|
||||
v.amp
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let env = burst.max(if v.t >= clap_gap * 2 { v.noise_amp * 0.6 } else { 0.0 });
|
||||
let y = v.filt.process_sample(v.rng.bipolar()) * env * 2.0;
|
||||
v.amp *= v.amp_coef;
|
||||
if v.t >= clap_gap * 2 {
|
||||
v.noise_amp *= v.noise_coef;
|
||||
}
|
||||
y
|
||||
}
|
||||
Kind::ClosedHat | Kind::OpenHat | Kind::Crash | Kind::Ride => {
|
||||
let scale = if matches!(kind, Kind::Crash | Kind::Ride) { 1.0 } else { 1.9 };
|
||||
let mut metal = 0.0;
|
||||
for (m, f) in v.metal.iter_mut().zip(METAL) {
|
||||
*m += f * scale / sr;
|
||||
*m -= m.floor();
|
||||
metal += if *m < 0.5 { 1.0 } else { -1.0 };
|
||||
}
|
||||
let raw = metal / 6.0 * (1.0 - v.noise_amp * 0.5) + v.rng.bipolar() * v.noise_amp;
|
||||
let y = v.filt2.process_sample(v.filt.process_sample(raw)) * v.amp;
|
||||
v.amp *= v.amp_coef;
|
||||
y
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DrumSynth {
|
||||
pub fn new() -> Self {
|
||||
let mut voices = [DrumVoice::new(1); VOICES];
|
||||
@@ -246,61 +305,7 @@ impl DrumSynth {
|
||||
let mut out = 0.0;
|
||||
for v in &mut self.voices {
|
||||
let Some(kind) = v.kind else { continue };
|
||||
let y = match kind {
|
||||
Kind::Kick | Kind::Tom(_) => {
|
||||
v.pitch_env *= v.pitch_coef;
|
||||
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
|
||||
v.phase += f / sr;
|
||||
v.phase -= v.phase.floor();
|
||||
let tone = (v.phase * TAU).sin() * v.amp;
|
||||
let click = v.filt.process_sample(v.rng.bipolar()) * v.noise_amp;
|
||||
v.amp *= v.amp_coef;
|
||||
v.noise_amp *= v.noise_coef;
|
||||
tone + click
|
||||
}
|
||||
Kind::Snare => {
|
||||
v.pitch_env *= v.pitch_coef;
|
||||
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
|
||||
v.phase += f / sr;
|
||||
v.phase -= v.phase.floor();
|
||||
let tone = (v.phase * TAU).sin() * v.amp * 0.6;
|
||||
let n = v.filt2.process_sample(v.filt.process_sample(v.rng.bipolar())) * v.noise_amp * 0.7;
|
||||
v.amp *= v.amp_coef;
|
||||
v.noise_amp *= v.noise_coef;
|
||||
tone + n
|
||||
}
|
||||
Kind::Clap => {
|
||||
// Three quick bursts then a diffuse tail.
|
||||
let burst = if v.t < clap_gap * 3 {
|
||||
if v.t % clap_gap == 0 {
|
||||
v.amp = 1.0;
|
||||
}
|
||||
v.amp
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let env = burst.max(if v.t >= clap_gap * 2 { v.noise_amp * 0.6 } else { 0.0 });
|
||||
let y = v.filt.process_sample(v.rng.bipolar()) * env * 2.0;
|
||||
v.amp *= v.amp_coef;
|
||||
if v.t >= clap_gap * 2 {
|
||||
v.noise_amp *= v.noise_coef;
|
||||
}
|
||||
y
|
||||
}
|
||||
Kind::ClosedHat | Kind::OpenHat | Kind::Crash | Kind::Ride => {
|
||||
let scale = if matches!(kind, Kind::Crash | Kind::Ride) { 1.0 } else { 1.9 };
|
||||
let mut metal = 0.0;
|
||||
for (m, f) in v.metal.iter_mut().zip(METAL) {
|
||||
*m += f * scale / sr;
|
||||
*m -= m.floor();
|
||||
metal += if *m < 0.5 { 1.0 } else { -1.0 };
|
||||
}
|
||||
let raw = metal / 6.0 * (1.0 - v.noise_amp * 0.5) + v.rng.bipolar() * v.noise_amp;
|
||||
let y = v.filt2.process_sample(v.filt.process_sample(raw)) * v.amp;
|
||||
v.amp *= v.amp_coef;
|
||||
y
|
||||
}
|
||||
};
|
||||
let y = voice_sample(v, kind, sr, clap_gap);
|
||||
v.t += 1;
|
||||
let alive = match kind {
|
||||
Kind::Clap => v.t < clap_gap * 3 || v.noise_amp > OFF,
|
||||
@@ -387,7 +392,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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction) and Rectifier.
|
||||
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction), Rectifier and
|
||||
//! Subharmonic (octave-down bass synthesis).
|
||||
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing};
|
||||
use crate::util::{DcBlocker, Rng, Smoother, flush, frames_in, sane_channels, sane_sr};
|
||||
use crate::biquad::{Biquad, FilterType};
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
|
||||
use crate::util::{DcBlocker, Rng, Smoother, flush, frames_in, sane_channels, sane_sr, time_coef};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain};
|
||||
|
||||
static SAT_PARAMS: [ParamInfo; 5] = [
|
||||
@@ -307,3 +309,289 @@ impl Plugin for Rectifier {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const SUB_RANGES: &[&str] = &["120-180 Hz", "80-120 Hz", "60-90 Hz", "40-60 Hz"];
|
||||
/// Lower and upper band frequency of each range.
|
||||
const SUB_RANGE_HZ: [(f32, f32); 4] = [(120.0, 180.0), (80.0, 120.0), (60.0, 90.0), (40.0, 60.0)];
|
||||
const SUB_CHARACTERS: &[&str] = &["Clean I", "Clean II", "Resonant I", "Resonant II", "Distort I", "Distort II"];
|
||||
/// Band gains at or below this are off.
|
||||
const SUB_OFF_DB: f32 = -120.0;
|
||||
/// Q of the band-pass that picks each band out of the input.
|
||||
const SUB_BAND_Q: f32 = 2.0;
|
||||
/// Cutoff of the low-pass after each divider, as a fraction of the band frequency.
|
||||
const SUB_SMOOTH: f32 = 0.75;
|
||||
/// Zero-crossing hysteresis, as a fraction of the band's envelope.
|
||||
const SUB_HYSTERESIS: f32 = 0.1;
|
||||
/// Release of the band envelope that sets the hysteresis.
|
||||
const SUB_ENV_RELEASE_MS: f32 = 50.0;
|
||||
/// Bias of the asymmetric Distort I curve.
|
||||
const SUB_ASYM: f32 = 0.25;
|
||||
|
||||
static SUB_PARAMS: [ParamInfo; 17] = [
|
||||
lin("input", "Input", -18.0, 18.0, 0.0, Unit::Db),
|
||||
choice("range", "Range", SUB_RANGES, 2),
|
||||
lin("lower_gain", "Lower Gain", SUB_OFF_DB, 24.0, 0.0, Unit::Db),
|
||||
lin("upper_gain", "Upper Gain", SUB_OFF_DB, 24.0, -6.0, Unit::Db),
|
||||
lin("direct_gain", "Direct Gain", SUB_OFF_DB, 24.0, SUB_OFF_DB, Unit::Db),
|
||||
toggle("lower_solo", "Lower Solo", false),
|
||||
toggle("upper_solo", "Upper Solo", false),
|
||||
toggle("direct_solo", "Direct Solo", false),
|
||||
lin("drive", "Drive", -18.0, 18.0, 0.0, Unit::Db),
|
||||
choice("character", "Character", SUB_CHARACTERS, 0),
|
||||
log("hp_freq", "HP Freq", 5.0, 1000.0, 20.0, Unit::Hz),
|
||||
log("hp_q", "HP Q", 0.1, 5.0, 0.707, Unit::None),
|
||||
log("lp_freq", "LP Freq", 5.0, 1000.0, 200.0, Unit::Hz),
|
||||
log("lp_q", "LP Q", 0.1, 5.0, 0.707, Unit::None),
|
||||
lin("mix", "Mix", 0.0, 100.0, 50.0, Unit::Percent),
|
||||
toggle("wet_solo", "Wet Solo", false),
|
||||
lin("output", "Output", -18.0, 18.0, 0.0, Unit::Db),
|
||||
];
|
||||
|
||||
pub static SUB_INFO: PluginInfo = PluginInfo {
|
||||
id: "subharmonic",
|
||||
name: "Subharmonic",
|
||||
short_name: "SubHarm",
|
||||
category: Category::Harmonic,
|
||||
params: &SUB_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// One synthesized band: a band-pass, a divide-by-two flip-flop and a smoothing low-pass.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct SubBand {
|
||||
band: Biquad,
|
||||
smooth: Biquad,
|
||||
/// Divider output: ±1, flipped on every rising zero crossing of the band.
|
||||
flip: f32,
|
||||
/// The band has gone below the negative threshold since the last flip.
|
||||
armed: bool,
|
||||
env: f32,
|
||||
}
|
||||
|
||||
impl SubBand {
|
||||
fn new() -> Self {
|
||||
Self { band: Biquad::default(), smooth: Biquad::default(), flip: 1.0, armed: false, env: 0.0 }
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.band.reset();
|
||||
self.smooth.reset();
|
||||
self.flip = 1.0;
|
||||
self.armed = false;
|
||||
self.env = 0.0;
|
||||
}
|
||||
|
||||
/// The octave below the band's part of `x`; `rel` is the envelope's release coefficient.
|
||||
#[inline]
|
||||
fn process(&mut self, x: f32, rel: f32) -> f32 {
|
||||
let b = self.band.process_sample(x);
|
||||
let a = b.abs();
|
||||
self.env = flush(if a > self.env { a } else { self.env * rel });
|
||||
let th = self.env * SUB_HYSTERESIS;
|
||||
if b < -th {
|
||||
self.armed = true;
|
||||
} else if self.armed && b > th {
|
||||
self.armed = false;
|
||||
self.flip = -self.flip;
|
||||
}
|
||||
self.smooth.process_sample(b * self.flip)
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter and divider state of one channel.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct SubChannel {
|
||||
/// The lower and the upper band.
|
||||
bands: [SubBand; 2],
|
||||
/// Low-pass that takes the direct bass out of the input.
|
||||
direct: Biquad,
|
||||
hp: Biquad,
|
||||
lp: Biquad,
|
||||
dc: DcBlocker,
|
||||
}
|
||||
|
||||
impl SubChannel {
|
||||
fn new(sr: f32) -> Self {
|
||||
Self { bands: [SubBand::new(); 2], direct: Biquad::default(), hp: Biquad::default(), lp: Biquad::default(), dc: DcBlocker::new(5.0, sr) }
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.bands.iter_mut().for_each(SubBand::reset);
|
||||
self.direct.reset();
|
||||
self.hp.reset();
|
||||
self.lp.reset();
|
||||
self.dc.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// Subharmonic synthesizer. The Range sets a lower and an upper band frequency. Each band
|
||||
/// band-passes the input at its frequency, and a divide-by-two flip-flop on the band's rising zero
|
||||
/// crossings multiplies the band, which moves it an octave down at a level that follows the band.
|
||||
/// The direct band is the input low-passed at the upper band frequency.
|
||||
///
|
||||
/// - The three bands are summed with their gains, raised by Drive and saturated, then
|
||||
/// shaped by the HP and LP filters. A soloed band plays alone and mutes the dry signal.
|
||||
/// - Clean I saturates with tanh and Clean II with a softer algebraic curve. Resonant I and II use
|
||||
/// tanh and raise the Q of the low-pass after each divider to 2 and 4. Distort I bends the curve
|
||||
/// asymmetrically (even harmonics, DC removed) and Distort II clips hard at full scale.
|
||||
/// - Below 50 % Mix the dry signal stays at full level and the wet level falls; above 50 % the wet
|
||||
/// signal stays at full level and the dry level falls. Wet Solo mutes the dry signal.
|
||||
pub struct SubHarmonic {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
ch: Vec<SubChannel>,
|
||||
input: Smoother,
|
||||
bands: [Smoother; 3],
|
||||
drive: Smoother,
|
||||
dry: Smoother,
|
||||
wet: Smoother,
|
||||
out: Smoother,
|
||||
env_rel: f32,
|
||||
}
|
||||
|
||||
impl Default for SubHarmonic {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SubHarmonic {
|
||||
pub fn new() -> Self {
|
||||
let mut s = Self {
|
||||
p: Params::new(&SUB_PARAMS),
|
||||
sr: 48_000.0,
|
||||
ch: Vec::new(),
|
||||
input: Smoother::new(1.0),
|
||||
bands: [Smoother::new(0.0); 3],
|
||||
drive: Smoother::new(1.0),
|
||||
dry: Smoother::new(1.0),
|
||||
wet: Smoother::new(1.0),
|
||||
out: Smoother::new(1.0),
|
||||
env_rel: 0.0,
|
||||
};
|
||||
s.update();
|
||||
s
|
||||
}
|
||||
|
||||
/// The lower and upper band frequencies of the current range.
|
||||
fn range_hz(&self) -> (f32, f32) {
|
||||
SUB_RANGE_HZ.get(self.p.choice(1)).copied().unwrap_or((60.0, 90.0))
|
||||
}
|
||||
|
||||
/// Q of the low-pass after each divider for the current Drive Character.
|
||||
fn smooth_q(&self) -> f32 {
|
||||
match self.p.choice(9) {
|
||||
2 => 2.0,
|
||||
3 => 4.0,
|
||||
_ => std::f32::consts::FRAC_1_SQRT_2,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
let gain = |db: f32| if db <= SUB_OFF_DB { 0.0 } else { db_to_gain(db) };
|
||||
let solo = [self.p.on(5), self.p.on(6), self.p.on(7)];
|
||||
let any_solo = solo.contains(&true);
|
||||
for (k, s) in self.bands.iter_mut().enumerate() {
|
||||
let heard = !any_solo || solo.get(k).copied().unwrap_or(false);
|
||||
s.set(if heard { gain(self.p.v(2 + k)) } else { 0.0 });
|
||||
}
|
||||
self.input.set(db_to_gain(self.p.v(0)));
|
||||
self.drive.set(db_to_gain(self.p.v(8)));
|
||||
self.out.set(db_to_gain(self.p.v(16)));
|
||||
let m = self.p.v(14) / 100.0;
|
||||
self.wet.set((2.0 * m).min(1.0));
|
||||
self.dry.set(if any_solo || self.p.on(15) { 0.0 } else { (2.0 * (1.0 - m)).min(1.0) });
|
||||
self.env_rel = time_coef(SUB_ENV_RELEASE_MS, self.sr);
|
||||
|
||||
let (lo, hi) = self.range_hz();
|
||||
let sq = self.smooth_q();
|
||||
let sr = self.sr;
|
||||
let (hp_f, hp_q, lp_f, lp_q) = (self.p.v(10), self.p.v(11), self.p.v(12), self.p.v(13));
|
||||
for c in &mut self.ch {
|
||||
for (b, f) in c.bands.iter_mut().zip([lo, hi]) {
|
||||
b.band.set(FilterType::BandPass, f, SUB_BAND_Q, 0.0, sr);
|
||||
b.smooth.set(FilterType::LowPass, f * SUB_SMOOTH, sq, 0.0, sr);
|
||||
}
|
||||
c.direct.set(FilterType::LowPass, hi, std::f32::consts::FRAC_1_SQRT_2, 0.0, sr);
|
||||
c.hp.set(FilterType::HighPass, hp_f, hp_q, 0.0, sr);
|
||||
c.lp.set(FilterType::LowPass, lp_f, lp_q, 0.0, sr);
|
||||
}
|
||||
}
|
||||
|
||||
fn smoothers(&mut self) -> impl Iterator<Item = &mut Smoother> {
|
||||
[&mut self.input, &mut self.drive, &mut self.dry, &mut self.wet, &mut self.out].into_iter().chain(self.bands.iter_mut())
|
||||
}
|
||||
}
|
||||
|
||||
/// Saturation curve of each Drive Character (index into `SUB_CHARACTERS`).
|
||||
fn sub_saturate(character: usize, x: f32) -> f32 {
|
||||
match character {
|
||||
1 => x / (1.0 + x * x).sqrt(),
|
||||
4 => ((x + SUB_ASYM).tanh() - SUB_ASYM.tanh()) / (1.0 + SUB_ASYM.tanh()),
|
||||
5 => x.clamp(-1.0, 1.0),
|
||||
_ => x.tanh(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for SubHarmonic {
|
||||
param_plumbing!(SUB_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.ch = vec![SubChannel::new(self.sr); sane_channels(channels)];
|
||||
let sr = self.sr;
|
||||
for s in self.smoothers() {
|
||||
s.set_time(20.0, sr);
|
||||
}
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ch.iter_mut().for_each(SubChannel::reset);
|
||||
for s in self.smoothers() {
|
||||
s.snap();
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.ch.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let character = self.p.choice(9);
|
||||
let rel = self.env_rel;
|
||||
for n in 0..frames {
|
||||
let gin = self.input.next();
|
||||
let g = [self.bands[0].next(), self.bands[1].next(), self.bands[2].next()];
|
||||
let drive = self.drive.next();
|
||||
let dg = self.dry.next();
|
||||
let wg = self.wet.next();
|
||||
let o = self.out.next();
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(st)) = (io.get_mut(c), self.ch.get_mut(c)) else { continue };
|
||||
let Some(x) = buf.get_mut(n) else { continue };
|
||||
let dry = if x.is_finite() { *x * gin } else { 0.0 };
|
||||
let mut sum = st.direct.process_sample(dry) * g[2];
|
||||
for (band, gain) in st.bands.iter_mut().zip(g) {
|
||||
sum += band.process(dry, rel) * gain;
|
||||
}
|
||||
let mut y = sub_saturate(character, sum * drive);
|
||||
if character == 4 {
|
||||
y = st.dc.process(y);
|
||||
}
|
||||
let wet = st.lp.process_sample(st.hp.process_sample(y));
|
||||
*x = (dry * dg + wet * wg) * o;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
// Time for the slowest, most resonant filter to ring down by 60 dB (6.9 time constants).
|
||||
let (lo, _) = self.range_hz();
|
||||
let f = self.p.v(10).min(self.p.v(12)).min(lo * SUB_SMOOTH).max(1.0);
|
||||
let q = self.p.v(11).max(self.p.v(13)).max(self.smooth_q()).max(SUB_BAND_Q);
|
||||
(6.9 * q / (std::f32::consts::PI * f) * self.sr) as usize
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The built-in plugins and their registry.
|
||||
|
||||
pub mod auto_pan;
|
||||
pub mod drums;
|
||||
pub mod dynamics;
|
||||
pub mod eq;
|
||||
@@ -7,6 +8,7 @@ pub mod harmonic;
|
||||
pub mod modulation;
|
||||
pub mod pitch;
|
||||
pub mod reverb;
|
||||
pub mod sampler;
|
||||
pub mod strip;
|
||||
pub mod synth;
|
||||
pub mod utility;
|
||||
@@ -25,12 +27,15 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[
|
||||
&reverb::ROOM_INFO,
|
||||
&reverb::PLATE_INFO,
|
||||
&modulation::DELAY_INFO,
|
||||
&auto_pan::AUTOPAN_INFO,
|
||||
&modulation::CHORUS_INFO,
|
||||
&modulation::FLANGER_INFO,
|
||||
&modulation::PHASER_INFO,
|
||||
&modulation::RING_INFO,
|
||||
&harmonic::SAT_INFO,
|
||||
&harmonic::LOFI_INFO,
|
||||
&harmonic::RECT_INFO,
|
||||
&harmonic::SUB_INFO,
|
||||
&pitch::PITCH_INFO,
|
||||
&utility::SHIFT_INFO,
|
||||
&utility::GAIN_INFO,
|
||||
@@ -41,6 +46,7 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[
|
||||
&utility::DITHER_INFO,
|
||||
&synth::SYNTH_INFO,
|
||||
&drums::DRUM_INFO,
|
||||
&sampler::SAMPLER_INFO,
|
||||
];
|
||||
|
||||
/// Constructs an (unprepared) plugin by id.
|
||||
@@ -56,12 +62,15 @@ pub(crate) fn instantiate(id: &str) -> Option<Box<dyn Plugin>> {
|
||||
"room_reverb" => Box::new(reverb::Reverb::room()),
|
||||
"plate_reverb" => Box::new(reverb::Reverb::plate()),
|
||||
"mod_delay" => Box::new(modulation::ModDelay::new()),
|
||||
"auto_pan" => Box::new(auto_pan::AutoPan::new()),
|
||||
"chorus" => Box::new(modulation::ModFx::chorus()),
|
||||
"flanger" => Box::new(modulation::ModFx::flanger()),
|
||||
"phaser" => Box::new(modulation::Phaser::new()),
|
||||
"ring_mod" => Box::new(modulation::RingMod::new()),
|
||||
"saturator" => Box::new(harmonic::Saturator::new()),
|
||||
"lofi" => Box::new(harmonic::LoFi::new()),
|
||||
"rectifier" => Box::new(harmonic::Rectifier::new()),
|
||||
"subharmonic" => Box::new(harmonic::SubHarmonic::new()),
|
||||
"pitch_shifter" => Box::new(pitch::PitchShifter::new()),
|
||||
"time_shift" => Box::new(utility::TimeShift::new()),
|
||||
"gain" => Box::new(utility::Gain::new()),
|
||||
@@ -72,6 +81,7 @@ pub(crate) fn instantiate(id: &str) -> Option<Box<dyn Plugin>> {
|
||||
"dither" => Box::new(utility::Dither::new()),
|
||||
"subtractive_synth" => Box::new(synth::SubtractiveSynth::new()),
|
||||
"drum_synth" => Box::new(drums::DrumSynth::new()),
|
||||
"sampler" => Box::new(sampler::Sampler::new()),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Delay and modulation effects: Mod Delay, Chorus, Flanger, Phaser.
|
||||
//! Delay and modulation effects: Mod Delay, Chorus, Flanger, Phaser, Ring Modulator.
|
||||
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing};
|
||||
use crate::util::{DelayLine, Lfo, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr};
|
||||
use crate::util::{DelayLine, Lfo, Rng, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr, time_coef};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit};
|
||||
|
||||
/// Per-channel LFO phase offset for stereo movement (channel 1 is offset by `spread` cycles).
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,3 +459,221 @@ impl Plugin for Phaser {
|
||||
ms_to_samples(100.0, self.sr) as usize
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const RING_EFFECTS: &[&str] = &["Ring", "Frequency Mod", "Resonator +", "Resonator -"];
|
||||
const RING_SOURCES: &[&str] = &["LFO", "Envelope", "Sample & Hold"];
|
||||
const RING_MIN_HZ: f32 = 20.0;
|
||||
/// Octaves the frequency moves at full modulation depth.
|
||||
const RING_MOD_OCTAVES: f32 = 2.0;
|
||||
/// Peak delay swing of Frequency Mod.
|
||||
const RING_FM_SWING_MS: f32 = 1.0;
|
||||
/// Sample-and-hold noise seed (the same sequence after every reset).
|
||||
const RING_SEED: u32 = 0x5EED_1234;
|
||||
|
||||
static RING_PARAMS: [ParamInfo; 7] = [
|
||||
choice("effect", "Effect", RING_EFFECTS, 0),
|
||||
log("frequency", "Frequency", RING_MIN_HZ, 5000.0, 440.0, Unit::Hz),
|
||||
lin("resonance", "Resonance", 0.0, 95.0, 50.0, Unit::Percent),
|
||||
choice("mod_source", "Mod Source", RING_SOURCES, 0),
|
||||
log("mod_rate", "Mod Rate", 0.05, 20.0, 1.0, Unit::Hz),
|
||||
lin("mod_depth", "Mod Depth", 0.0, 100.0, 0.0, Unit::Percent),
|
||||
lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
];
|
||||
|
||||
pub static RING_INFO: PluginInfo = PluginInfo {
|
||||
id: "ring_mod",
|
||||
name: "Ring Modulator",
|
||||
short_name: "RingMod",
|
||||
category: Category::Modulation,
|
||||
params: &RING_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// Ring modulation, frequency modulation and positive or negative comb resonators, with the
|
||||
/// frequency moved by an LFO, an envelope follower or a sample-and-hold source.
|
||||
///
|
||||
/// - Ring multiplies the input by a sine carrier at the frequency.
|
||||
/// - Frequency Mod reads the input through a delay that the carrier swings by ±1 ms.
|
||||
/// - Resonator + and Resonator - feed the output back after one period of the frequency, with
|
||||
/// a positive or a negative sign. The output is scaled by `1 - resonance`, so the peak gain at
|
||||
/// resonance is unity.
|
||||
///
|
||||
/// At 100 % depth the modulation moves the frequency by up to two octaves either way. The
|
||||
/// envelope follower moves it up only.
|
||||
pub struct RingMod {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
lines: Vec<DelayLine>,
|
||||
lfo: Lfo,
|
||||
rng: Rng,
|
||||
carrier: f32,
|
||||
env: f32,
|
||||
env_attack: f32,
|
||||
env_release: f32,
|
||||
held: f32,
|
||||
hold_left: usize,
|
||||
last_effect: usize,
|
||||
freq: Smoother,
|
||||
res: Smoother,
|
||||
depth: Smoother,
|
||||
mix: Smoother,
|
||||
}
|
||||
|
||||
impl Default for RingMod {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RingMod {
|
||||
pub fn new() -> Self {
|
||||
let mut r = Self {
|
||||
p: Params::new(&RING_PARAMS),
|
||||
sr: 48_000.0,
|
||||
lines: Vec::new(),
|
||||
lfo: Lfo::new(),
|
||||
rng: Rng::new(RING_SEED),
|
||||
carrier: 0.0,
|
||||
env: 0.0,
|
||||
env_attack: 0.0,
|
||||
env_release: 0.0,
|
||||
held: 0.0,
|
||||
hold_left: 0,
|
||||
last_effect: 0,
|
||||
freq: Smoother::new(440.0),
|
||||
res: Smoother::new(0.0),
|
||||
depth: Smoother::new(0.0),
|
||||
mix: Smoother::new(1.0),
|
||||
};
|
||||
r.update();
|
||||
r
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.freq.set(self.p.v(1));
|
||||
self.res.set(self.p.v(2) / 100.0);
|
||||
self.depth.set(self.p.v(5) / 100.0);
|
||||
self.mix.set(self.p.v(6) / 100.0);
|
||||
}
|
||||
|
||||
/// Samples in one period of the lowest frequency the modulation can reach.
|
||||
fn longest_period(&self) -> f32 {
|
||||
self.sr / (self.p.v(1) * 2f32.powf(-RING_MOD_OCTAVES * self.p.v(5) / 100.0)).max(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for RingMod {
|
||||
param_plumbing!(RING_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
let comb = self.sr / (RING_MIN_HZ * 2f32.powf(-RING_MOD_OCTAVES));
|
||||
let max = comb.max(ms_to_samples(2.0 * RING_FM_SWING_MS, self.sr)) as usize + 8;
|
||||
self.lines = (0..sane_channels(channels))
|
||||
.map(|_| {
|
||||
let mut d = DelayLine::default();
|
||||
d.allocate(max);
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
self.env_attack = time_coef(5.0, self.sr);
|
||||
self.env_release = time_coef(100.0, self.sr);
|
||||
for s in [&mut self.freq, &mut self.res, &mut self.depth, &mut self.mix] {
|
||||
s.set_time(20.0, self.sr);
|
||||
}
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.lines.iter_mut().for_each(DelayLine::clear);
|
||||
self.lfo.reset();
|
||||
self.rng = Rng::new(RING_SEED);
|
||||
self.carrier = 0.0;
|
||||
self.env = 0.0;
|
||||
self.held = 0.0;
|
||||
self.hold_left = 0;
|
||||
self.last_effect = self.p.choice(0);
|
||||
for s in [&mut self.freq, &mut self.res, &mut self.depth, &mut self.mix] {
|
||||
s.snap();
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.lines.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let effect = self.p.choice(0);
|
||||
if effect != self.last_effect {
|
||||
// The lines hold the previous effect's state.
|
||||
self.lines.iter_mut().for_each(DelayLine::clear);
|
||||
self.last_effect = effect;
|
||||
}
|
||||
let source = self.p.choice(3);
|
||||
let rate = self.p.v(4);
|
||||
let inc = rate / self.sr;
|
||||
let hold = ((self.sr / rate.max(0.05)) as usize).max(1);
|
||||
let nyq = self.sr * 0.45;
|
||||
let swing = ms_to_samples(RING_FM_SWING_MS, self.sr);
|
||||
for n in 0..frames {
|
||||
let level = io.iter().take(ch).filter_map(|b| b.get(n)).filter(|x| x.is_finite()).fold(0.0f32, |m, x| m.max(x.abs()));
|
||||
let k = if level > self.env { self.env_attack } else { self.env_release };
|
||||
self.env = flush(level + (self.env - level) * k);
|
||||
let lfo = Lfo::sin_at(self.lfo.tick(inc));
|
||||
let m = match source {
|
||||
1 => self.env.min(1.0),
|
||||
2 => {
|
||||
if self.hold_left == 0 {
|
||||
self.held = self.rng.bipolar();
|
||||
self.hold_left = hold;
|
||||
}
|
||||
self.hold_left -= 1;
|
||||
self.held
|
||||
}
|
||||
_ => lfo,
|
||||
};
|
||||
let f = (self.freq.next() * 2f32.powf(RING_MOD_OCTAVES * self.depth.next() * m)).clamp(1.0, nyq);
|
||||
let fb = self.res.next();
|
||||
let mix = self.mix.next();
|
||||
self.carrier += f / self.sr;
|
||||
if self.carrier >= 1.0 {
|
||||
self.carrier -= 1.0;
|
||||
}
|
||||
let carrier = Lfo::sin_at(self.carrier);
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(line)) = (io.get_mut(c), self.lines.get_mut(c)) else { continue };
|
||||
let Some(x) = buf.get_mut(n) else { continue };
|
||||
let dry = if x.is_finite() { *x } else { 0.0 };
|
||||
let wet = match effect {
|
||||
1 => {
|
||||
line.push(dry);
|
||||
line.read_cubic(2.0 + swing * (1.0 + carrier))
|
||||
}
|
||||
2 | 3 => {
|
||||
let sign = if effect == 2 { 1.0 } else { -1.0 };
|
||||
let y = flush(dry + sign * fb * line.read_cubic(self.sr / f));
|
||||
line.push(y);
|
||||
y * (1.0 - fb)
|
||||
}
|
||||
_ => dry * carrier,
|
||||
};
|
||||
*x = dry * (1.0 - mix) + wet * mix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
match self.p.choice(0) {
|
||||
1 => ms_to_samples(2.0 * RING_FM_SWING_MS, self.sr) as usize + 4,
|
||||
2 | 3 => {
|
||||
// Time for the feedback to fall by 60 dB at the longest period the modulation reaches.
|
||||
let fb = (self.p.v(2) / 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 };
|
||||
(self.longest_period() * repeats) as usize
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +201,31 @@ impl Reverb {
|
||||
}
|
||||
}
|
||||
|
||||
/// Input sample `n` of the left and right channels (a mono input feeds both); non-finite reads as 0.
|
||||
fn input_pair(io: &[Vec<f32>], ch: usize, n: usize) -> (f32, f32) {
|
||||
let xl = io.first().and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let xr = if ch > 1 { io.get(1).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0) } else { xl };
|
||||
(xl, xr)
|
||||
}
|
||||
|
||||
/// Blend the wet signal `[mid, left, right]` into sample `n` of the first `ch` channels at mix `m`.
|
||||
fn mix_out(io: &mut [Vec<f32>], ch: usize, n: usize, wet: [f32; 3], m: f32) {
|
||||
let [mid, wl, wr] = wet;
|
||||
for (c, b) in io.iter_mut().take(ch).enumerate() {
|
||||
let wet = if ch == 1 {
|
||||
mid
|
||||
} else if c % 2 == 0 {
|
||||
wl
|
||||
} else {
|
||||
wr
|
||||
};
|
||||
if let Some(x) = b.get_mut(n) {
|
||||
let dry = if x.is_finite() { *x } else { 0.0 };
|
||||
*x = dry * (1.0 - m) + wet * m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for Reverb {
|
||||
fn info(&self) -> &'static PluginInfo {
|
||||
self.info
|
||||
@@ -279,8 +304,7 @@ impl Plugin for Reverb {
|
||||
let size = self.size.value();
|
||||
let base: [f32; LINES] = self.cfg.delays_ms.map(|ms| ms_to_samples(ms * size, sr));
|
||||
for n in 0..frames {
|
||||
let xl = io.first().and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let xr = if ch > 1 { io.get(1).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0) } else { xl };
|
||||
let (xl, xr) = input_pair(io, ch, n);
|
||||
let pre = ms_to_samples(self.pre_ms.next(), sr);
|
||||
let mut ins = [xl, xr];
|
||||
for (side, x) in ins.iter_mut().enumerate() {
|
||||
@@ -319,19 +343,7 @@ impl Plugin for Reverb {
|
||||
let side = 0.5 * (wl - wr) * w;
|
||||
let (wl, wr) = (mid + side, mid - side);
|
||||
let m = self.mix.next();
|
||||
for (c, b) in io.iter_mut().take(ch).enumerate() {
|
||||
let wet = if ch == 1 {
|
||||
mid
|
||||
} else if c % 2 == 0 {
|
||||
wl
|
||||
} else {
|
||||
wr
|
||||
};
|
||||
if let Some(x) = b.get_mut(n) {
|
||||
let dry = if x.is_finite() { *x } else { 0.0 };
|
||||
*x = dry * (1.0 - m) + wet * m;
|
||||
}
|
||||
}
|
||||
mix_out(io, ch, n, [mid, wl, wr], m);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,875 @@
|
||||
//! Sampler: plays one recorded sample across the keyboard, 32 voices.
|
||||
//!
|
||||
//! Each note plays the sample pitched from its root note (an octave up plays twice as fast) with
|
||||
//! 4-point cubic interpolation, which also converts the sample's own rate to the host's. Key and
|
||||
//! velocity ranges, an amp ADSR with velocity sensitivity, a sample start point, forward or
|
||||
//! ping-pong loops, and gate or one-shot playback. The host hands the audio over with
|
||||
//! [`Plugin::set_sample`]; without one the Sampler is silent.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::synth::{Adsr, AdsrRates, Event, EventKind, EventQueue, MAX_EVENTS, Stage};
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
|
||||
use crate::util::{Smoother, frames_in, ms_to_samples, sane_channels, sane_sr};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, SampleSource, Unit, db_to_gain};
|
||||
|
||||
const PLAY_MODES: &[&str] = &["Gate", "One Shot"];
|
||||
const LOOP_MODES: &[&str] = &["Off", "Forward", "Ping-Pong"];
|
||||
|
||||
const ROOT: usize = 0;
|
||||
const TRANSPOSE: usize = 1;
|
||||
const FINE: usize = 2;
|
||||
const KEY_TRACK: usize = 3;
|
||||
const KEY_LOW: usize = 4;
|
||||
const KEY_HIGH: usize = 5;
|
||||
const VEL_LOW: usize = 6;
|
||||
const VEL_HIGH: usize = 7;
|
||||
const VEL_SENS: usize = 8;
|
||||
const ATTACK: usize = 9;
|
||||
const DECAY: usize = 10;
|
||||
const SUSTAIN: usize = 11;
|
||||
const RELEASE: usize = 12;
|
||||
const PLAY_MODE: usize = 13;
|
||||
const START: usize = 14;
|
||||
const LOOP_MODE: usize = 15;
|
||||
const LOOP_START: usize = 16;
|
||||
const LOOP_END: usize = 17;
|
||||
const LEVEL: usize = 18;
|
||||
|
||||
static SAMPLER_PARAMS: [ParamInfo; 19] = [
|
||||
lin("root_note", "Root Note", 0.0, 127.0, 60.0, Unit::None),
|
||||
lin("transpose", "Transpose", -48.0, 48.0, 0.0, Unit::Semitones),
|
||||
lin("fine_tune", "Fine Tune", -100.0, 100.0, 0.0, Unit::Cents),
|
||||
toggle("key_track", "Key Tracking", true),
|
||||
lin("key_low", "Lowest Key", 0.0, 127.0, 0.0, Unit::None),
|
||||
lin("key_high", "Highest Key", 0.0, 127.0, 127.0, Unit::None),
|
||||
lin("vel_low", "Lowest Velocity", 1.0, 127.0, 1.0, Unit::None),
|
||||
lin("vel_high", "Highest Velocity", 1.0, 127.0, 127.0, Unit::None),
|
||||
lin("velocity", "Velocity Sens", 0.0, 100.0, 70.0, Unit::Percent),
|
||||
log("attack", "Attack", 0.1, 5000.0, 1.0, Unit::Ms),
|
||||
log("decay", "Decay", 1.0, 10000.0, 300.0, Unit::Ms),
|
||||
lin("sustain", "Sustain", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
log("release", "Release", 1.0, 10000.0, 150.0, Unit::Ms),
|
||||
choice("play_mode", "Play Mode", PLAY_MODES, 0),
|
||||
lin("start", "Sample Start", 0.0, 100.0, 0.0, Unit::Percent),
|
||||
choice("loop_mode", "Loop", LOOP_MODES, 0),
|
||||
lin("loop_start", "Loop Start", 0.0, 100.0, 0.0, Unit::Percent),
|
||||
lin("loop_end", "Loop End", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
lin("level", "Level", -48.0, 12.0, 0.0, Unit::Db),
|
||||
];
|
||||
|
||||
pub static SAMPLER_INFO: PluginInfo = PluginInfo {
|
||||
id: "sampler",
|
||||
name: "Sampler",
|
||||
short_name: "Sampler",
|
||||
category: Category::Instrument,
|
||||
params: &SAMPLER_PARAMS,
|
||||
is_instrument: true,
|
||||
audiosuite: false,
|
||||
};
|
||||
|
||||
const VOICES: usize = 32;
|
||||
/// Fastest playback step in sample frames per output frame (keeps hostile pitch bounded).
|
||||
const MAX_STEP: f64 = 256.0;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum LoopMode {
|
||||
Off,
|
||||
Forward,
|
||||
PingPong,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Voice {
|
||||
note: u8,
|
||||
/// Key still down (gate mode only; one-shot voices are never held).
|
||||
held: bool,
|
||||
gain: f32,
|
||||
/// Read position in sample frames.
|
||||
pos: f64,
|
||||
step: f64,
|
||||
forward: bool,
|
||||
looping: bool,
|
||||
env: Adsr,
|
||||
}
|
||||
|
||||
impl Voice {
|
||||
const fn new() -> Self {
|
||||
Self { note: 0, held: false, gain: 0.0, pos: 0.0, step: 1.0, forward: true, looping: false, env: Adsr::new() }
|
||||
}
|
||||
|
||||
fn active(&self) -> bool {
|
||||
self.env.stage != Stage::Idle
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
*self = Voice::new();
|
||||
}
|
||||
}
|
||||
|
||||
/// Values derived from the parameters (refreshed on every change).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Derived {
|
||||
root: f64,
|
||||
/// Transpose plus fine tune, in semitones.
|
||||
offset: f64,
|
||||
key_track: bool,
|
||||
keys: (u8, u8),
|
||||
vels: (u8, u8),
|
||||
sens: f32,
|
||||
one_shot: bool,
|
||||
start: f64,
|
||||
loop_mode: LoopMode,
|
||||
/// Loop start and end as fractions of the sample (start <= end).
|
||||
loop_span: (f64, f64),
|
||||
}
|
||||
|
||||
/// A MIDI key or velocity parameter as a whole number in `lo..=127`.
|
||||
fn whole(v: f32, lo: u8) -> u8 {
|
||||
if v.is_finite() { v.round().clamp(f32::from(lo), 127.0) as u8 } else { lo }
|
||||
}
|
||||
|
||||
fn ordered(a: u8, b: u8) -> (u8, u8) {
|
||||
(a.min(b), a.max(b))
|
||||
}
|
||||
|
||||
/// 4-point cubic (Catmull-Rom) interpolation of `x` at fractional frame `pos` (>= 0). Reads
|
||||
/// outside the slice are silence.
|
||||
#[inline]
|
||||
fn hermite(x: &[f32], pos: f64) -> f32 {
|
||||
let i = pos as usize;
|
||||
let t = (pos - i as f64) as f32;
|
||||
let at = |k: usize| x.get(k).copied().unwrap_or(0.0);
|
||||
let x0 = at(i);
|
||||
if t == 0.0 {
|
||||
return if x0.is_finite() { x0 } else { 0.0 };
|
||||
}
|
||||
let xm1 = at(i.saturating_sub(1));
|
||||
let x1 = at(i.saturating_add(1));
|
||||
let x2 = at(i.saturating_add(2));
|
||||
let c1 = 0.5 * (x1 - xm1);
|
||||
let c2 = xm1 - 2.5 * x0 + 2.0 * x1 - 0.5 * x2;
|
||||
let c3 = 0.5 * (x2 - xm1) + 1.5 * (x0 - x1);
|
||||
let y = ((c3 * t + c2) * t + c1) * t + x0;
|
||||
// A float file can hold NaN or infinities; they play as silence.
|
||||
if y.is_finite() { y } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Plays a sample across the keyboard.
|
||||
pub struct Sampler {
|
||||
p: Params,
|
||||
d: Derived,
|
||||
sr: f32,
|
||||
ch: usize,
|
||||
sample: Option<Arc<dyn SampleSource>>,
|
||||
/// Frames in the sample (0 = nothing to play).
|
||||
len: usize,
|
||||
/// The sample's own rate.
|
||||
sample_sr: f32,
|
||||
voices: [Voice; VOICES],
|
||||
events: EventQueue,
|
||||
rates: AdsrRates,
|
||||
level: Smoother,
|
||||
}
|
||||
|
||||
impl Default for Sampler {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Sampler {
|
||||
pub fn new() -> Self {
|
||||
let mut s = Self {
|
||||
p: Params::new(&SAMPLER_PARAMS),
|
||||
d: Derived {
|
||||
root: 60.0,
|
||||
offset: 0.0,
|
||||
key_track: true,
|
||||
keys: (0, 127),
|
||||
vels: (1, 127),
|
||||
sens: 0.7,
|
||||
one_shot: false,
|
||||
start: 0.0,
|
||||
loop_mode: LoopMode::Off,
|
||||
loop_span: (0.0, 1.0),
|
||||
},
|
||||
sr: 48_000.0,
|
||||
ch: 0,
|
||||
sample: None,
|
||||
len: 0,
|
||||
sample_sr: 48_000.0,
|
||||
voices: [Voice::new(); VOICES],
|
||||
events: EventQueue::new(),
|
||||
rates: AdsrRates::default(),
|
||||
level: Smoother::new(1.0),
|
||||
};
|
||||
s.update();
|
||||
s
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
let v = |i| self.p.v(i);
|
||||
let pct = |i| f64::from(v(i) / 100.0).clamp(0.0, 1.0);
|
||||
let (ls, le) = (pct(LOOP_START), pct(LOOP_END));
|
||||
self.d = Derived {
|
||||
root: f64::from(whole(v(ROOT), 0)),
|
||||
offset: f64::from(v(TRANSPOSE)) + f64::from(v(FINE)) / 100.0,
|
||||
key_track: self.p.on(KEY_TRACK),
|
||||
keys: ordered(whole(v(KEY_LOW), 0), whole(v(KEY_HIGH), 0)),
|
||||
vels: ordered(whole(v(VEL_LOW), 1), whole(v(VEL_HIGH), 1)),
|
||||
sens: (v(VEL_SENS) / 100.0).clamp(0.0, 1.0),
|
||||
one_shot: self.p.choice(PLAY_MODE) == 1,
|
||||
start: pct(START),
|
||||
loop_mode: match self.p.choice(LOOP_MODE) {
|
||||
1 => LoopMode::Forward,
|
||||
2 => LoopMode::PingPong,
|
||||
_ => LoopMode::Off,
|
||||
},
|
||||
loop_span: (ls.min(le), ls.max(le)),
|
||||
};
|
||||
self.rates = AdsrRates::new(v(ATTACK), v(DECAY), v(SUSTAIN) / 100.0, v(RELEASE), self.sr);
|
||||
self.level.set(db_to_gain(v(LEVEL)));
|
||||
}
|
||||
|
||||
/// The loop region in sample frames, when it is long enough to loop.
|
||||
fn loop_frames(&self) -> Option<(f64, f64)> {
|
||||
if self.d.loop_mode == LoopMode::Off {
|
||||
return None;
|
||||
}
|
||||
let len = self.len as f64;
|
||||
let (a, b) = (self.d.loop_span.0 * len, self.d.loop_span.1 * len);
|
||||
(b - a >= 1.0).then_some((a, b))
|
||||
}
|
||||
|
||||
fn start_note(&mut self, note: u8, vel: u8) {
|
||||
if vel == 0 {
|
||||
self.stop_note(note);
|
||||
return;
|
||||
}
|
||||
let d = self.d;
|
||||
let (note, vel) = (note.min(127), vel.min(127));
|
||||
if self.len == 0 || note < d.keys.0 || note > d.keys.1 || vel < d.vels.0 || vel > d.vels.1 {
|
||||
return;
|
||||
}
|
||||
let semis = if d.key_track { f64::from(note) - d.root } else { 0.0 } + d.offset;
|
||||
let step = f64::from(self.sample_sr) / f64::from(self.sr) * 2f64.powf(semis / 12.0);
|
||||
if !step.is_finite() || step <= 0.0 {
|
||||
return;
|
||||
}
|
||||
if !d.one_shot {
|
||||
// Retriggering a held key releases the earlier voice instead of stacking it.
|
||||
self.stop_note(note);
|
||||
}
|
||||
let idx = self
|
||||
.voices
|
||||
.iter()
|
||||
.position(|v| !v.active())
|
||||
.or_else(|| {
|
||||
self.voices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by(|(_, a), (_, b)| (a.held, a.env.level).partial_cmp(&(b.held, b.env.level)).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|(i, _)| i)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let start = d.start * self.len.saturating_sub(1) as f64;
|
||||
let looping = self.loop_frames().is_some_and(|(_, end)| start < end);
|
||||
let gain = (1.0 - d.sens) + d.sens * f32::from(vel) / 127.0;
|
||||
if let Some(v) = self.voices.get_mut(idx) {
|
||||
*v = Voice { note, held: !d.one_shot, gain, pos: start, step: step.min(MAX_STEP), forward: true, looping, env: Adsr::new() };
|
||||
v.env.gate_on();
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_note(&mut self, note: u8) {
|
||||
for v in self.voices.iter_mut().filter(|v| v.held && v.note == note) {
|
||||
v.held = false;
|
||||
v.env.gate_off();
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&mut self, kind: EventKind) {
|
||||
match kind {
|
||||
EventKind::On(n, v) => self.start_note(n, v),
|
||||
EventKind::Off(n) => self.stop_note(n),
|
||||
EventKind::AllOff => {
|
||||
for v in &mut self.voices {
|
||||
v.held = false;
|
||||
v.env.gate_off();
|
||||
}
|
||||
}
|
||||
EventKind::Sustain(_) | EventKind::Bend(_) | EventKind::ResetControllers => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds every voice's output for frame `n` into `io` and advances the voices.
|
||||
#[inline]
|
||||
fn render_frame(&mut self, io: &mut [Vec<f32>], ch: usize, n: usize, level: f32) {
|
||||
let Some(sample) = self.sample.as_deref() else { return };
|
||||
let nch = sample.num_channels();
|
||||
let len = self.len as f64;
|
||||
let mode = self.d.loop_mode;
|
||||
let region = self.loop_frames();
|
||||
for v in &mut self.voices {
|
||||
if !v.active() {
|
||||
continue;
|
||||
}
|
||||
let a = v.env.next(&self.rates) * v.gain * level;
|
||||
for (c, b) in io.iter_mut().take(ch).enumerate() {
|
||||
let src = if nch == 1 { 0 } else { c };
|
||||
if let (Some(x), Some(data)) = (b.get_mut(n), sample.channel(src)) {
|
||||
*x += hermite(data, v.pos) * a;
|
||||
}
|
||||
}
|
||||
let r = if v.looping { region } else { None };
|
||||
advance(v, len, r, mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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 => {
|
||||
// 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)) => {
|
||||
if v.pos >= le {
|
||||
v.pos = ls + (v.pos - ls) % (le - ls);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if v.pos >= len || !v.pos.is_finite() {
|
||||
v.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
if !v.pos.is_finite() || v.pos < 0.0 {
|
||||
v.stop();
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for Sampler {
|
||||
param_plumbing!(SAMPLER_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.ch = sane_channels(channels);
|
||||
self.level.set_time(20.0, self.sr);
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
for v in &mut self.voices {
|
||||
v.stop();
|
||||
}
|
||||
self.events.clear();
|
||||
self.level.snap();
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.ch.min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
for b in io.iter_mut().take(ch) {
|
||||
b.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||||
}
|
||||
let mut evs = [Event { offset: 0, kind: EventKind::AllOff }; MAX_EVENTS];
|
||||
let count = {
|
||||
let sorted = self.events.sorted();
|
||||
for (d, s) in evs.iter_mut().zip(sorted) {
|
||||
*d = *s;
|
||||
}
|
||||
sorted.len().min(MAX_EVENTS)
|
||||
};
|
||||
self.events.clear();
|
||||
let mut next = 0;
|
||||
for n in 0..frames {
|
||||
while let Some(e) = evs.get(next).filter(|_| next < count) {
|
||||
if e.offset > n && n + 1 < frames {
|
||||
break;
|
||||
}
|
||||
let kind = e.kind;
|
||||
self.apply(kind);
|
||||
next += 1;
|
||||
}
|
||||
let level = self.level.next();
|
||||
self.render_frame(io, ch, n, level);
|
||||
}
|
||||
// Events for an empty block take effect immediately.
|
||||
for e in evs.iter().take(count).skip(next) {
|
||||
self.apply(e.kind);
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
let release = ms_to_samples(self.p.v(RELEASE), self.sr);
|
||||
// A one-shot keeps playing its sample after the note ends.
|
||||
let rest = if self.d.one_shot { self.len as f32 * self.sr / self.sample_sr } else { 0.0 };
|
||||
let t = release + rest;
|
||||
if t.is_finite() { t.min(1.0e9) as usize } else { 0 }
|
||||
}
|
||||
|
||||
fn note_on(&mut self, offset: usize, note: u8, velocity: u8) {
|
||||
self.events.push(offset, EventKind::On(note, velocity));
|
||||
}
|
||||
|
||||
fn note_off(&mut self, offset: usize, note: u8) {
|
||||
self.events.push(offset, EventKind::Off(note));
|
||||
}
|
||||
|
||||
fn all_notes_off(&mut self) {
|
||||
self.events.push(0, EventKind::AllOff);
|
||||
}
|
||||
|
||||
fn set_sample(&mut self, sample: Option<Arc<dyn SampleSource>>) -> bool {
|
||||
for v in &mut self.voices {
|
||||
v.stop();
|
||||
}
|
||||
self.len = sample.as_deref().map_or(0, |s| if s.num_channels() == 0 { 0 } else { s.frames() });
|
||||
let rate = sample.as_deref().map_or(self.sr, |s| s.sample_rate());
|
||||
// An unusable rate plays at the host rate rather than not at all.
|
||||
self.sample_sr = if rate.is_finite() && rate > 0.0 { rate.min(1_536_000.0) } else { self.sr };
|
||||
self.sample = sample;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::SampleBuffer;
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
const SR: f32 = 48_000.0;
|
||||
|
||||
fn sampler(sample: SampleBuffer) -> Sampler {
|
||||
let mut s = Sampler::new();
|
||||
s.prepare(SR, 512, 2);
|
||||
assert!(s.set_sample(Some(Arc::new(sample))));
|
||||
s
|
||||
}
|
||||
|
||||
fn mono(sample_rate: f32, data: Vec<f32>) -> SampleBuffer {
|
||||
SampleBuffer { sample_rate, channels: vec![data] }
|
||||
}
|
||||
|
||||
fn sine(freq: f32, sr: f32, len: usize) -> Vec<f32> {
|
||||
(0..len).map(|i| 0.5 * (TAU * freq * i as f32 / sr).sin()).collect()
|
||||
}
|
||||
|
||||
/// Renders `len` frames in 512-frame blocks; returns both channels.
|
||||
fn run(s: &mut Sampler, len: usize) -> [Vec<f32>; 2] {
|
||||
let mut out = [Vec::new(), Vec::new()];
|
||||
let mut buf = vec![vec![1.0f32; 512]; 2];
|
||||
let mut done = 0;
|
||||
while done < len {
|
||||
let n = 512.min(len - done);
|
||||
s.process(&mut buf, n);
|
||||
out[0].extend_from_slice(&buf[0][..n]);
|
||||
out[1].extend_from_slice(&buf[1][..n]);
|
||||
done += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn peak(x: &[f32]) -> f32 {
|
||||
x.iter().fold(0.0f32, |m, v| m.max(v.abs()))
|
||||
}
|
||||
|
||||
/// Frequency from upward zero crossings.
|
||||
fn freq(x: &[f32], sr: f32) -> f32 {
|
||||
let ups: Vec<usize> = x.windows(2).enumerate().filter(|(_, w)| w[0] < 0.0 && w[1] >= 0.0).map(|(i, _)| i).collect();
|
||||
let (Some(a), Some(b)) = (ups.first(), ups.last()) else { return 0.0 };
|
||||
(ups.len() - 1) as f32 * sr / (b - a) as f32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silent_without_a_sample_and_replaces_the_buffer() {
|
||||
let mut s = Sampler::new();
|
||||
s.prepare(SR, 512, 2);
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 4800);
|
||||
assert_eq!(peak(&out[0]), 0.0);
|
||||
assert_eq!(peak(&out[1]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_note_plays_the_sample_verbatim() {
|
||||
let data = sine(440.0, SR, 9600);
|
||||
let mut s = sampler(mono(SR, data.clone()));
|
||||
s.set_param("velocity", 0.0);
|
||||
s.note_on(0, 60, 100);
|
||||
let out = run(&mut s, 9600);
|
||||
// Past the 0.1-ms-ish attack the output is the sample itself, on both sides.
|
||||
for i in 100..9600 {
|
||||
assert!((out[0][i] - data[i]).abs() < 1e-6, "frame {i}: {} vs {}", out[0][i], data[i]);
|
||||
}
|
||||
assert_eq!(out[0], out[1], "a mono sample feeds both channels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_transpose_from_the_root_note() {
|
||||
let mut s = sampler(mono(SR, sine(400.0, SR, 96_000)));
|
||||
s.note_on(0, 72, 127);
|
||||
let up = run(&mut s, 24_000);
|
||||
assert!((freq(&up[0][480..], SR) - 800.0).abs() < 2.0, "an octave up: {}", freq(&up[0][480..], SR));
|
||||
s.reset();
|
||||
s.note_on(0, 53, 127);
|
||||
let down = run(&mut s, 24_000);
|
||||
let want = 400.0 * 2f32.powf(-7.0 / 12.0);
|
||||
assert!((freq(&down[0][480..], SR) - want).abs() < 2.0);
|
||||
// Root note 48 makes key 60 play an octave up; transpose and fine tune add on top.
|
||||
s.reset();
|
||||
s.set_param("root_note", 48.0);
|
||||
s.set_param("transpose", -12.0);
|
||||
s.set_param("fine_tune", 100.0);
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 24_000);
|
||||
let want = 400.0 * 2f32.powf(1.0 / 12.0);
|
||||
assert!((freq(&out[0][480..], SR) - want).abs() < 2.0, "{}", freq(&out[0][480..], SR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_tracking_off_plays_every_key_at_the_sample_pitch() {
|
||||
let mut s = sampler(mono(SR, sine(400.0, SR, 48_000)));
|
||||
s.set_param("key_track", 0.0);
|
||||
s.note_on(0, 90, 127);
|
||||
let out = run(&mut s, 24_000);
|
||||
assert!((freq(&out[0][480..], SR) - 400.0).abs() < 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_the_sample_rate() {
|
||||
// A 300 Hz tone recorded at 22.05 kHz still plays at 300 Hz on a 48 kHz host.
|
||||
let mut s = sampler(mono(22_050.0, sine(300.0, 22_050.0, 22_050)));
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 60_000);
|
||||
assert!((freq(&out[0][480..], SR) - 300.0).abs() < 1.0, "{}", freq(&out[0][480..], SR));
|
||||
// And it lasts as long as the recording: one second.
|
||||
assert!(peak(&out[0][46_000..47_500]) > 0.1);
|
||||
assert_eq!(peak(&out[0][48_200..]), 0.0, "the sample ended");
|
||||
// A 96 kHz recording is read faster, not cut short.
|
||||
let mut s = sampler(mono(96_000.0, sine(1000.0, 96_000.0, 96_000)));
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 48_000);
|
||||
assert!((freq(&out[0][480..], SR) - 1000.0).abs() < 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_and_velocity_ranges_gate_notes() {
|
||||
let mut s = sampler(mono(SR, vec![0.5; 4800]));
|
||||
s.set_param("key_low", 48.0);
|
||||
s.set_param("key_high", 60.0);
|
||||
s.set_param("vel_low", 40.0);
|
||||
s.set_param("vel_high", 100.0);
|
||||
for (note, vel, sounds) in
|
||||
[(47, 80, false), (61, 80, false), (48, 80, true), (60, 80, true), (55, 39, false), (55, 101, false), (55, 40, true)]
|
||||
{
|
||||
s.reset();
|
||||
s.note_on(0, note, vel);
|
||||
let out = run(&mut s, 960);
|
||||
assert_eq!(peak(&out[0]) > 0.0, sounds, "note {note} velocity {vel}");
|
||||
}
|
||||
// Reversed ranges are read in order rather than muting everything.
|
||||
s.set_param("key_low", 60.0);
|
||||
s.set_param("key_high", 48.0);
|
||||
s.reset();
|
||||
s.note_on(0, 50, 80);
|
||||
assert!(peak(&run(&mut s, 960)[0]) > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn velocity_sensitivity_scales_level() {
|
||||
let level = |sens: f32, vel: u8| {
|
||||
let mut s = sampler(mono(SR, vec![0.5; 4800]));
|
||||
s.set_param("velocity", sens);
|
||||
s.note_on(0, 60, vel);
|
||||
peak(&run(&mut s, 2400)[0][1000..])
|
||||
};
|
||||
assert!((level(100.0, 127) - 0.5).abs() < 1e-4);
|
||||
assert!((level(100.0, 64) - 0.5 * 64.0 / 127.0).abs() < 1e-4);
|
||||
assert!((level(0.0, 1) - 0.5).abs() < 1e-4, "no sensitivity: every velocity is full level");
|
||||
assert!(level(70.0, 30) < level(70.0, 127));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adsr_shapes_the_note() {
|
||||
let mut s = sampler(mono(SR, vec![0.5; 96_000]));
|
||||
s.set_param("velocity", 0.0);
|
||||
s.set_param("attack", 100.0);
|
||||
s.set_param("decay", 100.0);
|
||||
s.set_param("sustain", 50.0);
|
||||
s.set_param("release", 100.0);
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 24_000);
|
||||
// Linear attack: halfway up at 50 ms, full level at 100 ms, then decay to the sustain level.
|
||||
assert!((out[0][2400] - 0.25).abs() < 0.01, "attack midpoint {}", out[0][2400]);
|
||||
assert!(peak(&out[0][4700..4900]) > 0.49);
|
||||
assert!((out[0][20_000] - 0.25).abs() < 0.005, "sustain {}", out[0][20_000]);
|
||||
s.note_off(0, 60);
|
||||
let out = run(&mut s, 24_000);
|
||||
assert!(out[0][100] > 0.2, "release starts from the sustain level");
|
||||
assert!(peak(&out[0][5000..]) < 1e-3, "released after 100 ms");
|
||||
assert_eq!(peak(&out[0][12_000..]), 0.0, "the voice ended");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_mode_stops_on_note_off_and_one_shot_plays_through() {
|
||||
let mut s = sampler(mono(SR, vec![0.5; 24_000]));
|
||||
s.set_param("release", 1.0);
|
||||
s.note_on(0, 60, 127);
|
||||
s.note_off(1000, 60);
|
||||
let out = run(&mut s, 24_000);
|
||||
assert!(peak(&out[0][..900]) > 0.1);
|
||||
assert_eq!(peak(&out[0][2000..]), 0.0, "gate: released at the note-off");
|
||||
s.set_param("play_mode", 1.0);
|
||||
s.reset();
|
||||
s.note_on(0, 60, 127);
|
||||
s.note_off(1000, 60);
|
||||
let out = run(&mut s, 30_000);
|
||||
assert!(peak(&out[0][20_000..23_900]) > 0.1, "one shot: ignores the note-off");
|
||||
assert_eq!(peak(&out[0][24_100..]), 0.0, "and stops at the end of the sample");
|
||||
assert!(s.tail_samples() >= 24_000, "a one-shot's tail covers the sample");
|
||||
// Stopping the transport still silences one-shots.
|
||||
s.reset();
|
||||
s.note_on(0, 60, 127);
|
||||
run(&mut s, 512);
|
||||
s.all_notes_off();
|
||||
assert_eq!(peak(&run(&mut s, 4800)[0][2400..]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retriggering_a_held_key_releases_the_old_voice() {
|
||||
let mut s = sampler(mono(SR, vec![0.25; 48_000]));
|
||||
s.set_param("velocity", 0.0);
|
||||
s.set_param("release", 1.0);
|
||||
s.note_on(0, 60, 127);
|
||||
s.note_on(256, 60, 127);
|
||||
let out = run(&mut s, 4800);
|
||||
assert!(peak(&out[0][2400..]) < 0.26, "voices did not stack: {}", peak(&out[0][2400..]));
|
||||
// One-shots overlap.
|
||||
s.set_param("play_mode", 1.0);
|
||||
s.reset();
|
||||
s.note_on(0, 60, 127);
|
||||
s.note_on(256, 60, 127);
|
||||
let out = run(&mut s, 4800);
|
||||
assert!((peak(&out[0][2400..]) - 0.5).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_loop_sustains_past_the_end() {
|
||||
let data = sine(480.0, SR, 4800);
|
||||
let mut s = sampler(mono(SR, data.clone()));
|
||||
s.set_param("velocity", 0.0);
|
||||
s.set_param("loop_mode", 1.0);
|
||||
s.set_param("loop_start", 50.0);
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 48_000);
|
||||
assert!(peak(&out[0][40_000..]) > 0.4, "still sounding long after the 0.1 s sample");
|
||||
// After the first pass it repeats the loop region (2400 frames, whole cycles of 480 Hz).
|
||||
for i in 0..2400 {
|
||||
assert!((out[0][4800 + i] - data[2400 + i]).abs() < 1e-4, "frame {i}");
|
||||
}
|
||||
assert!((freq(&out[0][4800..], SR) - 480.0).abs() < 1.0);
|
||||
// Without the loop the note ends with the sample.
|
||||
s.set_param("loop_mode", 0.0);
|
||||
s.reset();
|
||||
s.note_on(0, 60, 127);
|
||||
assert_eq!(peak(&run(&mut s, 9600)[0][4900..]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ping_pong_loop_reverses_direction() {
|
||||
// A rising ramp, looped ping-pong over the whole sample, rises then falls.
|
||||
let data: Vec<f32> = (0..1000).map(|i| i as f32 / 1000.0).collect();
|
||||
let mut s = sampler(mono(SR, data));
|
||||
s.set_param("velocity", 0.0);
|
||||
s.set_param("loop_mode", 2.0);
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 3000);
|
||||
assert!(out[0][900] > out[0][500], "rising");
|
||||
assert!(out[0][1500] < out[0][1100], "falling after the loop end");
|
||||
assert!(out[0][2500] > out[0][2100], "rising again after the loop start");
|
||||
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]));
|
||||
s.set_param("loop_mode", 1.0);
|
||||
s.set_param("loop_start", 40.0);
|
||||
s.set_param("loop_end", 40.0);
|
||||
s.note_on(0, 60, 127);
|
||||
assert_eq!(peak(&run(&mut s, 4800)[0][1100..]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_start_skips_into_the_sample() {
|
||||
let data: Vec<f32> = (0..1000).map(|i| i as f32 / 1000.0).collect();
|
||||
let mut s = sampler(mono(SR, data));
|
||||
s.set_param("velocity", 0.0);
|
||||
s.set_param("attack", 0.1);
|
||||
s.set_param("start", 50.0);
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 2000);
|
||||
assert!((out[0][10] - 0.509).abs() < 0.002, "{}", out[0][10]);
|
||||
assert_eq!(peak(&out[0][520..]), 0.0, "half the sample left");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_samples_keep_their_sides() {
|
||||
let sample = SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 4800], vec![-0.25; 4800]] };
|
||||
let mut s = sampler(sample);
|
||||
s.set_param("velocity", 0.0);
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 2400);
|
||||
assert!((out[0][1000] - 0.5).abs() < 1e-6);
|
||||
assert!((out[1][1000] + 0.25).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_offsets_are_sample_accurate() {
|
||||
let mut s = sampler(mono(SR, vec![0.5; 4800]));
|
||||
s.note_on(300, 60, 127);
|
||||
let out = run(&mut s, 512);
|
||||
assert!(out[0][..300].iter().all(|&v| v == 0.0));
|
||||
assert!(out[0][301..].iter().any(|&v| v != 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_notes_steal_voices_and_stay_bounded() {
|
||||
let mut s = sampler(mono(SR, sine(220.0, SR, 48_000)));
|
||||
s.set_param("level", 12.0);
|
||||
for n in 0..100u8 {
|
||||
s.note_on(usize::from(n), n, 127);
|
||||
}
|
||||
let out = run(&mut s, 9600);
|
||||
assert!(out[0].iter().all(|v| v.is_finite()));
|
||||
assert!(s.voices.iter().filter(|v| v.active()).count() <= VOICES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_sample_stops_the_old_voices() {
|
||||
let mut s = sampler(mono(SR, vec![0.5; 48_000]));
|
||||
s.note_on(0, 60, 127);
|
||||
run(&mut s, 512);
|
||||
assert!(s.set_sample(Some(Arc::new(mono(SR, vec![0.1; 10])))));
|
||||
assert_eq!(peak(&run(&mut s, 512)[0]), 0.0);
|
||||
assert!(s.set_sample(None));
|
||||
s.note_on(0, 60, 127);
|
||||
assert_eq!(peak(&run(&mut s, 512)[0]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_samples_and_params_never_panic() {
|
||||
let samples = [
|
||||
SampleBuffer { sample_rate: f32::NAN, channels: vec![vec![0.5; 100]] },
|
||||
SampleBuffer { sample_rate: 0.0, channels: vec![vec![0.5; 100]] },
|
||||
SampleBuffer { sample_rate: f32::INFINITY, channels: vec![vec![0.5; 100]] },
|
||||
SampleBuffer { sample_rate: 1.0e-30, channels: vec![vec![0.5; 100]] },
|
||||
SampleBuffer { sample_rate: SR, channels: vec![] },
|
||||
SampleBuffer { sample_rate: SR, channels: vec![vec![]] },
|
||||
SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 100], vec![0.5; 3]] },
|
||||
SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 1]] },
|
||||
SampleBuffer { sample_rate: SR, channels: vec![vec![f32::NAN; 100]; 9] },
|
||||
];
|
||||
let params: Vec<(&str, f32)> = SAMPLER_PARAMS.iter().flat_map(|p| [(p.id, f32::NAN), (p.id, f32::INFINITY), (p.id, -1.0e30)]).collect();
|
||||
for sample in samples {
|
||||
let mut s = Sampler::new();
|
||||
s.prepare(f32::NAN, 0, 99);
|
||||
s.set_sample(Some(Arc::new(sample)));
|
||||
for (id, v) in ¶ms {
|
||||
assert!(s.set_param(id, *v));
|
||||
for n in [0u8, 60, 127, 255] {
|
||||
s.note_on(0, n, 255);
|
||||
s.note_on(7, n, 1);
|
||||
}
|
||||
let mut io = vec![vec![0.0f32; 64]; 3];
|
||||
s.process(&mut io, 1000);
|
||||
assert!(io.iter().flatten().all(|v| v.is_finite()), "{id} = {v}");
|
||||
s.process(&mut [], 64);
|
||||
let _ = s.tail_samples();
|
||||
}
|
||||
}
|
||||
// Loops with every mode at the extremes of pitch.
|
||||
let mut s = sampler(mono(SR, sine(100.0, SR, 1000)));
|
||||
for mode in [0.0, 1.0, 2.0] {
|
||||
s.set_param("loop_mode", mode);
|
||||
for (t, root) in [(48.0, 0.0), (-48.0, 127.0)] {
|
||||
s.set_param("transpose", t);
|
||||
s.set_param("root_note", root);
|
||||
s.note_on(0, 127, 127);
|
||||
s.note_on(0, 0, 127);
|
||||
let out = run(&mut s, 4096);
|
||||
assert!(out[0].iter().all(|v| v.is_finite() && v.abs() < 4.0), "mode {mode} transpose {t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -97,7 +104,7 @@ const VOICES: usize = 16;
|
||||
const SILENT: f32 = 1.0e-5;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum Stage {
|
||||
pub(crate) enum Stage {
|
||||
Idle,
|
||||
Attack,
|
||||
Decay,
|
||||
@@ -107,13 +114,13 @@ enum Stage {
|
||||
|
||||
/// Linear-attack, exponential decay/release ADSR. Times are "to -60 dB" for decay/release.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Adsr {
|
||||
stage: Stage,
|
||||
level: f32,
|
||||
pub(crate) struct Adsr {
|
||||
pub(crate) stage: Stage,
|
||||
pub(crate) level: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct AdsrRates {
|
||||
pub(crate) struct AdsrRates {
|
||||
attack: f32,
|
||||
decay: f32,
|
||||
sustain: f32,
|
||||
@@ -121,29 +128,29 @@ struct AdsrRates {
|
||||
}
|
||||
|
||||
impl AdsrRates {
|
||||
fn new(a_ms: f32, d_ms: f32, s: f32, r_ms: f32, sr: f32) -> Self {
|
||||
pub(crate) fn new(a_ms: f32, d_ms: f32, s: f32, r_ms: f32, sr: f32) -> Self {
|
||||
let exp60 = |ms: f32| (0.001f32.ln() / ms_to_samples(ms, sr).max(1.0)).exp();
|
||||
Self { attack: 1.0 / ms_to_samples(a_ms, sr).max(1.0), decay: exp60(d_ms), sustain: s.clamp(0.0, 1.0), release: exp60(r_ms) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Adsr {
|
||||
const fn new() -> Self {
|
||||
pub(crate) const fn new() -> Self {
|
||||
Self { stage: Stage::Idle, level: 0.0 }
|
||||
}
|
||||
|
||||
fn gate_on(&mut self) {
|
||||
pub(crate) fn gate_on(&mut self) {
|
||||
self.stage = Stage::Attack;
|
||||
}
|
||||
|
||||
fn gate_off(&mut self) {
|
||||
pub(crate) fn gate_off(&mut self) {
|
||||
if self.stage != Stage::Idle {
|
||||
self.stage = Stage::Release;
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self, r: &AdsrRates) -> f32 {
|
||||
pub(crate) fn next(&mut self, r: &AdsrRates) -> f32 {
|
||||
match self.stage {
|
||||
Stage::Idle => self.level = 0.0,
|
||||
Stage::Attack => {
|
||||
@@ -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,344 @@
|
||||
use std::f32::consts::{FRAC_PI_4, PI, TAU};
|
||||
|
||||
use soundcraft_dsp::{Plugin, Transport, create, db_to_gain};
|
||||
|
||||
const SR: f32 = 48_000.0;
|
||||
const BLOCK: usize = 512;
|
||||
|
||||
fn auto_pan(params: &[(&str, f32)]) -> Box<dyn Plugin> {
|
||||
let mut p = create("auto_pan").unwrap();
|
||||
p.prepare(SR, BLOCK, 2);
|
||||
for (id, v) in params {
|
||||
assert!(p.set_param(id, *v), "{id}");
|
||||
}
|
||||
p.reset();
|
||||
p
|
||||
}
|
||||
|
||||
/// Runs `seconds` of a constant `level` on both channels in host-sized blocks.
|
||||
fn run_level(p: &mut dyn Plugin, level: f32, seconds: f32) -> Vec<Vec<f32>> {
|
||||
let len = (seconds * SR) as usize;
|
||||
let mut out = vec![Vec::new(), Vec::new()];
|
||||
let mut pos = 0;
|
||||
while pos < len {
|
||||
let n = BLOCK.min(len - pos);
|
||||
let mut buf = vec![vec![level; n]; 2];
|
||||
p.process(&mut buf, n);
|
||||
for c in 0..2 {
|
||||
out[c].extend_from_slice(&buf[c]);
|
||||
}
|
||||
pos += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pan position (-1 left ..= 1 right) of frame `i`, from the constant-power gains.
|
||||
fn position(out: &[Vec<f32>], i: usize) -> f32 {
|
||||
4.0 / PI * out[1][i].atan2(out[0][i]) - 1.0
|
||||
}
|
||||
|
||||
fn at(seconds: f32) -> usize {
|
||||
(seconds * SR) as usize
|
||||
}
|
||||
|
||||
fn assert_near(got: f32, want: f32, tol: f32, what: &str) {
|
||||
assert!((got - want).abs() <= tol, "{what}: got {got}, want {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_manual_places_the_signal_when_width_is_zero() {
|
||||
let mut p = auto_pan(&[("width", 0.0), ("manual", -100.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 0.1);
|
||||
assert_near(out[0][at(0.05)], 0.5 * 2f32.sqrt(), 1e-4, "hard left, left channel");
|
||||
assert_near(out[1][at(0.05)], 0.0, 1e-4, "hard left, right channel");
|
||||
|
||||
let mut p = auto_pan(&[("width", 0.0), ("manual", 50.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 0.1);
|
||||
assert_near(position(&out, at(0.05)), 0.5, 1e-3, "manual 50 %");
|
||||
|
||||
let mut p = auto_pan(&[("width", 0.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 0.1);
|
||||
assert_near(out[0][at(0.05)], 0.5, 1e-4, "center keeps the level on the left");
|
||||
assert_near(out[1][at(0.05)], 0.5, 1e-4, "center keeps the level on the right");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_lfo_moves_from_left_to_right_and_back() {
|
||||
// 60 BPM with a Duration of 1 beat is one cycle per second.
|
||||
let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
for (t, want) in [(0.0, -1.0), (0.25, 0.0), (0.5, 1.0), (0.75, 0.0)] {
|
||||
assert_near(position(&out, at(t)), want, 0.05, &format!("left to right at {t} s"));
|
||||
}
|
||||
|
||||
let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0), ("path", 1.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
assert_near(position(&out, at(0.01)), 1.0, 0.05, "right to left starts on the right");
|
||||
assert_near(position(&out, at(0.5)), -1.0, 0.05, "right to left at 0.5 s");
|
||||
|
||||
// Width 50 % sweeps half the field, and Manual moves that half to the right.
|
||||
let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0), ("width", 50.0), ("manual", 50.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
assert_near(position(&out, at(0.0)), -0.25, 0.05, "width 50 % at the start");
|
||||
assert_near(position(&out, at(0.5)), 0.75, 0.05, "width 50 % at the far end");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_keeps_moving_at_the_slowest_rate() {
|
||||
// Rate 1 BPM over 4 bars of 4/4 is one cycle in 960 s; 600 s in, a saw is 62.5 % across.
|
||||
let mut p = auto_pan(&[("rate", 1.0), ("duration", 6.0), ("waveform", 3.0)]);
|
||||
let mut buf = vec![vec![0.5f32; BLOCK]; 2];
|
||||
for _ in 0..(600.0 * SR) as usize / BLOCK {
|
||||
buf.iter_mut().for_each(|c| c.fill(0.5));
|
||||
p.process(&mut buf, BLOCK);
|
||||
}
|
||||
assert_near(position(&buf, BLOCK - 1), 0.25, 0.01, "600 s into a 960 s cycle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_waveforms_take_their_shapes() {
|
||||
let shape = |waveform: f32, t: f32| {
|
||||
let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0), ("waveform", waveform)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
position(&out, at(t))
|
||||
};
|
||||
assert_near(shape(1.0, 0.25), 0.0, 0.05, "triangle at a quarter cycle");
|
||||
assert_near(shape(1.0, 0.125), -0.5, 0.05, "triangle at an eighth of a cycle");
|
||||
assert_near(shape(2.0, 0.2), -1.0, 0.02, "square in its first half");
|
||||
assert_near(shape(2.0, 0.7), 1.0, 0.02, "square in its second half");
|
||||
assert_near(shape(3.0, 0.25), -0.5, 0.05, "saw up");
|
||||
assert_near(shape(4.0, 0.25), 0.5, 0.05, "saw down");
|
||||
assert_near(shape(5.0, 1.0 / 6.0), 0.0, 0.05, "half sine reaches the center at a sixth of a cycle");
|
||||
assert_near(shape(6.0, 0.1), -1.0, 0.02, "4 step triangle, first step");
|
||||
assert_near(shape(6.0, 0.2), -1.0 / 3.0, 0.02, "4 step triangle, second step");
|
||||
assert_near(shape(6.0, 0.45), 1.0, 0.02, "4 step triangle, top step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_rate_follows_the_session_tempo_and_duration() {
|
||||
// Linked to a 120 BPM session with a Duration of 1 beat: two cycles per second.
|
||||
let mut p = auto_pan(&[("link_tempo", 1.0), ("rate", 30.0), ("duration", 2.0)]);
|
||||
p.set_transport(&Transport { tempo: 120.0, numerator: 4, denominator: 4, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 0.5);
|
||||
assert_near(position(&out, at(0.25)), 1.0, 0.05, "linked to 120 BPM, 1 beat");
|
||||
|
||||
// Not linked: Rate 120 BPM and the default Duration of 1 bar (4/4) is one cycle in 2 s.
|
||||
let mut p = auto_pan(&[]);
|
||||
p.set_transport(&Transport { tempo: 60.0, numerator: 4, denominator: 4, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 1.5);
|
||||
assert_near(position(&out, at(1.0)), 1.0, 0.05, "Rate 120 BPM, 1 bar");
|
||||
|
||||
// A 3/4 bar at 120 BPM lasts 1.5 s.
|
||||
let mut p = auto_pan(&[("link_tempo", 1.0)]);
|
||||
p.set_transport(&Transport { tempo: 120.0, numerator: 3, denominator: 4, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
assert_near(position(&out, at(0.75)), 1.0, 0.05, "1 bar of 3/4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_beat_clock_follows_the_transport_position() {
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("duration", 2.0)]);
|
||||
// Half a beat into the song, the LFO is half way through its cycle.
|
||||
p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, quarters: 7.5, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 0.01);
|
||||
assert_near(position(&out, 0), 1.0, 0.05, "half a beat in");
|
||||
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("duration", 2.0)]);
|
||||
p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, quarters: 3.0, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 0.2);
|
||||
assert_near(position(&out, 0), -1.0, 0.05, "on the beat");
|
||||
assert_near(position(&out, at(0.125)), 0.0, 0.05, "a quarter of a beat later");
|
||||
|
||||
// Not linked, Rate 60 BPM in a 120 BPM session: the LFO restarts on every beat (0.5 s) and
|
||||
// runs at 1 Hz in between, so it reaches the right and then starts again from the left.
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("rate", 60.0), ("duration", 2.0)]);
|
||||
p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, quarters: 3.0, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
assert_near(position(&out, at(0.25)), 0.0, 0.05, "a quarter cycle at 1 Hz");
|
||||
assert_near(position(&out, at(0.49)), 1.0, 0.05, "on the right at the end of the beat");
|
||||
assert_near(position(&out, at(0.51)), -1.0, 0.05, "restarted on the next beat");
|
||||
assert_near(position(&out, at(0.75)), 0.0, 0.05, "a quarter cycle after the restart");
|
||||
|
||||
// 2 Beats in 3/4, with the host updating the transport every block: the cycle runs on across
|
||||
// the bar line at quarter 3 instead of restarting there.
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("waveform", 3.0), ("duration", 3.0)]);
|
||||
let mut buf = vec![vec![0.5f32; 100]; 2];
|
||||
let mut positions = Vec::new();
|
||||
for block in 0..480 {
|
||||
let quarters = 2.0 + block as f64 * 100.0 * 2.0 / f64::from(SR);
|
||||
let bar = (quarters / 3.0).floor();
|
||||
p.set_transport(&Transport {
|
||||
playing: true,
|
||||
tempo: 120.0,
|
||||
numerator: 3,
|
||||
denominator: 4,
|
||||
quarters,
|
||||
bar_start: bar * 3.0,
|
||||
bar: bar as i64,
|
||||
..Transport::default()
|
||||
});
|
||||
buf.iter_mut().for_each(|c| c.fill(0.5));
|
||||
p.process(&mut buf, 100);
|
||||
positions.push((quarters, position(&buf, 0)));
|
||||
}
|
||||
for (q, want) in [(2.5, -0.5), (3.0, 0.0), (3.5, 0.5)] {
|
||||
let got = positions.iter().find(|(quarters, _)| (quarters - q).abs() < 1e-9).map(|(_, pos)| *pos).unwrap();
|
||||
assert_near(got, want, 0.02, &format!("2 beats in 3/4 at quarter {q}"));
|
||||
}
|
||||
|
||||
// Bar-based cycles start on the bar line after a change from 4/4 to 3/4 at quarter 4.
|
||||
let three_four = |quarters: f64| Transport {
|
||||
playing: true,
|
||||
tempo: 120.0,
|
||||
numerator: 3,
|
||||
denominator: 4,
|
||||
quarters,
|
||||
bar_start: 4.0,
|
||||
bar: 1,
|
||||
..Transport::default()
|
||||
};
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("waveform", 3.0)]);
|
||||
p.set_transport(&three_four(4.0));
|
||||
let out = run_level(p.as_mut(), 0.5, 0.01);
|
||||
assert_near(position(&out, 0), -1.0, 0.02, "1 bar starts on the 3/4 bar line");
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("waveform", 3.0), ("duration", 5.0)]);
|
||||
p.set_transport(&three_four(5.5));
|
||||
let out = run_level(p.as_mut(), 0.5, 0.01);
|
||||
assert_near(position(&out, 0), 0.5, 0.02, "half way through the second bar of 2 bars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_single_runs_one_cycle_and_holds() {
|
||||
let mut p = auto_pan(&[("trigger", 1.0), ("rate", 60.0), ("duration", 2.0), ("waveform", 3.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 2.0);
|
||||
assert_near(position(&out, at(0.5)), 0.0, 0.05, "half way through the one cycle");
|
||||
assert_near(position(&out, at(1.5)), 1.0, 0.02, "holds at the end of the path");
|
||||
assert_near(position(&out, at(1.99)), 1.0, 0.02, "still holding");
|
||||
|
||||
// Starting the transport runs the cycle again.
|
||||
p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 0.5);
|
||||
assert_near(position(&out, at(0.25)), -0.5, 0.05, "restarted when playback starts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_envelope_source_pans_by_level() {
|
||||
let settled = |level: f32| {
|
||||
let mut p = auto_pan(&[("source", 1.0), ("threshold", -30.0)]);
|
||||
let out = run_level(p.as_mut(), level, 0.5);
|
||||
position(&out, at(0.45))
|
||||
};
|
||||
assert_near(settled(db_to_gain(-40.0)), -1.0, 0.02, "below the threshold");
|
||||
assert_near(settled(db_to_gain(-15.0)), 0.0, 0.05, "half way to full scale");
|
||||
assert_near(settled(1.0), 1.0, 0.02, "full scale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_envelope_and_manual_triggers_start_a_cycle() {
|
||||
let mut p = auto_pan(&[("trigger", 3.0), ("rate", 60.0), ("duration", 2.0), ("waveform", 3.0), ("threshold", -30.0)]);
|
||||
let quiet = run_level(p.as_mut(), db_to_gain(-40.0), 0.5);
|
||||
assert_near(position(&quiet, at(0.4)), -1.0, 0.02, "waits at the start below the threshold");
|
||||
let loud = run_level(p.as_mut(), 1.0, 1.5);
|
||||
assert_near(position(&loud, at(0.25)), -0.5, 0.05, "a quarter cycle after the level crosses the threshold");
|
||||
assert_near(position(&loud, at(1.4)), 1.0, 0.02, "holds after one cycle while the level stays up");
|
||||
|
||||
let mut p = auto_pan(&[("trigger", 4.0), ("rate", 60.0), ("duration", 2.0), ("waveform", 3.0)]);
|
||||
let before = run_level(p.as_mut(), 0.5, 0.5);
|
||||
assert_near(position(&before, at(0.4)), -1.0, 0.02, "waits for the manual trigger");
|
||||
assert!(p.set_param("retrigger", 1.0));
|
||||
let after = run_level(p.as_mut(), 0.5, 0.5);
|
||||
assert_near(position(&after, at(0.25)), -0.5, 0.05, "a quarter cycle after switching the trigger on");
|
||||
let held = run_level(p.as_mut(), 0.5, 1.0);
|
||||
assert!(p.set_param("retrigger", 0.0));
|
||||
let again = run_level(p.as_mut(), 0.5, 0.5);
|
||||
assert_near(position(&held, at(0.9)), 1.0, 0.02, "holds after one cycle");
|
||||
assert_near(position(&again, at(0.25)), -0.5, 0.05, "switching the trigger off starts another cycle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_envelope_trigger_waits_for_the_release() {
|
||||
let mut p = auto_pan(&[
|
||||
("trigger", 3.0),
|
||||
("rate", 60.0),
|
||||
("duration", 2.0),
|
||||
("waveform", 3.0),
|
||||
("threshold", -30.0),
|
||||
("attack", 0.1),
|
||||
("release", 100.0),
|
||||
]);
|
||||
let quiet = db_to_gain(-40.0);
|
||||
let first = run_level(p.as_mut(), 1.0, 1.5);
|
||||
assert_near(position(&first, at(1.4)), 1.0, 0.02, "the first cycle ends and holds");
|
||||
// The envelope falls below the threshold about 0.38 s into the quiet part, so a 0.42 s dip
|
||||
// leaves it below for less than the 100 ms Release.
|
||||
run_level(p.as_mut(), quiet, 0.42);
|
||||
let short = run_level(p.as_mut(), 1.0, 0.5);
|
||||
assert_near(position(&short, at(0.25)), 1.0, 0.02, "no trigger before the release is over");
|
||||
run_level(p.as_mut(), quiet, 1.0);
|
||||
let long = run_level(p.as_mut(), 1.0, 0.5);
|
||||
assert_near(position(&long, at(0.25)), -0.5, 0.05, "a trigger once the release is over");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_output_gain_and_mono_tracks() {
|
||||
let mut p = auto_pan(&[("width", 0.0), ("output", 6.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 0.1);
|
||||
assert_near(out[0][at(0.05)], 0.5 * db_to_gain(6.0), 1e-4, "+6 dB at the center");
|
||||
|
||||
// A mono insert has nothing to pan to; Output still applies.
|
||||
let mut p = create("auto_pan").unwrap();
|
||||
p.prepare(SR, BLOCK, 1);
|
||||
assert!(p.set_param("output", -6.0));
|
||||
p.reset();
|
||||
let mut buf = vec![vec![0.5f32; BLOCK]];
|
||||
p.process(&mut buf, BLOCK);
|
||||
assert_near(buf[0][BLOCK - 1], 0.5 * db_to_gain(-6.0), 1e-4, "mono output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_balances_a_stereo_signal_without_summing_it() {
|
||||
// A sine on the left, the same sine inverted plus an offset on the right: the channels differ,
|
||||
// and a mono sum would cancel the sine.
|
||||
let len = at(0.2);
|
||||
let left: Vec<f32> = (0..len).map(|i| 0.5 * (TAU * 440.0 * i as f32 / SR).sin()).collect();
|
||||
let right: Vec<f32> = left.iter().map(|x| 0.25 - x).collect();
|
||||
let run = |p: &mut dyn Plugin| {
|
||||
let mut out = vec![Vec::new(), Vec::new()];
|
||||
for start in (0..len).step_by(BLOCK) {
|
||||
let end = (start + BLOCK).min(len);
|
||||
let mut buf = vec![left[start..end].to_vec(), right[start..end].to_vec()];
|
||||
p.process(&mut buf, end - start);
|
||||
out[0].extend_from_slice(&buf[0]);
|
||||
out[1].extend_from_slice(&buf[1]);
|
||||
}
|
||||
out
|
||||
};
|
||||
|
||||
let mut p = auto_pan(&[("width", 0.0)]);
|
||||
let out = run(p.as_mut());
|
||||
for i in 0..len {
|
||||
assert_near(out[0][i], left[i], 1e-5, "center passes the left channel");
|
||||
assert_near(out[1][i], right[i], 1e-5, "center passes the right channel");
|
||||
}
|
||||
|
||||
// Half left: each channel keeps its own content, the left louder and the right quieter.
|
||||
let mut p = auto_pan(&[("width", 0.0), ("manual", -50.0)]);
|
||||
let out = run(p.as_mut());
|
||||
let theta = 0.5 * FRAC_PI_4;
|
||||
let (gl, gr) = (2f32.sqrt() * theta.cos(), 2f32.sqrt() * theta.sin());
|
||||
for i in at(0.05)..len {
|
||||
assert_near(out[0][i], left[i] * gl, 1e-5, "half left, left channel");
|
||||
assert_near(out[1][i], right[i] * gr, 1e-5, "half left, right channel");
|
||||
}
|
||||
|
||||
// Anti-phase channels still drive the envelope follower to the right.
|
||||
let mut p = auto_pan(&[("source", 1.0), ("threshold", -30.0)]);
|
||||
let mut buf = vec![vec![1.0f32; BLOCK], vec![-1.0f32; BLOCK]];
|
||||
for _ in 0..at(0.5) / BLOCK {
|
||||
buf[0].fill(1.0);
|
||||
buf[1].fill(-1.0);
|
||||
p.process(&mut buf, BLOCK);
|
||||
}
|
||||
assert_near(buf[0][BLOCK - 1], 0.0, 1e-3, "anti-phase at full scale pans right: left");
|
||||
assert_near(buf[1][BLOCK - 1], -(2f32.sqrt()), 1e-3, "anti-phase at full scale pans right: right");
|
||||
}
|
||||
@@ -46,6 +46,9 @@ proptest! {
|
||||
let _ = offline::detect_transients(&ch, a, a);
|
||||
let _ = offline::time_stretch(&ch, r, a);
|
||||
let _ = offline::pitch_shift(&ch, a, a);
|
||||
let change = if n % 2 == 0 { offline::SpeedRampChange::SpeedUp } else { offline::SpeedRampChange::SlowDown };
|
||||
let selection = if n % 3 == 0 { offline::SpeedRampSelection::Extend } else { offline::SpeedRampSelection::FitTo };
|
||||
let _ = offline::speed_ramp(&ch, change, selection, n % 5 == 0);
|
||||
let _ = offline::resample(&ch, n as u32, (n as u32).wrapping_mul(3) % 200_000);
|
||||
let mut m = ch.clone();
|
||||
offline::normalize(&mut m, a, n % 2 == 0);
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
mod auto_pan;
|
||||
mod fuzz;
|
||||
mod offline_meters;
|
||||
mod offline_normalize;
|
||||
mod plugins;
|
||||
mod speed_ramp;
|
||||
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");
|
||||
@@ -430,6 +513,112 @@ fn saturator_adds_harmonics_and_stays_bounded() {
|
||||
assert!(spec[third - 1..=third + 1].iter().cloned().fold(f32::MIN, f32::max) > -30.0);
|
||||
}
|
||||
|
||||
/// Amplitude of the `freq` component of `x` (a whole number of cycles keeps it exact).
|
||||
fn tone_level(x: &[f32], freq: f32) -> f32 {
|
||||
let (mut re, mut im) = (0.0f64, 0.0f64);
|
||||
for (i, v) in x.iter().enumerate() {
|
||||
let w = f64::from(TAU * freq / SR) * i as f64;
|
||||
re += f64::from(*v) * w.cos();
|
||||
im += f64::from(*v) * w.sin();
|
||||
}
|
||||
(2.0 * (re * re + im * im).sqrt() / x.len() as f64) as f32
|
||||
}
|
||||
|
||||
/// A Subharmonic with only the wet path and wide filters, so a band's octave reaches the output.
|
||||
fn subharmonic_wet() -> Box<dyn Plugin> {
|
||||
let mut p = prepared("subharmonic");
|
||||
p.set_param("mix", 100.0);
|
||||
p.set_param("hp_freq", 5.0);
|
||||
p.set_param("lp_freq", 1000.0);
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subharmonic_adds_the_octave_below_each_range_band() {
|
||||
for (range, lower, upper) in [(0.0, 120.0, 180.0), (1.0, 80.0, 120.0), (2.0, 60.0, 90.0), (3.0, 40.0, 60.0)] {
|
||||
for (band, freq) in [("lower", lower), ("upper", upper)] {
|
||||
let mut p = subharmonic_wet();
|
||||
p.set_param("range", range);
|
||||
p.set_param("lower_gain", if band == "lower" { 0.0 } else { -120.0 });
|
||||
p.set_param("upper_gain", if band == "upper" { 0.0 } else { -120.0 });
|
||||
let out = run(p.as_mut(), &[sine(freq, 0.5, 96_000)]);
|
||||
// One second after the filters settle: a whole number of cycles of both tones.
|
||||
let tail = &out[0][48_000..];
|
||||
let sub = tone_level(tail, freq / 2.0);
|
||||
assert!(sub > 0.2, "range {range} {band} band: {sub} at {} Hz", freq / 2.0);
|
||||
assert!(sub > 4.0 * tone_level(tail, freq), "range {range} {band} band keeps its input tone");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subharmonic_mix_keeps_one_path_at_full_level() {
|
||||
// A 1 kHz tone is far above every band, so the output is the dry path alone.
|
||||
for (mix, dry) in [(0.0, 1.0), (25.0, 1.0), (50.0, 1.0), (75.0, 0.5), (100.0, 0.0)] {
|
||||
let mut p = prepared("subharmonic");
|
||||
p.set_param("mix", mix);
|
||||
let out = run(p.as_mut(), &[sine(1000.0, 0.5, 48_000)]);
|
||||
let level = tone_level(&out[0][24_000..], 1000.0);
|
||||
assert!((level - 0.5 * dry).abs() < 0.01, "mix {mix}: dry level {level}");
|
||||
}
|
||||
// The wet path is at full level from 50 % up and at half level at 25 %.
|
||||
let wet = |mix: f32| {
|
||||
let mut p = prepared("subharmonic");
|
||||
p.set_param("mix", mix);
|
||||
p.set_param("wet_solo", 1.0);
|
||||
let out = run(p.as_mut(), &[sine(60.0, 0.5, 96_000)]);
|
||||
tone_level(&out[0][48_000..], 30.0)
|
||||
};
|
||||
let full = wet(50.0);
|
||||
assert!(full > 0.1, "wet level {full}");
|
||||
assert!((wet(100.0) - full).abs() < 0.01 * full);
|
||||
assert!((wet(25.0) - 0.5 * full).abs() < 0.01 * full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subharmonic_solo_plays_one_band_and_mutes_the_dry_signal() {
|
||||
let level = |solo: &str, gain: f32| {
|
||||
let mut p = prepared("subharmonic");
|
||||
p.set_param("lower_gain", gain);
|
||||
p.set_param(solo, 1.0);
|
||||
let x = sine(60.0, 0.5, 96_000).iter().zip(sine(1000.0, 0.5, 96_000)).map(|(a, b)| a + b).collect();
|
||||
let out = run(p.as_mut(), &[x]);
|
||||
let tail = &out[0][48_000..];
|
||||
(tone_level(tail, 30.0), tone_level(tail, 1000.0))
|
||||
};
|
||||
let (sub, dry) = level("lower_solo", 0.0);
|
||||
assert!(sub > 0.1 && dry < 0.01, "lower solo: sub {sub}, dry {dry}");
|
||||
// With the soloed band turned off nothing else plays, though the upper band is on.
|
||||
let (sub, dry) = level("lower_solo", -120.0);
|
||||
assert!(sub < 0.01 && dry < 0.01, "silent lower solo: sub {sub}, dry {dry}");
|
||||
let (sub, dry) = level("upper_solo", -120.0);
|
||||
assert!(sub > 0.05 && dry < 0.01, "upper solo: sub {sub}, dry {dry}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subharmonic_drive_keeps_every_character_bounded() {
|
||||
let driven = |character: usize, q: f32| {
|
||||
let mut p = subharmonic_wet();
|
||||
p.set_param("input", 18.0);
|
||||
p.set_param("lower_gain", 24.0);
|
||||
p.set_param("upper_gain", 24.0);
|
||||
p.set_param("drive", 18.0);
|
||||
p.set_param("character", character as f32);
|
||||
p.set_param("hp_q", q);
|
||||
p.set_param("lp_q", q);
|
||||
run(p.as_mut(), &[sine(60.0, 1.0, 48_000)])
|
||||
};
|
||||
for character in 0..6 {
|
||||
let out = driven(character, 0.707);
|
||||
let top = peak(&out[0][24_000..]);
|
||||
assert!(top > 0.5 && top < 1.5, "character {character}: peak {top}");
|
||||
// Q 5 filters after the saturator ring well above full scale, but never to infinity.
|
||||
let out = driven(character, 5.0);
|
||||
assert!(out[0].iter().all(|v| v.is_finite()), "character {character} at Q 5");
|
||||
assert!(peak(&out[0][24_000..]) > 0.5, "character {character} at Q 5 is silent");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lofi_quantizes() {
|
||||
let mut p = prepared("lofi");
|
||||
@@ -441,3 +630,95 @@ fn lofi_quantizes() {
|
||||
levels.dedup();
|
||||
assert!(levels.len() <= 5, "{levels:?}");
|
||||
}
|
||||
|
||||
fn ring_mod(params: &[(&str, f32)]) -> Box<dyn Plugin> {
|
||||
let mut p = prepared("ring_mod");
|
||||
for &(id, v) in params {
|
||||
assert!(p.set_param(id, v), "ring_mod.{id}");
|
||||
}
|
||||
p.reset();
|
||||
p
|
||||
}
|
||||
|
||||
/// Loudest bin within two bins of `hz` in an 8192-point spectrum.
|
||||
fn band_db(spec: &[f32], hz: f32) -> f32 {
|
||||
let bin = (hz / (SR / 8192.0)).round() as usize;
|
||||
spec[bin - 2..=bin + 2].iter().cloned().fold(f32::MIN, f32::max)
|
||||
}
|
||||
|
||||
fn zero_crossings(x: &[f32]) -> usize {
|
||||
x.windows(2).filter(|w| (w[0] < 0.0) != (w[1] < 0.0)).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_mod_makes_sum_and_difference_tones() {
|
||||
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 300.0), ("mod_depth", 0.0), ("mix", 100.0)]);
|
||||
let out = run(p.as_mut(), &[sine(1000.0, 0.5, 48_000)]);
|
||||
let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][8192..], 8192);
|
||||
let (low, high, input) = (band_db(&spec, 700.0), band_db(&spec, 1300.0), band_db(&spec, 1000.0));
|
||||
// The two tones fall at different places between bins, so their bin peaks differ a little.
|
||||
assert!((low - high).abs() < 2.0, "700 Hz {low} dB, 1300 Hz {high} dB");
|
||||
assert!(low - input > 40.0, "the 1000 Hz input is left at {input} dB against {low} dB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_mod_frequency_mod_adds_sidebands() {
|
||||
let x = sine(1000.0, 0.5, 48_000);
|
||||
let dry = soundcraft_dsp::spectrum::magnitude_db(&x[8192..], 8192);
|
||||
assert!(band_db(&dry, 1000.0) - band_db(&dry, 1200.0) > 40.0);
|
||||
let mut p = ring_mod(&[("effect", 1.0), ("frequency", 200.0), ("mix", 100.0)]);
|
||||
let out = run(p.as_mut(), &[x]);
|
||||
let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][8192..], 8192);
|
||||
for hz in [800.0, 1200.0] {
|
||||
assert!(band_db(&spec, 1000.0) - band_db(&spec, hz) < 20.0, "no sideband at {hz} Hz");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_mod_resonators_ring_at_the_frequency_with_their_sign() {
|
||||
// 480 Hz at 48 kHz is a 100-sample period.
|
||||
for (effect, sign) in [(2.0, 1.0), (3.0, -1.0)] {
|
||||
let mut p = ring_mod(&[("effect", effect), ("frequency", 480.0), ("resonance", 90.0), ("mix", 100.0)]);
|
||||
let mut x = vec![0.0f32; 9600];
|
||||
x[0] = 1.0;
|
||||
let out = run(p.as_mut(), &[x]);
|
||||
let y = &out[0];
|
||||
assert!((y[0] - 0.1).abs() < 1e-4, "effect {effect}: direct {}", y[0]);
|
||||
assert!((y[100] - sign * 0.09).abs() < 1e-4, "effect {effect}: first echo {}", y[100]);
|
||||
assert!((y[200] - 0.081).abs() < 1e-4, "effect {effect}: second echo {}", y[200]);
|
||||
assert!(y[50].abs() < 1e-6 && y[150].abs() < 1e-6, "effect {effect}: sound between the echoes");
|
||||
let last = y.iter().rposition(|v| v.abs() > 0.1e-3).unwrap();
|
||||
assert!(last < p.tail_samples(), "effect {effect}: ringing at {last}, tail {}", p.tail_samples());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_mod_modulation_sources_move_the_frequency() {
|
||||
// A constant input through Ring gives the carrier itself.
|
||||
let dc = vec![1.0f32; 48_000];
|
||||
|
||||
// The envelope of a full-scale input lifts 100 Hz by one octave at 50 % depth.
|
||||
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 1.0), ("mod_depth", 50.0), ("mix", 100.0)]);
|
||||
let out = run(p.as_mut(), std::slice::from_ref(&dc));
|
||||
let n = zero_crossings(&out[0][24_000..]);
|
||||
assert!((195..=205).contains(&n), "envelope: {n} crossings in 0.5 s, 200 expected for 200 Hz");
|
||||
|
||||
// A 1 Hz LFO raises the frequency for its first half cycle and lowers it for the second.
|
||||
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 0.0), ("mod_rate", 1.0), ("mod_depth", 100.0), ("mix", 100.0)]);
|
||||
let out = run(p.as_mut(), std::slice::from_ref(&dc));
|
||||
let (up, down) = (zero_crossings(&out[0][..12_000]), zero_crossings(&out[0][24_000..36_000]));
|
||||
assert!(up > 3 * down, "LFO: {up} crossings rising, {down} falling");
|
||||
|
||||
// Sample and hold at 4 Hz keeps one frequency for each 12,000-sample step.
|
||||
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 2.0), ("mod_rate", 4.0), ("mod_depth", 100.0), ("mix", 100.0)]);
|
||||
let out = run(p.as_mut(), std::slice::from_ref(&dc));
|
||||
let mut steps = Vec::new();
|
||||
for s in out[0].chunks(12_000) {
|
||||
let (a, b) = (zero_crossings(&s[..6000]), zero_crossings(&s[6000..]));
|
||||
assert!(a.abs_diff(b) <= 2, "sample and hold changed within a step: {a} then {b}");
|
||||
steps.push(a);
|
||||
}
|
||||
steps.sort_unstable();
|
||||
steps.dedup_by(|a, b| a.abs_diff(*b) <= 2);
|
||||
assert!(steps.len() >= 3, "sample and hold steps: {steps:?}");
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use soundcraft_dsp::offline::{self, SpeedRampChange, SpeedRampSelection};
|
||||
|
||||
/// A channel whose samples rise from 0 to 1, so each output sample shows where it was read.
|
||||
fn ramp(len: usize) -> Vec<f32> {
|
||||
(0..len).map(|i| i as f32 / len as f32).collect()
|
||||
}
|
||||
|
||||
/// The fraction of the played input reached at `u` (0 to 1) of the result.
|
||||
fn slow_down_pos(u: f64) -> f64 {
|
||||
(3.0 * u - u * u * u) / 2.0
|
||||
}
|
||||
|
||||
fn speed_up_pos(u: f64) -> f64 {
|
||||
1.0 - slow_down_pos(1.0 - u)
|
||||
}
|
||||
|
||||
fn near(got: f32, want: f64, what: &str) {
|
||||
assert!((f64::from(got) - want).abs() < 2e-3, "{what}: got {got}, want {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_fit_to_keeps_the_length_and_plays_two_thirds() {
|
||||
let len = 30_000;
|
||||
let x = vec![ramp(len)];
|
||||
let slow = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::FitTo, false);
|
||||
assert_eq!(slow[0].len(), len);
|
||||
// Slow Down plays the first two thirds: it starts at normal speed at the start and stops at the end.
|
||||
for u in [0.0, 0.25, 0.5, 0.75, 0.9] {
|
||||
let n = (u * len as f64) as usize;
|
||||
near(slow[0][n], 2.0 / 3.0 * slow_down_pos(u), &format!("slow down at {u}"));
|
||||
}
|
||||
// The speed over 50 samples: a one-sample step inside the input also shows the interpolation's ripple.
|
||||
let step = |a: &[f32], n: usize| f64::from(a[n + 50] - a[n]) * len as f64 / 50.0;
|
||||
assert!((step(&slow[0], 100) - 1.0).abs() < 0.01, "slow down starts at normal speed: {}", step(&slow[0], 100));
|
||||
assert!(step(&slow[0], len - 200) < 0.02, "slow down ends near a stop: {}", step(&slow[0], len - 200));
|
||||
|
||||
// Speed Up plays the last two thirds: it starts from a stop and reaches normal speed at the end.
|
||||
let fast = offline::speed_ramp(&x, SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, false);
|
||||
assert_eq!(fast[0].len(), len);
|
||||
for u in [0.1, 0.25, 0.5, 0.75, 0.99] {
|
||||
let n = (u * len as f64) as usize;
|
||||
near(fast[0][n], 1.0 / 3.0 + 2.0 / 3.0 * speed_up_pos(u), &format!("speed up at {u}"));
|
||||
}
|
||||
assert!(step(&fast[0], 200) < 0.02, "speed up starts near a stop: {}", step(&fast[0], 200));
|
||||
assert!((step(&fast[0], len - 100) - 1.0).abs() < 0.01, "speed up ends at normal speed: {}", step(&fast[0], len - 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_extend_plays_everything_over_150_percent() {
|
||||
let len = 30_000;
|
||||
let x = vec![ramp(len), ramp(len)];
|
||||
for change in [SpeedRampChange::SlowDown, SpeedRampChange::SpeedUp] {
|
||||
let out = offline::speed_ramp(&x, change, SpeedRampSelection::Extend, false);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(out[0].len(), 45_000, "{change:?}");
|
||||
assert_eq!(out[0], out[1], "{change:?}: both channels are read at the same positions");
|
||||
for u in [0.1, 0.25, 0.5, 0.75, 0.97] {
|
||||
let n = (u * 45_000.0) as usize;
|
||||
let pos = if change == SpeedRampChange::SlowDown { slow_down_pos(u) } else { speed_up_pos(u) };
|
||||
near(out[0][n], pos, &format!("{change:?} at {u}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_fades_follow_the_speed() {
|
||||
let len = 30_000;
|
||||
let x = vec![vec![0.5; len]];
|
||||
let slow = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::FitTo, true);
|
||||
let fast = offline::speed_ramp(&x, SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, true);
|
||||
for u in [0.25, 0.5, 0.75] {
|
||||
let n = (u * len as f64) as usize;
|
||||
near(slow[0][n], 0.5 * (1.0 - u * u), &format!("slow down fades out at {u}"));
|
||||
near(fast[0][n], 0.5 * (1.0 - (1.0 - u) * (1.0 - u)), &format!("speed up fades in at {u}"));
|
||||
}
|
||||
assert!(slow[0][len - 1].abs() < 1e-3 && fast[0][0].abs() < 1e-3, "the fades reach silence at the stop");
|
||||
// Fades Off keeps the level.
|
||||
let off = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::FitTo, false);
|
||||
for u in [0.25, 0.5, 0.75, 0.9] {
|
||||
near(off[0][(u * len as f64) as usize], 0.5, &format!("slow down without fades at {u}"));
|
||||
}
|
||||
// ... at the stop too, where the reads reach either end of the input.
|
||||
let up = offline::speed_ramp(&x, SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, false);
|
||||
near(up[0][0], 0.5, "speed up without fades from the stop");
|
||||
near(off[0][len - 1], 0.5, "slow down without fades at the stop");
|
||||
let off = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::Extend, false);
|
||||
near(off[0][off[0].len() - 1], 0.5, "slow down with Extend and without fades at the stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_pitch_falls_as_the_tape_slows() {
|
||||
let sr = 48_000.0;
|
||||
let len = 96_000;
|
||||
let x = vec![(0..len).map(|i| (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr).sin() * 0.5).collect::<Vec<f32>>()];
|
||||
let out = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::Extend, false);
|
||||
// Upward zero crossings per second in a 0.1 s window starting at `u`.
|
||||
let rate = |u: f64| {
|
||||
let a = (u * out[0].len() as f64) as usize;
|
||||
let w = &out[0][a..a + 4800];
|
||||
w.windows(2).filter(|p| p[0] < 0.0 && p[1] >= 0.0).count() as f64 * 10.0
|
||||
};
|
||||
assert!((rate(0.0) - 1000.0).abs() < 20.0, "starts at the original pitch: {}", rate(0.0));
|
||||
let half = 1000.0 * 0.75;
|
||||
assert!((rate(0.5) - half).abs() < 30.0, "three quarters speed halfway: {}", rate(0.5));
|
||||
assert!(rate(0.9) < 250.0, "low near the stop: {}", rate(0.9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_handles_empty_and_uneven_channels() {
|
||||
assert!(offline::speed_ramp(&[], SpeedRampChange::SlowDown, SpeedRampSelection::Extend, true).is_empty());
|
||||
let out = offline::speed_ramp(&[vec![], vec![]], SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, true);
|
||||
assert!(out.iter().all(Vec::is_empty));
|
||||
// A shorter channel is read as silence past its end, and every channel gets the same length.
|
||||
let out = offline::speed_ramp(&[vec![0.5; 300], vec![0.5; 30]], SpeedRampChange::SlowDown, SpeedRampSelection::Extend, false);
|
||||
assert_eq!((out[0].len(), out[1].len()), (450, 450));
|
||||
assert!(out.iter().flatten().all(|v| v.is_finite()));
|
||||
near(out[0][449], 0.5, "the longer channel at the stop");
|
||||
assert!(out[1][200..].iter().all(|v| v.abs() < 1e-3), "the shorter channel is silent past its end");
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -22,6 +22,8 @@ serde.workspace = true
|
||||
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,719 @@
|
||||
//! 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());
|
||||
create_folder(root).map_err(|err| io_error(root, err))?;
|
||||
let mut dir = root.join(&base);
|
||||
let mut n = 1;
|
||||
loop {
|
||||
// Not recursive, so a folder that already exists is reported and the next name tried.
|
||||
#[cfg_attr(not(unix), allow(unused_mut))]
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
match builder.create(&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()))?;
|
||||
create_folder(&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);
|
||||
}
|
||||
|
||||
/// Create `dir` and its missing parents under the autosave root. On Unix every folder made here is
|
||||
/// owner-only (`0o700`): autosaves and the takes of a session never saved are unsaved work, and
|
||||
/// with the default mode other accounts on the machine could read them. A folder that already
|
||||
/// exists keeps its mode.
|
||||
pub fn create_folder(dir: &Path) -> std::io::Result<()> {
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
builder.create(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);
|
||||
}
|
||||
|
||||
/// Autosaves are unsaved work: on Unix the folders the autosaver makes are owner-only, and an
|
||||
/// autosave root that already exists keeps its mode.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_autosaver_makes_owner_only_folders() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
let base = folder("autosave-modes");
|
||||
let mut e = Engine::default();
|
||||
record(&mut e, 0.5);
|
||||
// A root that doesn't exist yet is made with the first autosave, owner-only like the
|
||||
// session's folder and the audio copied into it.
|
||||
let new = base.join("Autosave");
|
||||
let w = Autosaver::new(new.clone());
|
||||
w.write(e.session_arc(), None, None);
|
||||
w.flush().unwrap();
|
||||
let session = std::fs::read_dir(&new).unwrap().flatten().map(|d| d.path()).next().unwrap();
|
||||
assert_eq!((mode(&new), mode(&session), mode(&session.join("Audio Files"))), (0o700, 0o700, 0o700));
|
||||
w.close(false);
|
||||
w.flush().unwrap();
|
||||
// A root that already exists keeps its mode; the folders made inside it are owner-only.
|
||||
let old = base.join("old");
|
||||
std::fs::create_dir_all(&old).unwrap();
|
||||
std::fs::set_permissions(&old, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let w = Autosaver::new(old.clone());
|
||||
w.write(e.session_arc(), None, None);
|
||||
w.flush().unwrap();
|
||||
let session = std::fs::read_dir(&old).unwrap().flatten().map(|d| d.path()).next().unwrap();
|
||||
assert_eq!((mode(&old), mode(&session)), (0o755, 0o700));
|
||||
// A session never saved records its takes into `<name>/Audio Files` under the root. Both
|
||||
// are made owner-only, and so is `Audio Files` in a take folder an earlier version left.
|
||||
let takes = old.join("Untitled").join("Audio Files");
|
||||
create_folder(&takes).unwrap();
|
||||
assert_eq!((mode(&old.join("Untitled")), mode(&takes)), (0o700, 0o700));
|
||||
let earlier = old.join("Earlier");
|
||||
std::fs::create_dir_all(&earlier).unwrap();
|
||||
std::fs::set_permissions(&earlier, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
create_folder(&earlier.join("Audio Files")).unwrap();
|
||||
assert_eq!((mode(&earlier), mode(&earlier.join("Audio Files"))), (0o755, 0o700));
|
||||
w.close(false);
|
||||
w.flush().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[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,26 @@ 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();
|
||||
// The file does not hold the Auto Rename schemes of the session it came from.
|
||||
c.auto_name = None;
|
||||
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 +233,106 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_clips_carry_no_auto_rename_scheme() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-names-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("named.scgrp").to_string_lossy().to_string();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let kick = e.session().track_by_name("Kick").unwrap().clips()[0].id.0;
|
||||
e.execute("clip.auto_rename", &json!({"clips": [kick], "name": "Hit"})).unwrap();
|
||||
e.execute("file.export_clip_groups", &json!({"path": path, "clips": [kick]})).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 30_000_000, "track": "Bass"})).unwrap();
|
||||
let placed = soundcraft_model::ClipId(r["clips"][0].as_u64().unwrap());
|
||||
assert_eq!(e.session().find_clip(placed).unwrap().1.auto_name, None);
|
||||
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,12 @@
|
||||
//! 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_dsp::offline::{SpeedRampChange, SpeedRampSelection};
|
||||
use soundcraft_model::{Clip, ClipContent, TrackKind};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
@@ -12,7 +14,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 | speed_ramp | 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); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)",
|
||||
has_selection,
|
||||
process
|
||||
)]
|
||||
@@ -30,22 +32,54 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let known = soundcraft_dsp::plugin_info(&name).is_some()
|
||||
|| matches!(
|
||||
name.as_str(),
|
||||
"normalize" | "reverse" | "invert" | "gain" | "duplicate" | "time_stretch" | "pitch_shift" | "varispeed" | "dc_offset_removal"
|
||||
"normalize"
|
||||
| "reverse"
|
||||
| "invert"
|
||||
| "gain"
|
||||
| "duplicate"
|
||||
| "time_stretch"
|
||||
| "pitch_shift"
|
||||
| "varispeed"
|
||||
| "speed_ramp"
|
||||
| "dc_offset_removal"
|
||||
);
|
||||
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;
|
||||
let change = if get("change", 0.0) >= 0.5 { SpeedRampChange::SpeedUp } else { SpeedRampChange::SlowDown };
|
||||
let selection = if get("selection", 0.0) >= 0.5 { SpeedRampSelection::Extend } else { SpeedRampSelection::FitTo };
|
||||
let fades = get("fades", 1.0) >= 0.5;
|
||||
// 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()),
|
||||
"speed_ramp" => longest(if selection == SpeedRampSelection::Extend { 1.5 } else { 1.0 }),
|
||||
_ => 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 +112,12 @@ 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),
|
||||
"speed_ramp" => soundcraft_dsp::offline::speed_ramp(&audio, change, selection, fades),
|
||||
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,8 +133,14 @@ 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);
|
||||
nc.gain_db = 0.0;
|
||||
nc.gain_env.clear();
|
||||
// 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") {
|
||||
nc.gain_db = 0.0;
|
||||
nc.gain_env.clear();
|
||||
}
|
||||
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips.retain(|c| c.id != id);
|
||||
}
|
||||
@@ -118,7 +150,206 @@ 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)?;
|
||||
let mut budget = soundcraft_mix::MAX_RENDER_SAMPLES;
|
||||
// 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) {
|
||||
let channels = t.channels().max(1);
|
||||
crate::io::refuse_longer(e.session(), "audiosuite.process", &t.name, "generate on", r.len(), crate::io::render_limit(channels))?;
|
||||
// Each track keeps its generated buffer for the life of the command (and undo holds it), so
|
||||
// cap the total samples generated across tracks, not just each one.
|
||||
let frames = usize::try_from(r.len()).unwrap_or(0).min(crate::io::render_limit(channels));
|
||||
budget = budget
|
||||
.checked_sub(frames.saturating_mul(channels))
|
||||
.ok_or_else(|| bad("audiosuite.process", "too much audio to generate in one command"))?;
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{ChannelFormat, Session, TrackKind};
|
||||
|
||||
fn gained_clip() -> (crate::Engine, soundcraft_model::ClipId) {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] };
|
||||
let src = crate::io::add_source(&mut s, "tone", buf, None, FileFormat::Wav);
|
||||
let id = s.new_clip_id();
|
||||
let mut c = Clip::audio(id, "tone", src, 0, 0, 64);
|
||||
c.gain_db = -6.0;
|
||||
c.gain_env = vec![(0, 0.0), (32, -12.0)];
|
||||
crate::edit::place_clip(&mut s, t, c);
|
||||
s.edit.selected_clips = vec![id];
|
||||
(crate::Engine::new(s), id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invert_and_duplicate_keep_clip_gain() {
|
||||
let (mut e, id) = gained_clip();
|
||||
e.execute("audiosuite.process", &json!({"process": "invert", "clips": [id.0]})).unwrap();
|
||||
let c = e.session().tracks[0].clips()[0].clone();
|
||||
assert!((c.gain_db + 6.0).abs() < 1e-6, "{}", c.gain_db);
|
||||
assert_eq!(c.gain_env, vec![(0, 0.0), (32, -12.0)]);
|
||||
let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0];
|
||||
assert!((sample + 0.5).abs() < 1e-3, "invert flips the file and leaves the gain on the clip, got {sample}");
|
||||
|
||||
let (mut e, id) = gained_clip();
|
||||
e.execute("audiosuite.process", &json!({"process": "duplicate", "clips": [id.0]})).unwrap();
|
||||
let c = e.session().tracks[0].clips()[0].clone();
|
||||
assert!((c.gain_db + 6.0).abs() < 1e-6, "{}", c.gain_db);
|
||||
assert_eq!(c.gain_env, vec![(0, 0.0), (32, -12.0)]);
|
||||
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 speed_ramp_ramps_the_speed_and_extend_lengthens_the_clip() {
|
||||
// A mono clip at 480 whose samples rise from 0 to 1, so each output sample shows where it was read.
|
||||
let run = |params: Value| {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Ramp"));
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![(0..4800).map(|i| i as f32 / 4800.0).collect()] };
|
||||
let src = crate::io::add_source(&mut s, "ramp", buf, None, FileFormat::Wav);
|
||||
let id = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, t, Clip::audio(id, "ramp", src, 0, 480, 4800));
|
||||
let mut e = crate::Engine::new(s);
|
||||
e.execute("audiosuite.process", &json!({"process": "speed_ramp", "params": params, "clips": [id.0]})).unwrap();
|
||||
let c = e.session().tracks[0].clips()[0].clone();
|
||||
let audio = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0].clone();
|
||||
(c.start, c.length, c.name, audio)
|
||||
};
|
||||
// The defaults: Slow Down, Fit To, Fades On. Halfway through, the read position is 11/16 of the
|
||||
// way through the first two thirds, and the gain is the speed there, 3/4.
|
||||
let (start, length, name, audio) = run(json!({}));
|
||||
assert_eq!((start, length, name.as_str()), (480, 4800, "ramp-speeramp"));
|
||||
let want = (2.0 / 3.0 * 11.0 / 16.0) * 0.75;
|
||||
assert!((audio[2400] - want).abs() < 2e-3, "slow down halfway: got {}, want {want}", audio[2400]);
|
||||
assert!(audio[4790].abs() < 0.01, "the fade-out ends near silence, got {}", audio[4790]);
|
||||
// Speed Up with Extend and no fades: all of the clip is played over 150 % of its length, and the
|
||||
// clip still starts at 480.
|
||||
let (start, length, _, audio) = run(json!({"change": 1, "selection": 1, "fades": 0}));
|
||||
assert_eq!((start, length), (480, 7200));
|
||||
assert!((audio[3600] - 5.0 / 16.0).abs() < 2e-3, "speed up halfway: got {}", audio[3600]);
|
||||
let r = 1.0 - 7100.0 / 7200.0;
|
||||
let want = 1.0 - (3.0 * r - r * r * r) / 2.0;
|
||||
assert!((audio[7100] - want).abs() < 2e-3, "speed up near the end: got {}, want {want}", audio[7100]);
|
||||
}
|
||||
|
||||
#[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)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_track_ids_generate_once_not_per_listing() {
|
||||
let mut e = Engine::new(Session::default());
|
||||
let t = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
|
||||
e.execute("edit.select", &json!({"tracks": [t.0], "start": 0, "end": 4800})).unwrap();
|
||||
// The same track eight times over: it must generate once, not eight times (#896).
|
||||
let out = e
|
||||
.execute(
|
||||
"audiosuite.process",
|
||||
&json!({"process": "signal_generator", "tracks": [t.0, t.0, t.0, t.0, t.0, t.0, t.0, t.0], "params": {"freq": 440.0}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out["processed"], 1);
|
||||
assert_eq!(e.session().tracks[0].clips().len(), 1, "one clip, not one per duplicate listing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_refuses_a_total_budget_across_tracks() {
|
||||
let mut e = Engine::new(Session::default());
|
||||
// Two tracks each near the per-track limit: the second would push the total past the budget.
|
||||
for n in 0..2 {
|
||||
e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, Some(&format!("T{n}")));
|
||||
}
|
||||
let t: Vec<_> = e.session().tracks.iter().map(|t| t.id.0).collect();
|
||||
// Select both tracks so signal_generator targets them.
|
||||
e.execute("edit.select", &json!({"tracks": [t[0], t[1]], "start": 0, "end": 4800})).unwrap();
|
||||
e.execute("audiosuite.process", &serde_json::json!({"process": "signal_generator", "track": t[0], "params": {"freq": 440.0}})).unwrap();
|
||||
assert_eq!(e.session().tracks[0].clips().len(), 1, "one track generates one clip");
|
||||
// Two distinct tracks over the total budget are refused (MAX_RENDER_SAMPLES is f32 samples
|
||||
// across all channels; a single track over the limit is already refused per-track).
|
||||
let len = (soundcraft_mix::MAX_RENDER_SAMPLES as i64) / 3 * 2;
|
||||
let out = e.execute(
|
||||
"audiosuite.process",
|
||||
&serde_json::json!({"process": "signal_generator", "tracks": [t[0], t[1]], "params": {"freq": 440.0}, "start": 0, "end": len}),
|
||||
);
|
||||
let ok = matches!(out, Err(crate::EngineError::BadParams(_, ref m)) if m.contains("too much audio"));
|
||||
assert!(ok, "two tracks over budget, but got: {out:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user