mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-12 08:53:41 -06:00
Compare commits
697
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a166a5d00 | ||
|
|
220b42773d | ||
|
|
6a0949d9d0 | ||
|
|
4782cfa600 | ||
|
|
91952090ec | ||
|
|
55c89b364d | ||
|
|
eeb5d653a5 | ||
|
|
c25b12ba46 | ||
|
|
296bfbb2e1 | ||
|
|
f7a5adb474 | ||
|
|
23242338a6 | ||
|
|
e01ea00cdd | ||
|
|
659ec7a8e2 | ||
|
|
90e069f626 | ||
|
|
7a4f9c911d | ||
|
|
60f97a99a5 | ||
|
|
d255ca4fe9 | ||
|
|
1fe1d06073 | ||
|
|
10e4bc7b87 | ||
|
|
1d696bb3ef | ||
|
|
9f342bf403 | ||
|
|
17649aa693 | ||
|
|
0e511b1259 | ||
|
|
927c4f7b72 | ||
|
|
69af50aac2 | ||
|
|
e86d09ab2a | ||
|
|
ad34dbf87e | ||
|
|
f780dcab37 | ||
|
|
ff4be04936 | ||
|
|
1483dc833b | ||
|
|
2c042c599f | ||
|
|
e94a7875d3 | ||
|
|
dc4f82ea9f | ||
|
|
df67089ccd | ||
|
|
44e80dcc24 | ||
|
|
ef58a90f4a | ||
|
|
3f6703f87f | ||
|
|
c4c09ca789 | ||
|
|
7c6315d999 | ||
|
|
0ea934290d | ||
|
|
20338e0832 | ||
|
|
ddeb7e791f | ||
|
|
507786ad7a | ||
|
|
a7b7fb949e | ||
|
|
16e495550c | ||
|
|
e1b22258bc | ||
|
|
61becd52c5 | ||
|
|
76cc2aeac6 | ||
|
|
26cafa85bb | ||
|
|
8cdcab2697 | ||
|
|
436a2e22a8 | ||
|
|
b6f9f150a1 | ||
|
|
fbd7789b4a | ||
|
|
4edd315adb | ||
|
|
654deabd54 | ||
|
|
6c917ea378 | ||
|
|
c05dfc0a8e | ||
|
|
6f54446423 | ||
|
|
24451d5c42 | ||
|
|
7d508e2e44 | ||
|
|
bb0e070ab9 | ||
|
|
7bf178835f | ||
|
|
e66a5e7c67 | ||
|
|
87f46cf23b | ||
|
|
54cb247e12 | ||
|
|
fca0130b72 | ||
|
|
77b788007a | ||
|
|
73eeb692fe | ||
|
|
bd75fe691b | ||
|
|
6c8a9dd300 | ||
|
|
d393730b02 | ||
|
|
4240ffda42 | ||
|
|
c046c3bb1b | ||
|
|
e6cbf76148 | ||
|
|
1db9563379 | ||
|
|
341a112757 | ||
|
|
b93a182ef7 | ||
|
|
bf2a6b52de | ||
|
|
594d0ade4f | ||
|
|
92e6973640 | ||
|
|
2b03df3ad4 | ||
|
|
b8e1e69134 | ||
|
|
b6946d3a94 | ||
|
|
5fabf28c31 | ||
|
|
fa34e0c182 | ||
|
|
ccebb8ff93 | ||
|
|
b7d78c7e9e | ||
|
|
b26ab2b25c | ||
|
|
76f58fcb81 | ||
|
|
6a4c055094 | ||
|
|
d5564bce05 | ||
|
|
87ad11cccc | ||
|
|
b11ffe73ae | ||
|
|
f91b4173b6 | ||
|
|
98c3a307c3 | ||
|
|
6492b5f27e | ||
|
|
9c202d6f17 | ||
|
|
44b864ffaf | ||
|
|
035de82c84 | ||
|
|
181f329ec8 | ||
|
|
5ba9f9d7b8 | ||
|
|
c15682a088 | ||
|
|
0d253b6d8c | ||
|
|
c0f2f7dc87 | ||
|
|
f99407fb1f | ||
|
|
636454e639 | ||
|
|
7546ab992b | ||
|
|
f86b077cdf | ||
|
|
aa250cc1d7 | ||
|
|
dbcbcbac00 | ||
|
|
351b9d3a55 | ||
|
|
da02c19fcc | ||
|
|
64540f109b | ||
|
|
0af1ad909e | ||
|
|
7587130d59 | ||
|
|
e4f0599a2a | ||
|
|
17d3f00700 | ||
|
|
f46ea7d70c | ||
|
|
1403bbc4d3 | ||
|
|
b0793ca875 | ||
|
|
1c350101bc | ||
|
|
36d42e0136 | ||
|
|
c692648200 | ||
|
|
bf274560ae | ||
|
|
b35dd0f9eb | ||
|
|
087978a4a0 | ||
|
|
9b5554e312 | ||
|
|
04dab602c7 | ||
|
|
e5d3174929 | ||
|
|
27329587c6 | ||
|
|
1dbc185e01 | ||
|
|
576ff36f61 | ||
|
|
4b06407dae | ||
|
|
1a95692f50 | ||
|
|
7dfa7e470f | ||
|
|
8f7eb4d777 | ||
|
|
ffc035ba29 | ||
|
|
8444edf075 | ||
|
|
6c3a0dfc50 | ||
|
|
d557d18118 | ||
|
|
98cda6ecaf | ||
|
|
920ec2c3f3 | ||
|
|
5e8898976e | ||
|
|
c426b889bb | ||
|
|
e569fbc50e | ||
|
|
e1f10ccdc8 | ||
|
|
627bbbf9cc | ||
|
|
84baa5debc | ||
|
|
468bec487c | ||
|
|
db51dd1739 | ||
|
|
d588e74667 | ||
|
|
71f899baac | ||
|
|
ccbeec6680 | ||
|
|
bdb73895a5 | ||
|
|
1a4a88fb0d | ||
|
|
9e902b48fe | ||
|
|
265e526f6a | ||
|
|
d2298ff974 | ||
|
|
cf40296888 | ||
|
|
7169dc492e | ||
|
|
381832447c | ||
|
|
b32065a109 | ||
|
|
85ffa1f53e | ||
|
|
97e2603683 | ||
|
|
444a716396 | ||
|
|
ba86e7eae7 | ||
|
|
54d36c2fcc | ||
|
|
382965153a | ||
|
|
2de7846d4b | ||
|
|
eb095cbe81 | ||
|
|
ef2f70eb35 | ||
|
|
e60a559596 | ||
|
|
4716141401 | ||
|
|
314be8740e | ||
|
|
675673346b | ||
|
|
ba3b191440 | ||
|
|
60cd0622f2 | ||
|
|
87836c3ec2 | ||
|
|
c896668d00 | ||
|
|
deceb0cd3a | ||
|
|
6446eb5598 | ||
|
|
a4e4a44bab | ||
|
|
99895de2c7 | ||
|
|
48f61d297e | ||
|
|
6e2da18e9e | ||
|
|
13b2488e95 | ||
|
|
6ef3300aba | ||
|
|
ad3147388f | ||
|
|
a13a4f5bd2 | ||
|
|
2d88f46f02 | ||
|
|
f28a10a5dd | ||
|
|
73e3478ae3 | ||
|
|
53bb43db46 | ||
|
|
b9fee48a75 | ||
|
|
948460a953 | ||
|
|
4179cf88e6 | ||
|
|
ca6271b082 | ||
|
|
4c31f2884b | ||
|
|
1964662a71 | ||
|
|
5288e42bc9 | ||
|
|
a97ce7556c | ||
|
|
13fe1df4b8 | ||
|
|
b2a386bc7a | ||
|
|
331b1820db | ||
|
|
966e253873 | ||
|
|
e8bc4758cc | ||
|
|
af4dd7ce4c | ||
|
|
f7b0805de6 | ||
|
|
cc02045a2d | ||
|
|
ddfe991a87 | ||
|
|
8da40bef96 | ||
|
|
90e8f60bad | ||
|
|
3bd3965c51 | ||
|
|
5a1324b8bc | ||
|
|
7902d59b47 | ||
|
|
552607cbee | ||
|
|
9f5f312051 | ||
|
|
92e74f6e00 | ||
|
|
ffb95ebcf3 | ||
|
|
440999a51f | ||
|
|
4740e8d4c4 | ||
|
|
14a081304b | ||
|
|
072e548ec8 | ||
|
|
98fae00688 | ||
|
|
d2079bf09d | ||
|
|
5447c94cb6 | ||
|
|
3276358bef | ||
|
|
081f43b90a | ||
|
|
b4412929a7 | ||
|
|
ca27f0f8b4 | ||
|
|
5b69a11d89 | ||
|
|
81ab012724 | ||
|
|
5075da2f3e | ||
|
|
a62d955a39 | ||
|
|
af4970e388 | ||
|
|
9e768d659b | ||
|
|
4e9d778734 | ||
|
|
c2f14dff49 | ||
|
|
f39413033e | ||
|
|
19f4b695e2 | ||
|
|
17d553a7a7 | ||
|
|
57ae0a8b5d | ||
|
|
52083e07f1 | ||
|
|
b46584cc30 | ||
|
|
52b5cea05f | ||
|
|
228fd37ab7 | ||
|
|
60d18f225d | ||
|
|
ba0c6e381f | ||
|
|
bcb872f1a3 | ||
|
|
b119e3adce | ||
|
|
0d8dde8294 | ||
|
|
94fb7db790 | ||
|
|
3f5377c877 | ||
|
|
3b87c6dcc2 | ||
|
|
d2244d0765 | ||
|
|
e2809001cf | ||
|
|
92c925a1dc | ||
|
|
140e63fe09 | ||
|
|
c3cf5fe236 | ||
|
|
1ce93ab2ea | ||
|
|
dd1ac56430 | ||
|
|
fda41ecd12 | ||
|
|
6a10dfa19d | ||
|
|
672f5ab767 | ||
|
|
11ab6a999c | ||
|
|
b84d6c95d5 | ||
|
|
1d99907d85 | ||
|
|
57b3ae5357 | ||
|
|
2e6c519bef | ||
|
|
adc7c13a37 | ||
|
|
f5d7ca5d87 | ||
|
|
a6df32ffab | ||
|
|
7b654fd1a3 | ||
|
|
f16e312fbc | ||
|
|
986fb951df | ||
|
|
d9c293a92b | ||
|
|
5dbebaf617 | ||
|
|
5c8654b2d7 | ||
|
|
d97e5e7f16 | ||
|
|
a63b23adf7 | ||
|
|
0ede26f5bc | ||
|
|
09444326d5 | ||
|
|
b609172db6 | ||
|
|
65e5443975 | ||
|
|
1c284e3703 | ||
|
|
179f0c8c5d | ||
|
|
8ca6939981 | ||
|
|
60b2c07a70 | ||
|
|
c6befe57af | ||
|
|
9f2be37e36 | ||
|
|
e600e6bf64 | ||
|
|
90932afed0 | ||
|
|
50d0028e07 | ||
|
|
dcdcca4704 | ||
|
|
fd9c673694 | ||
|
|
1a20190149 | ||
|
|
1ffc164082 | ||
|
|
c14dc61473 | ||
|
|
e82c1689b3 | ||
|
|
ceb3404c31 | ||
|
|
7c029c40ce | ||
|
|
ed0aed5ccb | ||
|
|
40f593a360 | ||
|
|
1aab8e449d | ||
|
|
662cf85c00 | ||
|
|
c7a7cfa09b | ||
|
|
a8c19f0ffe | ||
|
|
bf9ad66ea6 | ||
|
|
747c79e3a7 | ||
|
|
79c45a154d | ||
|
|
d293336eac | ||
|
|
d7c194d728 | ||
|
|
7efeed6c9e | ||
|
|
7511548c7e | ||
|
|
1e6310f85b | ||
|
|
9adcc1f389 | ||
|
|
f92150c64e | ||
|
|
240726bf32 | ||
|
|
6686118627 | ||
|
|
5396ecbdbc | ||
|
|
de6108a34d | ||
|
|
450ba857c1 | ||
|
|
795f1cb6ef | ||
|
|
e631966cf7 | ||
|
|
45498c8268 | ||
|
|
60087e0f46 | ||
|
|
6dcac57282 | ||
|
|
afa1532aba | ||
|
|
aabe5ae320 | ||
|
|
0d0d0abc27 | ||
|
|
8f50b5e455 | ||
|
|
a69481c284 | ||
|
|
49463b3c95 | ||
|
|
640180997c | ||
|
|
57f6033a6b | ||
|
|
3c27179667 | ||
|
|
fe945d8058 | ||
|
|
f196310402 | ||
|
|
26b3e417a2 | ||
|
|
2471f6b8ab | ||
|
|
834fe6588f | ||
|
|
0efc9a6e5e | ||
|
|
c28bf90d58 | ||
|
|
e90a6b4a7c | ||
|
|
e6eaf52e8c | ||
|
|
9c7ff62bf5 | ||
|
|
47c7420e4f | ||
|
|
691dc895e4 | ||
|
|
db163e35b5 | ||
|
|
120acefa9e | ||
|
|
efa2434f5c | ||
|
|
1ae4f569d2 | ||
|
|
3729da0776 | ||
|
|
37dbadfc04 | ||
|
|
19b3f5ec9f | ||
|
|
db6e7ed1cc | ||
|
|
a53385acf0 | ||
|
|
408e5cd961 | ||
|
|
741a381ae5 | ||
|
|
20f4cca8ec | ||
|
|
7c3547ffd4 | ||
|
|
23394f0b6a | ||
|
|
01c70688bf | ||
|
|
c4fb9afb8b | ||
|
|
552fdd5c1b | ||
|
|
fcd1e06d02 | ||
|
|
97de0a35f2 | ||
|
|
64154c7e71 | ||
|
|
74f305d1e5 | ||
|
|
e7103c3df5 | ||
|
|
27a00c9f55 | ||
|
|
9207a9d37c | ||
|
|
c1df0b83cd | ||
|
|
9dc26fa1ca | ||
|
|
75e5b90a86 | ||
|
|
7a9b3e38e1 | ||
|
|
9a99c82d2c | ||
|
|
7dc56a4dbe | ||
|
|
088e270a5e | ||
|
|
23f90a9164 | ||
|
|
0c7212854a | ||
|
|
84e591e1ca | ||
|
|
1c63b2a5b9 | ||
|
|
cf0f4d2f6a | ||
|
|
e14cea42b2 | ||
|
|
d683aee3eb | ||
|
|
1f8b267722 | ||
|
|
0197e27fc4 | ||
|
|
5bbefeae62 | ||
|
|
ee55acbc93 | ||
|
|
a44d1b830b | ||
|
|
eec559f057 | ||
|
|
b854771251 | ||
|
|
0a95b6dadf | ||
|
|
cf96cb888c | ||
|
|
e0951aecf9 | ||
|
|
01b39850ad | ||
|
|
b48df2a8b9 | ||
|
|
106678471f | ||
|
|
e294aa48c0 | ||
|
|
021caab499 | ||
|
|
66f305b747 | ||
|
|
fa77e03e16 | ||
|
|
ccd3fe7ca2 | ||
|
|
b9accf92a8 | ||
|
|
7d4ae23202 | ||
|
|
d33cac774a | ||
|
|
9f049ac7d0 | ||
|
|
7509b64c5c | ||
|
|
541096d2cb | ||
|
|
353ea145aa | ||
|
|
35905fb90b | ||
|
|
dab64379cb | ||
|
|
d7b1562cc1 | ||
|
|
9048e04d60 | ||
|
|
e954639900 | ||
|
|
bbebfa1a6e | ||
|
|
9821168bc4 | ||
|
|
e46339606a | ||
|
|
835de9f038 | ||
|
|
e910e72940 | ||
|
|
54467f544b | ||
|
|
4311742624 | ||
|
|
a8ca533b9b | ||
|
|
3a139d4bed | ||
|
|
092122b49c | ||
|
|
be75be896a | ||
|
|
f77c360f94 | ||
|
|
a6cec46275 | ||
|
|
10e26f6fe2 | ||
|
|
c0dc2c3ec6 | ||
|
|
06f30628bb | ||
|
|
746a825ed5 | ||
|
|
fe4cd8095d | ||
|
|
a41f254aa0 | ||
|
|
95f25979eb | ||
|
|
929a7c5307 | ||
|
|
ee63b09cbe | ||
|
|
698507cf30 | ||
|
|
1e9a0e9c88 | ||
|
|
4f771268b0 | ||
|
|
ee050db59c | ||
|
|
37e155c828 | ||
|
|
0ca492e0ab | ||
|
|
3e56738290 | ||
|
|
d5be1e6de3 | ||
|
|
8385ed94c6 | ||
|
|
8f23afe462 | ||
|
|
6d86a52310 | ||
|
|
7b19c3068a | ||
|
|
9b60e84fc3 | ||
|
|
02b153797d | ||
|
|
4a0e4caa3f | ||
|
|
dc9e84978f | ||
|
|
9d5f5784d5 | ||
|
|
aa74bffac1 | ||
|
|
40ced8132b | ||
|
|
b322ff2f13 | ||
|
|
c5ddbbafd1 | ||
|
|
f0f9addd16 | ||
|
|
e884d6eb37 | ||
|
|
4ab3bde39b | ||
|
|
824cf5cecb | ||
|
|
f6a27c2ccf | ||
|
|
08679d5a65 | ||
|
|
7469834420 | ||
|
|
e220666546 | ||
|
|
93800e0764 | ||
|
|
4071975aa4 | ||
|
|
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 | ||
|
|
bf56b66b5c | ||
|
|
82202fe88c | ||
|
|
ef14d75534 | ||
|
|
64669e9a7c | ||
|
|
07b85277ee | ||
|
|
1f72dc30be | ||
|
|
525a4e02fe | ||
|
|
8b34dc533f | ||
|
|
2e057309b9 | ||
|
|
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 | ||
|
|
8198d6e35a | ||
|
|
1c1df8728d | ||
|
|
52253e89c5 | ||
|
|
63eacf115d | ||
|
|
76bac6495e | ||
|
|
16371a8502 | ||
|
|
717ae97af5 | ||
|
|
01c8c85ebc | ||
|
|
187ee51ae0 | ||
|
|
4ef6458e4e | ||
|
|
152c2c45e6 | ||
|
|
96130d22c7 | ||
|
|
988d678eec | ||
|
|
797aaac058 | ||
|
|
5703db0b80 | ||
|
|
9e9177f1af | ||
|
|
3ea1f34a1d | ||
|
|
6e8c039097 | ||
|
|
1816f2dc92 | ||
|
|
09c2e71141 | ||
|
|
dfde8a6724 | ||
|
|
0d8a92f53b | ||
|
|
5d7197a2d0 | ||
|
|
b5bcf577b2 | ||
|
|
4eee6b17a8 | ||
|
|
82616dcfb8 | ||
|
|
5ddd4b115f | ||
|
|
c8f8fc45eb | ||
|
|
9ac264184e | ||
|
|
3c04a3c36f | ||
|
|
93ac4f2913 | ||
|
|
352d90a24a | ||
|
|
278b41b8ff | ||
|
|
c09dab9fef | ||
|
|
4110f5ff10 | ||
|
|
a72e46e7b6 | ||
|
|
6f3591ae11 | ||
|
|
0a9d29f227 | ||
|
|
a4b6bd0179 | ||
|
|
3ea890496e | ||
|
|
7a086baa7b | ||
|
|
5f03a5991e | ||
|
|
adab577f9a | ||
|
|
05fe56ec1a | ||
|
|
fb087c879b | ||
|
|
075104f448 | ||
|
|
ea0e4719fc | ||
|
|
2cba9ed4cd | ||
|
|
315745b022 | ||
|
|
952ca5e4ed | ||
|
|
b48a939d64 | ||
|
|
7849e8cf51 | ||
|
|
800b27013d | ||
|
|
7bf80663dc | ||
|
|
b50373d6db | ||
|
|
1e1458caf1 | ||
|
|
76696826cd | ||
|
|
f8b2f8ce2e | ||
|
|
d4096db1f0 | ||
|
|
28c936a256 | ||
|
|
2dd8f7da55 | ||
|
|
08b0704b0c | ||
|
|
d13f6657f8 | ||
|
|
bddc113f46 | ||
|
|
b7329ee904 | ||
|
|
9a398d79ff | ||
|
|
f9d04ffc98 | ||
|
|
245015fd89 |
@@ -0,0 +1,3 @@
|
||||
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
|
||||
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
|
||||
nextest-version = "0.9.150"
|
||||
@@ -12,6 +12,14 @@ updates:
|
||||
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)"
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# This file is machine-generated by `gh actions-lock`.
|
||||
# Do not edit by hand; run `gh actions-lock` to update.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
version: 'v0.0.3'
|
||||
workflows:
|
||||
'.github/workflows/ast-grep.yml': []
|
||||
'.github/workflows/ci.yml': []
|
||||
'.github/workflows/freebsd.yml': []
|
||||
'.github/workflows/house-rules.yml': []
|
||||
'.github/workflows/lockfile.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/nix.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/packaging-lint.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/release.yml': []
|
||||
'.github/workflows/sonarcloud.yml': []
|
||||
'.github/workflows/windows-arm64.yml': []
|
||||
dependencies:
|
||||
'actions/checkout@v7.0.1':
|
||||
ref: 'v7.0.1'
|
||||
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
owner_id: 44036562
|
||||
repo_id: 197814629
|
||||
'step-security/harden-runner@v2.22.4':
|
||||
ref: 'v2.22.4'
|
||||
commit: 'sha1-1d88384fd173af6ecb012dfd0264a306b711241a'
|
||||
owner_id: 88700172
|
||||
repo_id: 422287306
|
||||
@@ -0,0 +1,26 @@
|
||||
# Structural rules for the command-line code (RFC-0012): lint/ast-grep's rule tests, then a scan.
|
||||
# Seconds, no Rust build.
|
||||
name: ast-grep
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["lint/ast-grep/**", "apps/soundcraft-cli/**", "crates/automation/src/args.rs", "apps/soundcraft/src/launch_args.rs", "apps/soundcraft/src/main.rs", ".github/workflows/ast-grep.yml"]
|
||||
pull_request:
|
||||
paths: ["lint/ast-grep/**", "apps/soundcraft-cli/**", "crates/automation/src/args.rs", "apps/soundcraft/src/launch_args.rs", "apps/soundcraft/src/main.rs", ".github/workflows/ast-grep.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ast-grep-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
# Tracked at @main like the other shared workflows.
|
||||
uses: storytold/craft-common-actions/.github/workflows/ast-grep.yml@main
|
||||
with:
|
||||
config: lint/ast-grep/sgconfig.yml
|
||||
paths: apps/soundcraft-cli crates/automation/src/args.rs apps/soundcraft/src/launch_args.rs apps/soundcraft/src/main.rs
|
||||
@@ -0,0 +1,27 @@
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
|
||||
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
libgl1-mesa-dev libasound2-dev
|
||||
@@ -3,20 +3,19 @@
|
||||
# player falls back to its silent clock when the VM has no sound device.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
|
||||
# or dependency change: a drifted dependency still shows up within a day, and always before a
|
||||
# release ships. PRs that change the FreeBSD packaging itself still run it.
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow or packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "47 3 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- "crates/playback/**"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "47 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: House rules
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: house-rules-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
house-rules:
|
||||
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
|
||||
with:
|
||||
rust: true
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Lockfile
|
||||
|
||||
# Fails when .github/workflows/actions.lock is out of sync with the workflows, or when a locked pin
|
||||
# no longer verifies upstream, so a moved tag cannot slip a different commit into a run.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
jobs:
|
||||
lockfile:
|
||||
name: Lockfile is in sync
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-actions-lock
|
||||
run: gh extension install github/gh-actions-lock --pin v0.4.1
|
||||
- name: Verify the lockfile
|
||||
run: gh actions-lock --verify
|
||||
@@ -21,3 +21,45 @@ 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:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@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
|
||||
|
||||
@@ -21,3 +21,31 @@ jobs:
|
||||
lint:
|
||||
# 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
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# A nightly dry run on main: every release build except macOS, nothing signed or released
|
||||
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
|
||||
schedule:
|
||||
- cron: "36 2 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
@@ -10,21 +14,31 @@ on:
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
release:
|
||||
needs: credits
|
||||
# The shared workflow creates the draft release, which needs write access to contents.
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
# 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:
|
||||
dry-run: ${{ github.event_name == 'schedule' }}
|
||||
app-name: soundcraft
|
||||
app-title: SoundCraft
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -40,3 +54,4 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
|
||||
@@ -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
|
||||
@@ -31,3 +31,5 @@ jobs:
|
||||
with:
|
||||
app-name: soundcraft
|
||||
app-dir: SoundCraft
|
||||
install-relative-path: Programs/ArtCraft/SoundCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -1,13 +1,62 @@
|
||||
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
|
||||
## House rules
|
||||
|
||||
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
|
||||
|
||||
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
|
||||
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
|
||||
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
|
||||
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
|
||||
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
|
||||
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
|
||||
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
|
||||
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
|
||||
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
|
||||
10. **Keep secrets and personal details out** of the repository, logs and requests.
|
||||
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
|
||||
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
|
||||
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
|
||||
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
|
||||
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
|
||||
<!-- craft-house-rules:end -->
|
||||
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
|
||||
## Rust Craft repositories
|
||||
|
||||
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
|
||||
|
||||
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
|
||||
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
|
||||
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
|
||||
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
|
||||
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
|
||||
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
|
||||
|
||||
## Licence allowlist
|
||||
|
||||
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
|
||||
|
||||
- `ISC`
|
||||
- `MIT`
|
||||
- `BSD-2-Clause`
|
||||
- `BSD-3-Clause`
|
||||
- `Apache-2.0`
|
||||
- `OFL-1.1`
|
||||
- `Ubuntu-font-1.0`
|
||||
- `Bitstream-Vera`
|
||||
- `CC0-1.0`
|
||||
- `CC-BY-4.0`
|
||||
- `CC-BY-SA-4.0`
|
||||
- `LicenseRef-Public-Domain`
|
||||
|
||||
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
|
||||
<!-- craft-house-rules:end -->
|
||||
|
||||
# SoundCraft — instructions for agents
|
||||
|
||||
SoundCraft is a clean-room, open-source, pure-Rust digital audio workstation targeting Avid Pro Tools parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft-apps folder, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
|
||||
|
||||
## 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 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.
|
||||
Read `ROADMAP.md` for stage, status and the parity numbers, and pick work from `docs/gaps.md`. The progress docs are `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. `plan/` is an optional gitignored notebook (`plan/protools/` holds an observed menu tree and screenshots, never committed). Do not ask for it and do not stop when it is absent.
|
||||
|
||||
## ⚠️ Assets: the absolute rule
|
||||
**No Avid, Pro Tools, Adobe or other proprietary iconography, images, sounds, presets or artwork — ever.** This is not negotiable.
|
||||
@@ -22,7 +71,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
|
||||
Pro Tools is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic sessions, take screenshots by window id (`screencapture -l <id>`), stored only under `plan/protools/screenshots/`. Never read, disassemble or copy anything inside the Pro Tools bundle; never copy Avid icons, artwork, plugin names, presets, or wording beyond feature names. Never copy GPL/AGPL/LGPL code (Ardour, Audacity, LMMS, Freeverb sources…). Our plugins have our own names.
|
||||
|
||||
## Never crash
|
||||
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.**
|
||||
- No panics in non-test code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- The one named exception is `soundcraft-clap-host` (`crates/clap-host`), the isolated unsafe helper crate for CLAP plugin FFI: `unsafe_code = "deny"` crate-wide, allowed only in `src/ffi.rs` with a `// SAFETY:` comment on every block, safe `Result` API.
|
||||
- The same exception, same rules, for `soundcraft-vst3-host` (`crates/vst3-host`), the isolated unsafe helper crate for VST3 plugin FFI (COM vtables via the `vst3` bindings, module loading, host-side COM objects).
|
||||
@@ -37,7 +86,7 @@ People trust SoundCraft with their recordings; a crash loses takes. **This outra
|
||||
## Architecture (layers enforced by `cargo xtask layers`)
|
||||
| Layer | Crate | Role |
|
||||
|---|---|---|
|
||||
| L0 | `soundcraft-time`, `soundcraft-audio-io`, `soundcraft-midi` | Timebases/tempo/grid; audio file formats + peaks; Standard MIDI Files + MIDI ops |
|
||||
| L0 | `soundcraft-time`, `soundcraft-midi` | Timebases/tempo/grid; Standard MIDI Files + MIDI ops |
|
||||
| L1 | `soundcraft-dsp` | Plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utility, instruments), offline processing, meters, FFT |
|
||||
| L2 | `soundcraft-model` | The session document (tracks, playlists, clips, mixer, routing, automation, markers, groups) |
|
||||
| L3 | `soundcraft-mix` | Mix engine shared by realtime playback and offline bounce |
|
||||
@@ -46,6 +95,8 @@ People trust SoundCraft with their recordings; a crash loses takes. **This outra
|
||||
| L6 | `soundcraft-ui-egui` | The swappable UI (egui) |
|
||||
| apps | `soundcraft`, `soundcraft-cli`, `soundcraft-web` | Entry points |
|
||||
|
||||
Audio file formats, codecs and waveform peaks are not a SoundCraft crate: they come from `craft-audio` in `storytold/craft-libs`, which every Craft app shares. Fix or extend a format there, never in a local copy; SoundCraft keeps only the mapping onto its session model (`soundcraft_model::FileFormat`).
|
||||
|
||||
Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin: panels read engine state and act through `engine.execute(id, params)`.
|
||||
|
||||
## Everything is a command
|
||||
@@ -66,7 +117,7 @@ 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; 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`.
|
||||
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/releasing.md`.
|
||||
|
||||
## Roadmap
|
||||
`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.
|
||||
@@ -80,11 +131,15 @@ Push to the `release` branch to build signed installers for macOS (universal DMG
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
(copy the template at the top of the file)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
New workspace crates include `[lints] workspace = true` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
|
||||
|
||||
+19
-9
@@ -22,21 +22,31 @@ 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 |
|
||||
|---|---|---|---|
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `assets/app-icon/icon.svg` | SoundCraft contributors | original (hand-placed SVG paths; placeholder app icon) | MIT OR Apache-2.0 |
|
||||
| `packaging/macos/dmg/` (all files) | @XusBadia | original (macOS DMG window background and layout, from the app icon; see `packaging/macos/dmg/README.md`) | MIT OR Apache-2.0 |
|
||||
| `assets/app-icon/soundcraft-1024.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -1 +1 @@
|
||||
Read `AGENTS.md`: it is the entry point for agents in this repo.
|
||||
@AGENTS.md
|
||||
|
||||
Generated
+1512
-604
File diff suppressed because it is too large
Load Diff
+57
-11
@@ -4,16 +4,15 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.11.0"
|
||||
version = "0.20.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/soundcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
soundcraft-time = { path = "crates/time" }
|
||||
soundcraft-audio-io = { path = "crates/audio-io" }
|
||||
soundcraft-midi = { path = "crates/midi" }
|
||||
soundcraft-video = { path = "crates/video" }
|
||||
soundcraft-dsp = { path = "crates/dsp" }
|
||||
@@ -29,27 +28,47 @@ soundcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
clap_complete = "4.6.11"
|
||||
clap_mangen = { version = "0.3.3", features = ["env"] }
|
||||
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"
|
||||
# Whether an output path is one of the inputs (the CLI refuses to replace an input unless asked).
|
||||
same-file = "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"] }
|
||||
rustfft = "6"
|
||||
# Session bundles (one file holding a session and its audio, for the browser): stored entries only.
|
||||
zip = { version = "9", default-features = false }
|
||||
cpal = "0.16"
|
||||
cpal = "0.18"
|
||||
|
||||
# Which file paths stored in a session may be opened.
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
|
||||
# Video containers and bitstreams, shared with the other craft apps.
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed" }
|
||||
# Audio file formats, codecs and waveform overviews, shared with the other craft apps. It shares
|
||||
# types with craft-media, so both stay on one rev.
|
||||
# Standard MIDI Files and the note/controller model, shared with the other craft apps.
|
||||
craft-midi = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed", features = ["serde"] }
|
||||
craft-audio = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed", features = ["decode", "lossless-encode", "peaks", "serde"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["serde"] }
|
||||
egui = { version = "0.37" }
|
||||
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
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"] }
|
||||
|
||||
@@ -57,19 +76,46 @@ image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
too_many_arguments = "deny"
|
||||
type_complexity = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
# symphonia's FLAC decoder overflows an i32 add on 32-bit LPC FLAC; the wrapped value is correct
|
||||
# (craft-audio README, "symphonia").
|
||||
[profile.dev.package.symphonia-bundle-flac]
|
||||
overflow-checks = false
|
||||
|
||||
[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,308 @@ 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.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
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.
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
</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">
|
||||
@@ -118,11 +122,11 @@ where things are.
|
||||
- **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, 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.
|
||||
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.
|
||||
@@ -130,11 +134,11 @@ where things are.
|
||||
playlist per take, input monitoring through the full channel strip, and autosave with recovery.
|
||||
- **Time and tempo:** Bars|Beats, Min:Secs, Timecode (every rate, drop-frame included),
|
||||
Feet+Frames and Samples; tempo and meter maps with a tempo editor; linear, parabolic and
|
||||
S-curve tempo ramps; key signatures and detected chords; Beat Detective and Identify Beat;
|
||||
pitch-preserving Elastic warping.
|
||||
S-curve tempo ramps; key signatures and detected chords; Beat Analysis and Identify Beat;
|
||||
pitch-preserving Elastic Audio 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.
|
||||
@@ -142,8 +146,7 @@ where things are.
|
||||
## Getting started
|
||||
|
||||
Download a build from the [releases page](https://github.com/storytold/soundcraft/releases), or build
|
||||
from source with Rust 1.95 or later for the desktop/web UI (the core workspace's declared
|
||||
minimum remains Rust 1.90):
|
||||
from source with Rust 1.95 or later:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/soundcraft
|
||||
@@ -164,6 +167,16 @@ Useful shortcuts: <kbd>Space</kbd> play/stop, <kbd>⌘</kbd><kbd>=</kbd> Mix/Edi
|
||||
separate, <kbd>⌘</kbd><kbd>D</kbd> duplicate, <kbd>Enter</kbd> new marker, <kbd>⌘</kbd><kbd>⇧</kbd><kbd>N</kbd>
|
||||
new tracks. **Setup › Keyboard Shortcuts** lists them all.
|
||||
|
||||
In **New Tracks**, each row has its own count, format, type, Samples/Ticks timebase and name.
|
||||
Use **+** to add a row below or **−** to remove it; **Create** makes all rows in order, with one
|
||||
Undo step (up to 128 tracks per creation). The last row stays available. The dropdown widths
|
||||
fit their longest option and remain fixed when the selection changes.
|
||||
|
||||
On macOS, **⌘←/→** changes the active row's format, **⌘↑/↓** its type (also with **⌃**),
|
||||
and **⌥⌘↑/↓** its timebase. **⇧⌃⌘↓/↑** adds/removes the active row, also with **⌥**;
|
||||
**⇧⌘↓/↑** works too. Windows/Linux use **Ctrl** for **⌘**. The active row follows the
|
||||
field you click. A new row starts with its count focused and selected; Enter creates and Escape cancels.
|
||||
|
||||
### Logs
|
||||
|
||||
The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the
|
||||
@@ -180,7 +193,7 @@ error only.
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for SoundCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,soundcraft_mix=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`soundcraft*=debug`). |
|
||||
| `SOUNDCRAFT_NO_PREFS` | No preferences read or written and no log file (agents' test runs). |
|
||||
| `SOUNDCRAFT_NO_PREFS` | `1`: no preferences read or written and no log file (agents' test runs). |
|
||||
|
||||
The realtime audio thread never writes a record itself: the logger keeps the first one it logs
|
||||
(without waiting or allocating), counts the rest, and the UI thread writes them. The logger is
|
||||
@@ -212,8 +225,10 @@ claude mcp add soundcraft -- soundcraft-cli mcp --demo
|
||||
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).
|
||||
`soundcraft-cli commands` lists every command.
|
||||
See [`docs/cli.md`](docs/cli.md) for the command-line tool (subcommands, exit codes, environment
|
||||
variables, man pages and completions), [`docs/control-protocol.md`](docs/control-protocol.md) and
|
||||
[`docs/mcp.md`](docs/mcp.md). `soundcraft-cli commands` lists every command (`--json` for each
|
||||
one's parameters and details, as JSON).
|
||||
|
||||
Open **SoundCraft › Session Audio Health** to check loaded audio availability, sample-rate
|
||||
mismatches and clip source bounds across all playlists, including alternate takes. The report
|
||||
@@ -229,7 +244,6 @@ about egui, so the interface could be swapped for another one.
|
||||
| Crate | What it does |
|
||||
|---|---|
|
||||
| `soundcraft-time` | Timebases, tempo and meter maps, timecode, grid |
|
||||
| `soundcraft-audio-io` | Audio file formats and waveform overviews |
|
||||
| `soundcraft-midi` | Standard MIDI Files and MIDI operations |
|
||||
| `soundcraft-dsp` | Plugins, offline processing, meters, loudness, FFT |
|
||||
| `soundcraft-model` | The session document |
|
||||
@@ -335,7 +349,15 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | **Recording, editing and mixing audio · you are here** | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
@@ -383,4 +405,4 @@ Forks and modified versions must remove them.
|
||||
<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 to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
|
||||
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.
|
||||
|
||||
+34
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
|
||||
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Cancel Zoom Toggle, side panels in dock columns, 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 page is
|
||||
@@ -122,12 +122,28 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-25:** Alt+Shift+E (`view.zoom_toggle_cancel`) and an Alt-click on the Zoom Toggle button cancel Zoom Toggle and keep the current view: the zoom, the scroll and the track heights stay as they are, and the button is no longer lit. The next Zoom Toggle zooms in from that view and goes back to it. No readiness percentage change.
|
||||
- **2026-10-25:** Tracks and Groups and the Clip List sit in dock columns that run the Edit window's full height at its left and right edges, with the MIDI Editor under the session view between them; Mix docks Tracks and Groups at its left edge. A panel dragged onto a dock group joins it as a tab, stacks above or below it, or makes a new column; one dragged to an empty window edge makes a dock there. A list with no saved place reopens in its dock, and makes the dock again when it is gone. Preferences and window configurations saved with the earlier split-frame layouts load with the new default layouts and keep their other settings. No readiness percentage change.
|
||||
- **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)
|
||||
@@ -141,8 +157,8 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
- **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/
|
||||
- **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 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
|
||||
@@ -161,9 +177,23 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | trivial | Progress log: Cancel Zoom Toggle; readiness estimates unchanged |
|
||||
| 2026-10-25 | trivial | Progress log: side panels in dock columns; readiness estimates unchanged |
|
||||
| 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) |
|
||||
@@ -176,6 +206,6 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
| 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-10 | major | Progress docs restructured: 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 |
|
||||
|
||||
@@ -6,6 +6,10 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "soundcraft_cli"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "soundcraft-cli"
|
||||
path = "src/main.rs"
|
||||
@@ -16,6 +20,15 @@ workspace = true
|
||||
[dependencies]
|
||||
soundcraft-engine.workspace = true
|
||||
soundcraft-automation.workspace = true
|
||||
soundcraft-audio-io.workspace = true
|
||||
craft-audio.workspace = true
|
||||
soundcraft-dsp.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
clap.workspace = true
|
||||
same-file.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
clap_complete.workspace = true
|
||||
clap_mangen.workspace = true
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Writes soundcraft-cli's shell completions ([`clap_complete`]: bash, zsh, fish, elvish,
|
||||
//! PowerShell) and man pages ([`clap_mangen`]: `soundcraft-cli.1` and one per subcommand, such as
|
||||
//! `soundcraft-cli-convert.1`) into a directory. The Linux and FreeBSD packages ship them
|
||||
//! (`packaging/linux/package.sh`, `packaging/freebsd/package.sh`):
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p soundcraft-cli --example man-and-completions -- <out-dir>
|
||||
//! ```
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::ValueEnum;
|
||||
use clap_complete::Shell;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
let (Some(dir), None) = (args.next(), args.next()) else {
|
||||
eprintln!("usage: man-and-completions <out-dir>");
|
||||
return ExitCode::from(2);
|
||||
};
|
||||
let dir = Path::new(&dir);
|
||||
match generate(dir) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}: {e}", dir.display());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Also run by `tests/it/man_and_completions.rs`.
|
||||
pub(crate) fn generate(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let mut cmd = soundcraft_cli::command();
|
||||
for &shell in Shell::value_variants() {
|
||||
clap_complete::generate_to(shell, &mut cmd, "soundcraft-cli", dir)?;
|
||||
}
|
||||
// A fresh command: `clap_complete::generate_to` built `cmd`, adding the `help` subcommand,
|
||||
// which `clap_mangen` would otherwise give pages of its own (`soundcraft-cli-help-convert.1`).
|
||||
clap_mangen::generate_to(soundcraft_cli::command(), dir)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use super::args::AppArgs;
|
||||
use super::error::CliError;
|
||||
use super::output::print_json;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_automation::Remote;
|
||||
use std::io::Write;
|
||||
|
||||
/// 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}))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn app(args: &AppArgs, out: &mut dyn Write) -> Result<(), CliError> {
|
||||
let method = &args.method;
|
||||
let params = args.json.clone().unwrap_or_else(|| json!({}));
|
||||
let mut r = Remote::new(&args.control.port);
|
||||
// A bare command id is shorthand for engine.execute. Control-channel methods stay methods.
|
||||
let (m, p) = app_request(method, params);
|
||||
let v = r.request(&m, p)?;
|
||||
print_json(out, &v, args.compact);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
//! The command line, as clap types. A flag a subcommand doesn't take is a usage error (exit 2), so
|
||||
//! a typo such as `--bounces` can't be silently ignored (#549), and so is a value flag without its
|
||||
//! value: a following flag is never taken as the value.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::ffi::OsString;
|
||||
use std::num::NonZeroU32;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use clap::builder::NonEmptyStringValueParser;
|
||||
use clap::error::ErrorKind;
|
||||
use clap::{ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
|
||||
use craft_audio::{DECODE_EXTENSIONS, ENCODE_EXTENSIONS};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_automation::ControlTarget;
|
||||
use soundcraft_automation::args::{AutomationArgs, ControlClientArgs, blank_env_is_unset, control_target, long_version, name_env_vars};
|
||||
|
||||
/// Help text that names the extensions in `list`, built once from craft-audio's
|
||||
/// [`DECODE_EXTENSIONS`] or [`ENCODE_EXTENSIONS`] so it can't drift from what the code accepts.
|
||||
fn with_formats(cell: &'static OnceLock<String>, before: &str, list: &[&str], after: &str) -> &'static str {
|
||||
cell.get_or_init(|| format!("{before}({}){after}", list.join(", ")))
|
||||
}
|
||||
|
||||
fn info_file_help() -> &'static str {
|
||||
static C: OnceLock<String> = OnceLock::new();
|
||||
with_formats(&C, "An audio file ", DECODE_EXTENSIONS, " or a .scraft session")
|
||||
}
|
||||
|
||||
fn read_help() -> &'static str {
|
||||
static C: OnceLock<String> = OnceLock::new();
|
||||
with_formats(&C, "The audio file to read ", DECODE_EXTENSIONS, "")
|
||||
}
|
||||
|
||||
fn write_help() -> &'static str {
|
||||
static C: OnceLock<String> = OnceLock::new();
|
||||
with_formats(&C, "The audio file to write; its extension picks the format ", ENCODE_EXTENSIONS, "")
|
||||
}
|
||||
|
||||
/// soundcraft-cli: SoundCraft without a window
|
||||
#[derive(Parser)]
|
||||
#[command(name = "soundcraft-cli", version = long_version(), arg_required_else_help = true)]
|
||||
pub(crate) struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum Command {
|
||||
Info(InfoArgs),
|
||||
Convert(ConvertArgs),
|
||||
Run(RunArgs),
|
||||
Script(ScriptArgs),
|
||||
Commands(CommandsArgs),
|
||||
Describe(DescribeArgs),
|
||||
App(AppArgs),
|
||||
Mcp(McpArgs),
|
||||
Parity(ParityArgs),
|
||||
/// List the built-in plugins
|
||||
Plugins,
|
||||
}
|
||||
|
||||
/// Print an audio file's format as JSON, or a .scraft session's summary
|
||||
#[derive(Args)]
|
||||
pub(crate) struct InfoArgs {
|
||||
#[arg(help = info_file_help())]
|
||||
pub(crate) file: PathBuf,
|
||||
/// One line of JSON instead of pretty-printed (a .scraft summary is text and ignores it)
|
||||
#[arg(long)]
|
||||
pub(crate) compact: bool,
|
||||
}
|
||||
|
||||
/// Convert an audio file: resample, change the bit depth, normalize
|
||||
///
|
||||
/// Dithers only when the output loses resolution, unless --dither or --no-dither says otherwise.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct ConvertArgs {
|
||||
#[arg(value_name = "IN", help = read_help())]
|
||||
pub(crate) input: PathBuf,
|
||||
#[arg(value_name = "OUT", help = write_help())]
|
||||
pub(crate) output: PathBuf,
|
||||
/// The output's bit depth
|
||||
#[arg(long, value_enum, value_name = "16|24|32f", default_value = "24")]
|
||||
pub(crate) bit_depth: BitDepthArg,
|
||||
/// Resample to this sample rate
|
||||
#[arg(long, value_name = "HZ")]
|
||||
pub(crate) rate: Option<NonZeroU32>,
|
||||
/// Normalize the peak to -0.1 dBFS
|
||||
#[arg(long)]
|
||||
pub(crate) normalize: bool,
|
||||
/// Always dither
|
||||
#[arg(long, conflicts_with = "no_dither")]
|
||||
pub(crate) dither: bool,
|
||||
/// Never dither
|
||||
#[arg(long)]
|
||||
pub(crate) no_dither: bool,
|
||||
/// Allow an <OUT> that is the <IN> file, replacing the original
|
||||
#[arg(long)]
|
||||
pub(crate) in_place: bool,
|
||||
}
|
||||
|
||||
/// Where `run` and `script` start, and what they do with the result.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct EngineArgs {
|
||||
/// Open this session first
|
||||
#[arg(long = "in", value_name = "S.scraft", conflicts_with = "demo")]
|
||||
pub(crate) input: Option<PathBuf>,
|
||||
/// Start from the demo session
|
||||
#[arg(long, visible_alias = "sample")]
|
||||
pub(crate) demo: bool,
|
||||
/// Bounce the mix to this file at the end; without --start and --end, the whole session
|
||||
#[arg(long, value_name = "OUT")]
|
||||
pub(crate) bounce: Option<PathBuf>,
|
||||
/// Where the bounce starts: seconds (12.5) or a time string
|
||||
#[arg(long, value_name = "T", allow_negative_numbers = true, requires = "bounce", value_parser = time_arg)]
|
||||
pub(crate) start: Option<TimeArg>,
|
||||
/// Where the bounce ends: seconds (12.5) or a time string
|
||||
#[arg(long, value_name = "T", allow_negative_numbers = true, requires = "bounce", value_parser = time_arg)]
|
||||
pub(crate) end: Option<TimeArg>,
|
||||
/// Save the session to this file at the end, replacing it
|
||||
#[arg(long, value_name = "S.scraft")]
|
||||
pub(crate) save: Option<PathBuf>,
|
||||
/// Print the session as JSON at the end
|
||||
#[arg(long)]
|
||||
pub(crate) inspect: bool,
|
||||
/// Print the --inspect JSON on one line instead of pretty-printed
|
||||
#[arg(long, requires = "inspect")]
|
||||
pub(crate) compact: bool,
|
||||
/// Allow a --save that is the --in session, replacing the original
|
||||
#[arg(long, requires = "input")]
|
||||
pub(crate) in_place: bool,
|
||||
}
|
||||
|
||||
/// Run engine commands on a new, demo or saved session
|
||||
///
|
||||
/// Prints each command's JSON result on one line, then bounces, saves and inspects, in that order.
|
||||
/// The --inspect JSON is pretty-printed unless --compact is given.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct RunArgs {
|
||||
/// An engine command, with its JSON parameters after `=` (default {}); repeat for several, run
|
||||
/// in order
|
||||
#[arg(long, value_name = "ID[=JSON]", action = ArgAction::Append, value_parser = cmd_spec)]
|
||||
pub(crate) cmd: Vec<CmdSpec>,
|
||||
#[command(flatten)]
|
||||
pub(crate) engine: EngineArgs,
|
||||
}
|
||||
|
||||
/// Run a file of engine commands, one `ID JSON` per line
|
||||
///
|
||||
/// Blank lines and lines starting with # are skipped. A failing line stops the script unless
|
||||
/// --keep-going is given.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct ScriptArgs {
|
||||
/// The script to run
|
||||
pub(crate) file: PathBuf,
|
||||
/// Continue after command errors and finish requested output, then exit nonzero if any command failed.
|
||||
/// Malformed JSON stops immediately
|
||||
#[arg(long)]
|
||||
pub(crate) keep_going: bool,
|
||||
#[command(flatten)]
|
||||
pub(crate) engine: EngineArgs,
|
||||
}
|
||||
|
||||
/// List the engine commands
|
||||
#[derive(Args)]
|
||||
pub(crate) struct CommandsArgs {
|
||||
/// Only the commands whose id, or label in any case, contains this text
|
||||
#[arg(value_parser = NonEmptyStringValueParser::new())]
|
||||
pub(crate) filter: Option<String>,
|
||||
/// Print a JSON array of each command's details, as describe prints them
|
||||
#[arg(long)]
|
||||
pub(crate) json: bool,
|
||||
/// One line of JSON instead of pretty-printed
|
||||
#[arg(long, requires = "json")]
|
||||
pub(crate) compact: bool,
|
||||
}
|
||||
|
||||
/// Print one engine command's details as JSON
|
||||
#[derive(Args)]
|
||||
pub(crate) struct DescribeArgs {
|
||||
/// The command's id, such as file.bounce_mix
|
||||
#[arg(value_parser = NonEmptyStringValueParser::new())]
|
||||
pub(crate) id: String,
|
||||
/// One line of JSON instead of pretty-printed
|
||||
#[arg(long)]
|
||||
pub(crate) compact: bool,
|
||||
}
|
||||
|
||||
/// Call a running app's control channel
|
||||
///
|
||||
/// A dotted engine command id is shorthand for engine.execute; the control methods
|
||||
/// (session.inspect, ui.screenshot, …) stay methods. Prints the reply as JSON.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct AppArgs {
|
||||
#[command(flatten)]
|
||||
pub(crate) control: ControlClientArgs,
|
||||
/// A control method or engine command id
|
||||
#[arg(value_parser = NonEmptyStringValueParser::new())]
|
||||
pub(crate) method: String,
|
||||
/// Its parameters as JSON [default: {}]
|
||||
#[arg(value_parser = json_value)]
|
||||
pub(crate) json: Option<Value>,
|
||||
/// One line of JSON instead of pretty-printed
|
||||
#[arg(long)]
|
||||
pub(crate) compact: bool,
|
||||
}
|
||||
|
||||
/// Serve MCP on stdin and stdout
|
||||
///
|
||||
/// The automation roots confine every file the agent's commands read (open, import, presets) and
|
||||
/// write (save, bounce, export) to those folders, links followed. Each root 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. The roots confine the in-process server, so they, like --demo, conflict with --connect.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct McpArgs {
|
||||
/// Serve a running app's control channel at this port or host:port instead of an in-process
|
||||
/// engine; conflicts with --demo, which starts only that engine
|
||||
#[arg(long, value_name = "PORT", value_parser = control_target, conflicts_with_all = ["demo", "automation_read_root", "automation_write_root"])]
|
||||
pub(crate) connect: Option<ControlTarget>,
|
||||
/// Start the in-process engine from the demo session
|
||||
#[arg(long)]
|
||||
pub(crate) demo: bool,
|
||||
#[command(flatten)]
|
||||
pub(crate) automation: AutomationArgs,
|
||||
}
|
||||
|
||||
/// Print the parity report (markdown)
|
||||
#[derive(Args)]
|
||||
pub(crate) struct ParityArgs {
|
||||
/// Write the report to this file instead
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub(crate) write: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// The outputs clap can't check: one that is an input file, however it is spelt, or both of
|
||||
/// `--bounce` and `--save` naming one file. The paths are compared before any work starts and
|
||||
/// written later, so a link swapped in meanwhile goes unnoticed: this guards against mistakes,
|
||||
/// it is not a security boundary.
|
||||
fn check_outputs(&self) -> Result<(), OutputError> {
|
||||
match self {
|
||||
Self::Convert(a) if !a.in_place && same_file(&a.input, &a.output) => {
|
||||
Err(OutputError::ReplacesInput { output: "<OUT>", input: "<IN> file", path: a.output.clone() })
|
||||
}
|
||||
Self::Run(a) => a.engine.check_outputs(None),
|
||||
Self::Script(a) => a.engine.check_outputs(Some(&a.file)),
|
||||
Self::Info(_)
|
||||
| Self::Convert(_)
|
||||
| Self::Commands(_)
|
||||
| Self::Describe(_)
|
||||
| Self::App(_)
|
||||
| Self::Mcp(_)
|
||||
| Self::Parity(_)
|
||||
| Self::Plugins => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConvertArgs {
|
||||
/// The file `convert` writes: <OUT>, or the file it links to when it replaces <IN> in place.
|
||||
pub(crate) fn write_path(&self) -> Cow<'_, Path> {
|
||||
in_place_target(self.in_place, Some(&self.input), &self.output)
|
||||
}
|
||||
}
|
||||
|
||||
impl EngineArgs {
|
||||
/// The file --save writes: its path, or the file it links to when it replaces --in in place.
|
||||
pub(crate) fn save_path(&self) -> Option<Cow<'_, Path>> {
|
||||
self.save.as_deref().map(|save| in_place_target(self.in_place, self.input.as_deref(), save))
|
||||
}
|
||||
|
||||
/// `script` is the script file `script` runs, which no flag lets an output replace. --in-place
|
||||
/// lets only --save replace the --in session: a bounce would turn it into audio.
|
||||
fn check_outputs(&self, script: Option<&Path>) -> Result<(), OutputError> {
|
||||
for (flag, path) in [("--bounce", &self.bounce), ("--save", &self.save)] {
|
||||
let Some(path) = path else { continue };
|
||||
if script.is_some_and(|script| same_file(script, path)) {
|
||||
return Err(OutputError::ReplacesScript { output: flag, path: path.clone() });
|
||||
}
|
||||
}
|
||||
let replaces_input = |path: &Path| self.input.as_ref().is_some_and(|input| same_file(input, path));
|
||||
if let Some(bounce) = &self.bounce
|
||||
&& replaces_input(bounce)
|
||||
{
|
||||
return Err(OutputError::BounceReplacesInput(bounce.clone()));
|
||||
}
|
||||
if let Some(save) = &self.save
|
||||
&& !self.in_place
|
||||
&& replaces_input(save)
|
||||
{
|
||||
return Err(OutputError::ReplacesInput { output: "--save", input: "--in session", path: save.clone() });
|
||||
}
|
||||
match (&self.bounce, &self.save) {
|
||||
(Some(bounce), Some(save)) if same_file(bounce, save) => Err(OutputError::SameOutputs(save.clone())),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` name one file, however spelt (`a.wav`, `./a.wav`, a symbolic or hard link).
|
||||
/// When either exists, only two regular files can be one, compared by identity (device and inode);
|
||||
/// a FIFO or device is never opened, so the check can't block. When neither exists yet, they are
|
||||
/// one if they would be created at the same place.
|
||||
fn same_file(a: &Path, b: &Path) -> bool {
|
||||
if a.exists() || b.exists() {
|
||||
return a.is_file() && b.is_file() && same_file::is_same_file(a, b).unwrap_or(false);
|
||||
}
|
||||
matches!((destination(a), destination(b)), (Some(a), Some(b)) if a == b)
|
||||
}
|
||||
|
||||
/// With `in_place`, an `output` that is a symbolic link to `input` resolved to the file it names.
|
||||
/// A save writes a new file and renames it over its path, which would replace the link and leave
|
||||
/// the input as it was: through the resolved path, `convert` and `--save` both replace the input.
|
||||
fn in_place_target<'a>(in_place: bool, input: Option<&Path>, output: &'a Path) -> Cow<'a, Path> {
|
||||
let link = output.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink());
|
||||
if in_place
|
||||
&& link
|
||||
&& input.is_some_and(|input| same_file(input, output))
|
||||
&& let Ok(target) = output.canonicalize()
|
||||
{
|
||||
return Cow::Owned(target);
|
||||
}
|
||||
Cow::Borrowed(output)
|
||||
}
|
||||
|
||||
/// Where a file that doesn't exist yet would be created: its folder resolved, links followed.
|
||||
fn destination(path: &Path) -> Option<PathBuf> {
|
||||
let folder = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
Some(folder.canonicalize().ok()?.join(path.file_name()?))
|
||||
}
|
||||
|
||||
/// An output [`Command::check_outputs`] refuses: `parse` prints it as a usage error (exit 2).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum OutputError {
|
||||
#[error("{output} {} is the {input}, which it would replace: choose another file, or pass --in-place", .path.display())]
|
||||
ReplacesInput { output: &'static str, input: &'static str, path: PathBuf },
|
||||
#[error("--bounce {} is the --in session, which it would replace with audio: choose another file", .0.display())]
|
||||
BounceReplacesInput(PathBuf),
|
||||
#[error("{output} {} is the script file, which it would replace: choose another file", .path.display())]
|
||||
ReplacesScript { output: &'static str, path: PathBuf },
|
||||
#[error("--bounce and --save both name {}, so the save would replace the bounce: choose another file for one of them", .0.display())]
|
||||
SameOutputs(PathBuf),
|
||||
}
|
||||
|
||||
impl OutputError {
|
||||
/// Two arguments that may not name one file: clap's conflict kind.
|
||||
fn into_clap(self, cmd: &mut clap::Command) -> clap::Error {
|
||||
cmd.error(ErrorKind::ArgumentConflict, self)
|
||||
}
|
||||
}
|
||||
|
||||
/// The bit depths `convert` writes. `32` is the same as `32f`.
|
||||
#[derive(Clone, Copy, ValueEnum)]
|
||||
pub(crate) enum BitDepthArg {
|
||||
#[value(name = "16")]
|
||||
Int16,
|
||||
#[value(name = "24")]
|
||||
Int24,
|
||||
#[value(name = "32f", alias = "32")]
|
||||
Float32,
|
||||
}
|
||||
|
||||
impl From<BitDepthArg> for craft_audio::BitDepth {
|
||||
fn from(depth: BitDepthArg) -> Self {
|
||||
match depth {
|
||||
BitDepthArg::Int16 => Self::Int16,
|
||||
BitDepthArg::Int24 => Self::Int24,
|
||||
BitDepthArg::Float32 => Self::Float32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounce position: seconds, or a time string the engine reads.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum TimeArg {
|
||||
Seconds(f64),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
impl TimeArg {
|
||||
/// The `start` / `end` parameter of `file.bounce_mix`.
|
||||
pub(crate) fn to_json(&self) -> Value {
|
||||
match self {
|
||||
Self::Seconds(s) => json!({"seconds": s}),
|
||||
Self::Text(t) => json!(t),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One `--cmd ID[=JSON]`: the command id and its parameters (`{}` when none are given).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct CmdSpec {
|
||||
pub(crate) id: String,
|
||||
pub(crate) params: Value,
|
||||
}
|
||||
|
||||
/// Why a value was refused: clap prints it after the flag and its value (exit 2).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum ValueError {
|
||||
#[error("expected seconds (12.5) or a time string, not an empty value")]
|
||||
EmptyTime,
|
||||
#[error("expected a finite number of seconds")]
|
||||
NotFinite,
|
||||
#[error("expected a command id, optionally followed by `=` and JSON parameters")]
|
||||
EmptyCommandId,
|
||||
#[error("bad JSON: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
fn time_arg(s: &str) -> Result<TimeArg, ValueError> {
|
||||
if s.is_empty() {
|
||||
return Err(ValueError::EmptyTime);
|
||||
}
|
||||
match s.parse::<f64>() {
|
||||
// JSON has no NaN or infinity: serde_json would send the engine `null`.
|
||||
Ok(secs) if !secs.is_finite() => Err(ValueError::NotFinite),
|
||||
Ok(secs) => Ok(TimeArg::Seconds(secs)),
|
||||
Err(_) => Ok(TimeArg::Text(s.to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_value(s: &str) -> Result<Value, ValueError> {
|
||||
Ok(serde_json::from_str(s)?)
|
||||
}
|
||||
|
||||
/// `ID` or `ID=JSON`; malformed JSON is an error, not `{}`.
|
||||
fn cmd_spec(s: &str) -> Result<CmdSpec, ValueError> {
|
||||
let (id, params) = match s.split_once('=') {
|
||||
Some((id, p)) => (id, json_value(p)?),
|
||||
None => (s, json!({})),
|
||||
};
|
||||
if id.is_empty() {
|
||||
return Err(ValueError::EmptyCommandId);
|
||||
}
|
||||
Ok(CmdSpec { id: id.to_owned(), params })
|
||||
}
|
||||
|
||||
/// Parse the arguments (without the binary name). clap reads them as `OsString`s: a folder need
|
||||
/// not be valid Unicode, text must. A flag wins over its environment variable, unless that is
|
||||
/// empty, and a bad value from one is a usage error naming the variable. A bare `--help` or `-h`
|
||||
/// is help even after a mistake on the same line (`run --bounce --help`): clap reports the mistake
|
||||
/// first. An output that would replace an input is refused here too
|
||||
/// ([`Command::check_outputs`]), so nothing has run when it is reported.
|
||||
pub(crate) fn parse(args: &[OsString]) -> Result<Cli, clap::Error> {
|
||||
let mut cmd = Cli::command().mut_subcommands(|sub| sub.mut_args(blank_env_is_unset));
|
||||
let argv = std::iter::once(OsString::from("soundcraft-cli")).chain(args.iter().cloned());
|
||||
let matches = match cmd.try_get_matches_from_mut(argv) {
|
||||
Err(e) if e.kind() != ErrorKind::DisplayHelp => {
|
||||
let Some(flag) = args.iter().take_while(|a| *a != "--").find(|a| *a == "--help" || *a == "-h") else {
|
||||
return Err(name_env_vars(e, &cmd, args));
|
||||
};
|
||||
let help = [OsString::from("soundcraft-cli"), args.first().cloned().unwrap_or_default(), flag.clone()];
|
||||
return Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e));
|
||||
}
|
||||
matches => matches?,
|
||||
};
|
||||
let cli = Cli::from_arg_matches(&matches)?;
|
||||
if let Err(e) = cli.command.check_outputs() {
|
||||
return Err(match matches.subcommand_name().and_then(|name| cmd.find_subcommand_mut(name)) {
|
||||
Some(sub) => e.into_clap(sub),
|
||||
None => e.into_clap(&mut cmd),
|
||||
});
|
||||
}
|
||||
Ok(cli)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
|
||||
//! flag must not silently remove a root and leave the server unconfined.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AutomationArgs {
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
|
||||
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
|
||||
if value.starts_with('-') {
|
||||
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
|
||||
}
|
||||
value
|
||||
}
|
||||
};
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_are_taken_once_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
let mut args = ["a folder".to_owned(), "song.scraft".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--demo", &mut args).unwrap());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(args.next().as_deref(), Some("song.scraft"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_operands_leave_roots_unchanged() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use super::args::{CommandsArgs, DescribeArgs, ParityArgs};
|
||||
use super::error::CliError;
|
||||
use super::output::print_json;
|
||||
use soundcraft_engine::Engine;
|
||||
use std::io::Write;
|
||||
|
||||
pub(crate) fn commands(args: &CommandsArgs, out: &mut dyn Write) {
|
||||
let filter = args.filter.clone().unwrap_or_default();
|
||||
let e = Engine::default();
|
||||
let infos = soundcraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter(|c| filter.is_empty() || c.id.contains(&filter) || c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()))
|
||||
.map(|c| c.info(&e));
|
||||
if args.json {
|
||||
print_json(out, &infos.collect::<Vec<_>>(), args.compact);
|
||||
return;
|
||||
}
|
||||
for i in infos {
|
||||
outln!(out, "{:<36} {:<34} {}", i.id, i.label, i.menu.join(" > "));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn describe(args: &DescribeArgs, out: &mut dyn Write) -> Result<(), CliError> {
|
||||
let id = &args.id;
|
||||
let c = soundcraft_engine::find_command(id).ok_or_else(|| CliError::UnknownCommand(id.clone()))?;
|
||||
print_json(out, &c.info(&Engine::default()), args.compact);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn parity(args: &ParityArgs, out: &mut dyn Write) -> Result<(), CliError> {
|
||||
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-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!(
|
||||
"**Menu items implemented by the engine: {} / {} ({}%)**\n\n| Menu | Implemented | Total |\n|---|---:|---:|\n",
|
||||
p["implemented"], p["total"], p["percent"]
|
||||
));
|
||||
for m in p["per_menu"].as_array().cloned().unwrap_or_default() {
|
||||
md.push_str(&format!("| {} | {} | {} |\n", m["menu"].as_str().unwrap_or(""), m["implemented"], m["total"]));
|
||||
}
|
||||
md.push_str("\n## Not yet implemented\n\n");
|
||||
for m in p["missing"].as_array().cloned().unwrap_or_default() {
|
||||
md.push_str(&format!("- {}\n", m.as_str().unwrap_or("")));
|
||||
}
|
||||
if let Some(path) = &args.write {
|
||||
std::fs::write(path, &md).map_err(|source| CliError::File { path: path.clone(), source })?;
|
||||
outln!(out, "wrote {}: {}% ({}/{})", path.display(), p["percent"], p["implemented"], p["total"]);
|
||||
} else {
|
||||
outln!(out, "{md}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use super::args::ConvertArgs;
|
||||
use super::error::{CliError, ConvertError};
|
||||
use std::io::Write;
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), CliError> {
|
||||
let (input, output) = (&args.input, &args.output);
|
||||
let rate = args.rate.map(NonZeroU32::get);
|
||||
let bit_depth = args.bit_depth.into();
|
||||
let dither_flag = match (args.dither, args.no_dither) {
|
||||
(true, _) => Some(true),
|
||||
(_, true) => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
let bytes = std::fs::read(input).map_err(|source| CliError::File { path: input.clone(), source })?;
|
||||
let ext = input.extension().and_then(std::ffi::OsStr::to_str);
|
||||
let (info, mut buf) = craft_audio::decode(&bytes, ext).map_err(|source| CliError::Audio { path: input.clone(), source })?;
|
||||
// The decoder replaces packets it cannot decode with silence of their length and counts them.
|
||||
// Such a file is not a copy of what was recorded: never let it replace its source, and say so
|
||||
// when it is written elsewhere.
|
||||
if info.damaged > 0 {
|
||||
if args.in_place {
|
||||
return Err(ConvertError::Damaged { input: input.clone(), packets: info.damaged }.into());
|
||||
}
|
||||
log::warn!("{}: {} damaged packet(s) decoded as silence", input.display(), info.damaged);
|
||||
}
|
||||
let source_rate = buf.sample_rate;
|
||||
if let Some(rate) = rate {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
|
||||
buf.sample_rate = rate;
|
||||
}
|
||||
let normalize = args.normalize;
|
||||
if normalize {
|
||||
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
|
||||
}
|
||||
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));
|
||||
// Lossy only for the message: an extension that is not valid Unicode names no format.
|
||||
let out_ext = output.extension().map_or_else(|| "wav".into(), |e| e.to_string_lossy().to_ascii_lowercase());
|
||||
let Some(format) = craft_audio::encode_format_for(&out_ext) else {
|
||||
return Err(ConvertError::OutputFormat { output: output.clone(), ext: out_ext }.into());
|
||||
};
|
||||
// A BWF source keeps its `bext` (written for WAV output only). The time reference counts
|
||||
// samples, so it moves to the output rate.
|
||||
let mut opts = craft_audio::EncodeOptions::new(format).with_bit_depth(bit_depth).with_dither(dither);
|
||||
// The source's speakers, not the usual ones for its channel count (5.1 with side surrounds
|
||||
// stays that). Channels with no speaker assignment get the format's default.
|
||||
if info.layout.is_ordered() {
|
||||
opts = opts.with_layout(info.layout);
|
||||
}
|
||||
opts.bwf = info.bwf.map(|mut b| {
|
||||
b.time_reference = at_rate(b.time_reference, source_rate, buf.sample_rate);
|
||||
b
|
||||
});
|
||||
let bytes = craft_audio::encode(&buf, &opts).map_err(|source| CliError::Audio { path: output.clone(), source })?;
|
||||
write_replacing(&args.write_path(), &bytes).map_err(|source| CliError::File { path: output.clone(), source })?;
|
||||
outln!(out, "wrote {} ({} ch, {} Hz, {:.2} s)", output.display(), buf.num_channels(), buf.sample_rate, buf.duration_secs());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The sample position `pos` at `from` Hz as the nearest sample at `to` Hz.
|
||||
fn at_rate(pos: u64, from: u32, to: u32) -> u64 {
|
||||
if from == 0 || from == to {
|
||||
return pos;
|
||||
}
|
||||
let scaled = (u128::from(pos) * u128::from(to) + u128::from(from) / 2) / u128::from(from);
|
||||
u64::try_from(scaled).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// Writes `bytes` to a new file next to `target`, flushes it and renames it over `target`, so `target`
|
||||
/// (the input, for --in-place) holds either its old or its new contents. The new file is removed when
|
||||
/// writing or renaming fails.
|
||||
fn write_replacing(target: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let name = target.file_name().map_or_else(|| "output".into(), |n| n.to_string_lossy().into_owned());
|
||||
let tmp = target.with_file_name(format!(".{name}.{}.part", std::process::id()));
|
||||
let written = (|| {
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp)?;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&tmp, target)
|
||||
})();
|
||||
if written.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
/// 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: craft_audio::SampleFormat, target: craft_audio::BitDepth, processed: bool) -> bool {
|
||||
use craft_audio::{BitDepth, SampleFormat};
|
||||
let target_bits = match target {
|
||||
BitDepth::Float32 => return false,
|
||||
BitDepth::Int16 => 16,
|
||||
BitDepth::Int24 => 24,
|
||||
BitDepth::Int32 => 32,
|
||||
// A depth added to craft-audio later: dither, as for any step that may lose resolution.
|
||||
_ => return true,
|
||||
};
|
||||
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 (as is any format added to craft-audio later).
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn convert_dithers_only_when_resolution_is_lost() {
|
||||
use craft_audio::{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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use craft_audio::AudioError;
|
||||
use soundcraft_automation::RemoteError;
|
||||
use soundcraft_engine::EngineError;
|
||||
use soundcraft_engine::file_access::RootError;
|
||||
|
||||
/// Why a subcommand failed: printed as `error: {e}`, exit 1.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum CliError {
|
||||
#[error(transparent)]
|
||||
Convert(#[from] ConvertError),
|
||||
#[error(transparent)]
|
||||
Engine(#[from] EngineError),
|
||||
#[error("unknown command `{0}`")]
|
||||
UnknownCommand(String),
|
||||
#[error("{id}: {source}")]
|
||||
Command { id: String, source: EngineError },
|
||||
#[error("{}:{line}: bad JSON: {source}", path.display())]
|
||||
ScriptJson { path: PathBuf, line: usize, source: serde_json::Error },
|
||||
#[error("{}:{line}: {id}: {source}", path.display())]
|
||||
ScriptLine { path: PathBuf, line: usize, id: String, source: EngineError },
|
||||
#[error("script finished with command errors")]
|
||||
ScriptFailed,
|
||||
#[error("{}: {source}", path.display())]
|
||||
File { path: PathBuf, source: std::io::Error },
|
||||
#[error("{}: {source}", path.display())]
|
||||
Audio { path: PathBuf, source: AudioError },
|
||||
/// A `--bounce` or `--save` path: the engine's commands take them in JSON, which holds only
|
||||
/// text.
|
||||
#[error("{}: the path is not valid Unicode", .0.display())]
|
||||
NotUnicode(PathBuf),
|
||||
#[error(transparent)]
|
||||
Remote(#[from] RemoteError),
|
||||
#[error(transparent)]
|
||||
Roots(#[from] RootError),
|
||||
#[error(transparent)]
|
||||
Serve(std::io::Error),
|
||||
}
|
||||
|
||||
/// A path as the text the engine's commands take.
|
||||
pub(crate) fn unicode(path: &Path) -> Result<&str, CliError> {
|
||||
path.to_str().ok_or_else(|| CliError::NotUnicode(path.to_owned()))
|
||||
}
|
||||
|
||||
/// A `convert` option or output the tool refuses.
|
||||
#[derive(Debug, PartialEq, thiserror::Error)]
|
||||
pub(crate) enum ConvertError {
|
||||
#[error("{}: cannot write `.{ext}` files (supported: {})", output.display(), craft_audio::ENCODE_EXTENSIONS.join(", "))]
|
||||
OutputFormat { output: PathBuf, ext: String },
|
||||
#[error("{}: {packets} damaged packet(s) would be written as silence; not replacing the file (convert to another file to keep what decodes)", input.display())]
|
||||
Damaged { input: PathBuf, packets: u64 },
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use super::args::InfoArgs;
|
||||
use super::error::CliError;
|
||||
use super::output::print_json;
|
||||
use super::session::warn_missing;
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::Engine;
|
||||
use std::io::Write;
|
||||
|
||||
pub(crate) fn info(args: &InfoArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result<(), CliError> {
|
||||
let path = &args.file;
|
||||
if path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("scraft")) {
|
||||
let mut e = Engine::default();
|
||||
let missing = soundcraft_engine::io::open_session(&mut e, path)?;
|
||||
warn_missing(err, &missing);
|
||||
outln!(out, "{}", soundcraft_engine::inspect::session_text(&e));
|
||||
return Ok(());
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|source| CliError::File { path: path.clone(), source })?;
|
||||
let ext = path.extension().and_then(std::ffi::OsStr::to_str);
|
||||
let i = craft_audio::probe(&bytes, ext).map_err(|source| CliError::Audio { path: path.clone(), source })?;
|
||||
print_json(
|
||||
out,
|
||||
&json!({"format": soundcraft_engine::model::FileFormat::of(&i), "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf}),
|
||||
args.compact,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! `soundcraft-cli`: SoundCraft without a window. `run` is the whole program, so it can be tested
|
||||
//! in-process as well as through the binary. `soundcraft-cli --help` lists the subcommands.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[macro_use]
|
||||
mod output;
|
||||
|
||||
mod app;
|
||||
mod args;
|
||||
mod catalog;
|
||||
mod convert;
|
||||
mod error;
|
||||
mod info;
|
||||
pub mod logger;
|
||||
mod mcp;
|
||||
mod session;
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io::Write;
|
||||
|
||||
use clap::CommandFactory;
|
||||
|
||||
use app::app;
|
||||
use args::{Cli, Command, parse};
|
||||
use catalog::{commands, describe, parity};
|
||||
use convert::convert;
|
||||
use error::CliError;
|
||||
use info::info;
|
||||
use mcp::mcp;
|
||||
use session::{run_cmds, script};
|
||||
|
||||
/// The command line's clap definition.
|
||||
pub fn command() -> clap::Command {
|
||||
Cli::command()
|
||||
}
|
||||
|
||||
/// Run the CLI on its arguments (without the program name), writing results to `out` and
|
||||
/// diagnostics to `err`. Returns the process exit code: 0 on success, 1 on failure, 2 for a usage
|
||||
/// error. Help and the version go to `out`, exit 0. Plugin hosts are shut down by the caller once
|
||||
/// the process is done with every engine (see `main`).
|
||||
pub fn run<I, T>(args: I, out: &mut dyn Write, err: &mut dyn Write) -> i32
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString>,
|
||||
{
|
||||
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
|
||||
// Help and the version (`help`, `-h`, `<subcommand> --help`, `-V`) come back as errors too.
|
||||
let cli = match parse(&args) {
|
||||
Ok(cli) => cli,
|
||||
Err(e) => {
|
||||
let to: &mut dyn Write = if e.use_stderr() { err } else { out };
|
||||
let _ = write!(to, "{}", e.render());
|
||||
return e.exit_code();
|
||||
}
|
||||
};
|
||||
match dispatch(cli.command, out, err) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
outln!(err, "error: {e}");
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(command: Command, out: &mut dyn Write, err: &mut dyn Write) -> Result<(), CliError> {
|
||||
match command {
|
||||
Command::Info(a) => info(&a, out, err)?,
|
||||
Command::Convert(a) => convert(&a, out)?,
|
||||
Command::Run(a) => run_cmds(&a, out, err)?,
|
||||
Command::Script(a) => script(&a, out, err)?,
|
||||
Command::Commands(a) => commands(&a, out),
|
||||
Command::Describe(a) => describe(&a, out)?,
|
||||
Command::App(a) => app(&a, out)?,
|
||||
Command::Mcp(a) => mcp(&a, err)?,
|
||||
Command::Parity(a) => parity(&a, out)?,
|
||||
Command::Plugins => {
|
||||
for p in soundcraft_dsp::plugins() {
|
||||
outln!(out, "{:<20} {:<22} {:?}{}", p.id, p.name, p.category, if p.is_instrument { " (instrument)" } else { "" });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! The command line's logger. Without one, every `log::warn!` and `log::error!` in the workspace
|
||||
//! was dropped: a plugin that failed to load just vanished from `engine.clap_plugins`, and nothing
|
||||
//! said why. Warnings and errors now go to standard error as `warning: <message>` and
|
||||
//! `error: <message>`, like the CLI's own errors; standard output keeps only the results.
|
||||
//! `RUST_LOG` stays a desktop app variable (docs/cli.md), so the level is fixed.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
struct StderrLogger;
|
||||
|
||||
impl log::Log for StderrLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
metadata.level() <= log::Level::Warn
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if self.enabled(record.metadata()) {
|
||||
// A closed standard error is not worth failing a command over.
|
||||
let _ = std::io::stderr().write_all(line(record.level(), &record.args().to_string()).as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// One record as the CLI prints it.
|
||||
fn line(level: log::Level, message: &str) -> String {
|
||||
let label = if level == log::Level::Error { "error" } else { "warning" };
|
||||
format!("{label}: {}\n", message.strip_suffix('\n').unwrap_or(message))
|
||||
}
|
||||
|
||||
/// Installs the logger. Once per process: `main` calls it, not `run`, which tests call many times.
|
||||
pub fn install() {
|
||||
static LOGGER: StderrLogger = StderrLogger;
|
||||
if log::set_logger(&LOGGER).is_ok() {
|
||||
log::set_max_level(log::LevelFilter::Warn);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn records_print_like_the_cli_errors() {
|
||||
assert_eq!(line(log::Level::Warn, "cannot load CLAP plugin x.clap: bad file\n"), "warning: cannot load CLAP plugin x.clap: bad file\n");
|
||||
assert_eq!(line(log::Level::Error, "panic: boom"), "error: panic: boom\n");
|
||||
}
|
||||
}
|
||||
@@ -1,542 +1,13 @@
|
||||
//! `soundcraft-cli`: SoundCraft without a window.
|
||||
//!
|
||||
//! ```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 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 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] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
//! MCP server on stdio. The two roots confine every file the
|
||||
//! agent's commands read (open, import, presets) and write
|
||||
//! (save, bounce, export) to those folders, links followed;
|
||||
//! a root left out grants none of its access. They run the
|
||||
//! in-process server, so they conflict with --connect.
|
||||
//! soundcraft-cli parity [--write PATH] parity report (markdown)
|
||||
//! soundcraft-cli plugins list built-in plugins
|
||||
//! ```
|
||||
//! `soundcraft-cli`: SoundCraft without a window. See the library crate for the commands.
|
||||
|
||||
#![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. 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();
|
||||
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()
|
||||
}
|
||||
|
||||
fn fail(msg: impl std::fmt::Display) -> ExitCode {
|
||||
eprintln!("soundcraft-cli: {msg}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
|
||||
/// The usage block of the module doc comment, up to its closing code fence.
|
||||
fn help_text() -> String {
|
||||
include_str!("main.rs")
|
||||
.lines()
|
||||
.skip(3)
|
||||
.take_while(|l| l.trim_end() != "//! ```")
|
||||
.map(|l| l.trim_start_matches("//! "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let Some(cmd) = args.first().cloned() else {
|
||||
eprintln!("usage: soundcraft-cli <info|convert|run|script|commands|describe|app|mcp|parity|plugins> … (see --help)");
|
||||
return ExitCode::FAILURE;
|
||||
};
|
||||
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
|
||||
let code = match cmd.as_str() {
|
||||
"--version" | "-V" | "version" => {
|
||||
outln!("SoundCraft CLI {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
"--help" | "-h" | "help" => {
|
||||
outln!("{}", help_text());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
"info" => info(&rest),
|
||||
"convert" => convert(&rest),
|
||||
"run" => run(&rest),
|
||||
"script" => script(&rest),
|
||||
"commands" => commands(&rest),
|
||||
"describe" => describe(&rest),
|
||||
"app" => app(&rest),
|
||||
"mcp" => mcp(&rest),
|
||||
"parity" => parity(&rest),
|
||||
"plugins" => {
|
||||
for p in soundcraft_dsp::plugins() {
|
||||
outln!("{:<20} {:<22} {:?}{}", p.id, p.name, p.category, if p.is_instrument { " (instrument)" } else { "" });
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
other => fail(format!("unknown subcommand `{other}`")),
|
||||
};
|
||||
// The subcommand's engine and its plugin instances are gone by now.
|
||||
fn main() {
|
||||
soundcraft_cli::logger::install();
|
||||
// `args_os`, not `args`: a non-Unicode argument must reach clap, not panic before anything is printed.
|
||||
let code = soundcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
|
||||
// The subcommand's engine and its plugin instances are gone by now. This is process teardown,
|
||||
// so it lives here and not in `run`, which tests call many times in one process.
|
||||
soundcraft_engine::shutdown_plugin_hosts();
|
||||
code
|
||||
}
|
||||
|
||||
fn info(args: &[String]) -> ExitCode {
|
||||
let Some(path) = args.first() else { return fail("info FILE") };
|
||||
if path.ends_with(".scraft") {
|
||||
let mut e = Engine::default();
|
||||
match soundcraft_engine::io::open_session(&mut e, path) {
|
||||
Ok(missing) => warn_missing(&missing),
|
||||
Err(err) => return fail(err),
|
||||
}
|
||||
outln!("{}", soundcraft_engine::inspect::session_text(&e));
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return fail(format!("{path}: {e}")),
|
||||
};
|
||||
let ext = std::path::Path::new(path).extension().and_then(|x| x.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());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => fail(format!("{path}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(args: &[String]) -> ExitCode {
|
||||
let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return fail("convert IN OUT") };
|
||||
let rate = match parse_rate(arg_value(args, "--rate").as_deref()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return fail(e),
|
||||
};
|
||||
let bit_depth = match parse_bit_depth(arg_value(args, "--bit-depth").as_deref()) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return fail(e),
|
||||
};
|
||||
let bytes = match std::fs::read(input) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return fail(format!("{input}: {e}")),
|
||||
};
|
||||
let ext = std::path::Path::new(input).extension().and_then(|x| x.to_str());
|
||||
let (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
|
||||
Ok(x) => x,
|
||||
Err(e) => return fail(format!("{input}: {e}")),
|
||||
};
|
||||
if let Some(rate) = rate {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
|
||||
buf.sample_rate = rate;
|
||||
}
|
||||
if args.iter().any(|a| a == "--normalize") {
|
||||
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
|
||||
}
|
||||
let out_ext = std::path::Path::new(output).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase();
|
||||
let 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 opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None };
|
||||
match soundcraft_audio_io::encode(&buf, &opts).map_err(|e| e.to_string()).and_then(|b| std::fs::write(output, b).map_err(|e| e.to_string())) {
|
||||
Ok(()) => {
|
||||
outln!("wrote {output} ({} ch, {} Hz, {:.2} s)", buf.num_channels(), buf.sample_rate, buf.duration_secs());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => fail(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_rate(v: Option<&str>) -> Result<Option<u32>, String> {
|
||||
match v {
|
||||
None => Ok(None),
|
||||
Some(r) => match r.parse::<u32>() {
|
||||
Ok(hz) if hz > 0 => Ok(Some(hz)),
|
||||
_ => Err(format!("--rate: expected a sample rate in Hz, got `{r}`")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bit_depth(v: Option<&str>) -> Result<soundcraft_audio_io::BitDepth, String> {
|
||||
match v {
|
||||
None | Some("24") => Ok(soundcraft_audio_io::BitDepth::Int24),
|
||||
Some("16") => Ok(soundcraft_audio_io::BitDepth::Int16),
|
||||
Some("32") | Some("32f") => Ok(soundcraft_audio_io::BitDepth::Float32),
|
||||
Some(b) => Err(format!("--bit-depth: expected 16, 24 or 32f, got `{b}`")),
|
||||
}
|
||||
}
|
||||
|
||||
/// One stderr line per media file a session refers to but could not load (the exit code is unchanged).
|
||||
fn missing_warnings(missing: &[String]) -> Vec<String> {
|
||||
missing.iter().map(|m| format!("warning: missing media: {m}")).collect()
|
||||
}
|
||||
|
||||
fn warn_missing(missing: &[String]) {
|
||||
for line in missing_warnings(missing) {
|
||||
eprintln!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
fn load_engine(args: &[String]) -> Result<Engine, String> {
|
||||
if let Some(p) = arg_value(args, "--in") {
|
||||
let mut e = Engine::default();
|
||||
let missing = soundcraft_engine::io::open_session(&mut e, &p).map_err(|e| e.to_string())?;
|
||||
warn_missing(&missing);
|
||||
Ok(e)
|
||||
} else if args.iter().any(|a| a == "--demo" || a == "--sample") {
|
||||
Ok(soundcraft_engine::demo::demo_engine())
|
||||
} else {
|
||||
Ok(Engine::default())
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(e: &mut Engine, args: &[String]) -> ExitCode {
|
||||
if let Some(out) = arg_value(args, "--bounce") {
|
||||
let mut p = json!({"path": out});
|
||||
if let Some(s) = arg_value(args, "--start") {
|
||||
p["start"] = time_arg(&s);
|
||||
}
|
||||
if let Some(s) = arg_value(args, "--end") {
|
||||
p["end"] = time_arg(&s);
|
||||
}
|
||||
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, "replace": true})) {
|
||||
Ok(v) => outln!("{v}"),
|
||||
Err(err) => return fail(err),
|
||||
}
|
||||
}
|
||||
if args.iter().any(|a| a == "--inspect") {
|
||||
outln!("{}", serde_json::to_string_pretty(&soundcraft_engine::inspect::session(e, false)).unwrap_or_default());
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// `12.5` → seconds; anything else is passed through as a time string.
|
||||
fn time_arg(s: &str) -> Value {
|
||||
s.parse::<f64>().map_or_else(|_| json!(s), |f| json!({"seconds": f}))
|
||||
}
|
||||
|
||||
/// `ID` or `ID=JSON` → command id and params; malformed JSON is an error, not `{}`.
|
||||
fn parse_cmd_spec(spec: &str) -> Result<(String, Value), String> {
|
||||
match spec.split_once('=') {
|
||||
Some((id, p)) => serde_json::from_str::<Value>(p).map(|v| (id.to_string(), v)).map_err(|err| format!("{id}: bad JSON: {err}")),
|
||||
None => Ok((spec.to_string(), json!({}))),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> ExitCode {
|
||||
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;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
finish(&mut e, args)
|
||||
}
|
||||
|
||||
fn script(args: &[String]) -> ExitCode {
|
||||
let Some(path) = args.first() else { return fail("script FILE") };
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return fail(format!("{path}: {e}")),
|
||||
};
|
||||
let mut e = match load_engine(args) {
|
||||
Ok(e) => e,
|
||||
Err(err) => return fail(err),
|
||||
};
|
||||
let keep = args.iter().any(|a| a == "--keep-going");
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let (id, p) = line.split_once(char::is_whitespace).unwrap_or((line, "{}"));
|
||||
let params = match serde_json::from_str::<Value>(p.trim()) {
|
||||
Ok(v) => v,
|
||||
Err(err) => return fail(format!("{path}:{}: bad JSON: {err}", n + 1)),
|
||||
};
|
||||
match e.execute(id, ¶ms) {
|
||||
Ok(v) => outln!("{id}: {v}"),
|
||||
Err(err) => {
|
||||
eprintln!("{path}:{}: {id}: {err}", n + 1);
|
||||
if !keep {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finish(&mut e, args)
|
||||
}
|
||||
|
||||
fn commands(args: &[String]) -> ExitCode {
|
||||
let filter = args.first().cloned().unwrap_or_default();
|
||||
let e = Engine::default();
|
||||
for c in soundcraft_engine::command_specs() {
|
||||
if !filter.is_empty() && !c.id.contains(&filter) && !c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
let i = c.info(&e);
|
||||
outln!("{:<36} {:<34} {}", i.id, i.label, i.menu.join(" > "));
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn describe(args: &[String]) -> ExitCode {
|
||||
let Some(id) = args.first() else { return fail("describe ID") };
|
||||
match soundcraft_engine::find_command(id) {
|
||||
Some(c) => {
|
||||
outln!("{}", serde_json::to_string_pretty(&c.info(&Engine::default())).unwrap_or_default());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
None => fail(format!("unknown command `{id}`")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"))))
|
||||
.map(|(_, a)| a)
|
||||
.collect();
|
||||
let Some(method) = positional.first() else { return fail("app [--port P] METHOD [JSON]") };
|
||||
let params = match positional.get(1) {
|
||||
Some(p) => match serde_json::from_str::<Value>(p) {
|
||||
Ok(v) => v,
|
||||
Err(err) => return fail(format!("{method}: bad JSON: {err}")),
|
||||
},
|
||||
None => json!({}),
|
||||
};
|
||||
let mut r = Remote::new(&port);
|
||||
// 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());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => fail(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// What `mcp` was asked for.
|
||||
struct McpArgs {
|
||||
connect: Option<String>,
|
||||
demo: bool,
|
||||
roots: Option<std::sync::Arc<AutomationRoots>>,
|
||||
}
|
||||
|
||||
/// Parse the `mcp` options up front: a malformed or misspelled root flag must not leave the
|
||||
/// server running unconfined.
|
||||
fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> {
|
||||
let mut connect = None;
|
||||
let mut demo = false;
|
||||
let mut roots_args = automation_args::AutomationArgs::new(None, None);
|
||||
let mut it = args.iter().cloned();
|
||||
while let Some(a) = it.next() {
|
||||
if roots_args.consume(&a, &mut it)? {
|
||||
continue;
|
||||
}
|
||||
match a.as_str() {
|
||||
"--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?),
|
||||
"--demo" => demo = true,
|
||||
other if other.starts_with("--automation-") => return Err(format!("unknown mcp option `{other}`")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
|
||||
if roots.is_some() && connect.is_some() {
|
||||
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
|
||||
so drop --connect (or use --connect without the roots)"
|
||||
.into());
|
||||
}
|
||||
Ok(McpArgs { connect, demo, roots })
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> ExitCode {
|
||||
let McpArgs { connect, demo, roots } = match parse_mcp_args(args) {
|
||||
Ok(a) => a,
|
||||
Err(err) => return fail(err),
|
||||
};
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
let r = if let Some(port) = connect {
|
||||
let mut s = Server::new(Remote::new(&port));
|
||||
s.serve(stdin.lock(), stdout.lock())
|
||||
} else {
|
||||
// `--demo` is the operator's own choice, made before the roots come into force.
|
||||
let e = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
|
||||
let mut h = Headless::new(e);
|
||||
if let Some(roots) = roots {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
file_access::confine_process(roots.clone());
|
||||
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
eprintln!("soundcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
h = h.with_automation_roots(Some(roots));
|
||||
}
|
||||
let mut s = Server::new(h);
|
||||
s.serve(stdin.lock(), stdout.lock())
|
||||
};
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => fail(e),
|
||||
}
|
||||
}
|
||||
|
||||
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-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!(
|
||||
"**Menu items implemented by the engine: {} / {} ({}%)**\n\n| Menu | Implemented | Total |\n|---|---:|---:|\n",
|
||||
p["implemented"], p["total"], p["percent"]
|
||||
));
|
||||
for m in p["per_menu"].as_array().cloned().unwrap_or_default() {
|
||||
md.push_str(&format!("| {} | {} | {} |\n", m["menu"].as_str().unwrap_or(""), m["implemented"], m["total"]));
|
||||
}
|
||||
md.push_str("\n## Not yet implemented\n\n");
|
||||
for m in p["missing"].as_array().cloned().unwrap_or_default() {
|
||||
md.push_str(&format!("- {}\n", m.as_str().unwrap_or("")));
|
||||
}
|
||||
if let Some(path) = arg_value(args, "--write") {
|
||||
if let Err(e) = std::fs::write(&path, &md) {
|
||||
return fail(format!("{path}: {e}"));
|
||||
}
|
||||
outln!("wrote {path}: {}% ({}/{})", p["percent"], p["implemented"], p["total"]);
|
||||
} else {
|
||||
outln!("{md}");
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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 missing_media_gets_one_warning_each() {
|
||||
assert!(missing_warnings(&[]).is_empty());
|
||||
let w = missing_warnings(&["a.wav".to_string(), "b.wav: decode failed".to_string()]);
|
||||
assert_eq!(w, vec!["warning: missing media: a.wav", "warning: missing media: b.wav: decode failed"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_spec_parses_id_and_json() {
|
||||
assert_eq!(parse_cmd_spec("a.b").unwrap(), ("a.b".to_string(), json!({})));
|
||||
assert_eq!(parse_cmd_spec("a.b={\"x\":1}").unwrap(), ("a.b".to_string(), json!({"x": 1})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_spec_rejects_truncated_json() {
|
||||
let err = parse_cmd_spec("a.b={\"x\":").unwrap_err();
|
||||
assert!(err.starts_with("a.b: bad JSON"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_text_is_only_the_usage_block() {
|
||||
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"));
|
||||
}
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
use super::args::McpArgs;
|
||||
use super::error::CliError;
|
||||
use soundcraft_automation::{ControlTarget, Headless, Remote, mcp::Server};
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_engine::file_access::{self, AutomationRoots};
|
||||
use std::io::Write;
|
||||
|
||||
/// What `mcp` serves.
|
||||
struct Setup {
|
||||
connect: Option<ControlTarget>,
|
||||
demo: bool,
|
||||
roots: Option<std::sync::Arc<AutomationRoots>>,
|
||||
}
|
||||
|
||||
/// Open the roots before the server starts: a folder that can't be opened must not leave the
|
||||
/// server running unconfined.
|
||||
fn setup(args: &McpArgs) -> Result<Setup, CliError> {
|
||||
let read: Vec<&std::path::Path> = args.automation.automation_read_root.iter().map(std::path::PathBuf::as_path).collect();
|
||||
let write: Vec<&std::path::Path> = args.automation.automation_write_root.iter().map(std::path::PathBuf::as_path).collect();
|
||||
let roots = AutomationRoots::new(&read, &write)?;
|
||||
Ok(Setup { connect: args.connect.clone(), demo: args.demo, roots })
|
||||
}
|
||||
|
||||
/// Serves on the process's real stdin and stdout; only the notes go to `err`.
|
||||
pub(crate) fn mcp(args: &McpArgs, err: &mut dyn Write) -> Result<(), CliError> {
|
||||
let Setup { connect, demo, roots } = setup(args)?;
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
let served = if let Some(target) = connect {
|
||||
let mut s = Server::new(Remote::new(&target));
|
||||
s.serve(stdin.lock(), stdout.lock())
|
||||
} else {
|
||||
// `--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());
|
||||
outln!(err, "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())
|
||||
};
|
||||
served.map_err(CliError::Serve)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/// Print to a writer 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 {
|
||||
($w:expr, $($t:tt)*) => {{
|
||||
let _ = writeln!($w, $($t)*);
|
||||
}};
|
||||
}
|
||||
|
||||
/// Print `value` as JSON: pretty-printed, or on one line when `compact`. Every subcommand that
|
||||
/// prints a JSON document goes through here, so `--compact` means the same thing everywhere.
|
||||
pub(crate) fn print_json(out: &mut dyn std::io::Write, value: &impl serde::Serialize, compact: bool) {
|
||||
let text = if compact { serde_json::to_string(value) } else { serde_json::to_string_pretty(value) };
|
||||
outln!(out, "{}", text.unwrap_or_default());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn compact_is_one_line_and_pretty_is_the_same_value_over_several() {
|
||||
let v = json!({"a": [1, 2], "b": {"c": "d"}});
|
||||
let (mut pretty, mut compact) = (Vec::new(), Vec::new());
|
||||
print_json(&mut pretty, &v, false);
|
||||
print_json(&mut compact, &v, true);
|
||||
let (pretty, compact) = (String::from_utf8(pretty).unwrap_or_default(), String::from_utf8(compact).unwrap_or_default());
|
||||
assert!(pretty.trim_end().lines().count() > 1);
|
||||
assert_eq!(compact, "{\"a\":[1,2],\"b\":{\"c\":\"d\"}}\n");
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&pretty).ok(), Some(v));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use super::args::{CmdSpec, EngineArgs, RunArgs, ScriptArgs};
|
||||
use super::error::{CliError, unicode};
|
||||
use super::output::print_json;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_engine::Engine;
|
||||
use std::io::Write;
|
||||
|
||||
/// One stderr line per media file a session refers to but could not load (the exit code is unchanged).
|
||||
pub(crate) fn warn_missing(err: &mut dyn Write, missing: &[String]) {
|
||||
for m in missing {
|
||||
outln!(err, "warning: missing media: {m}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The outputs go to the engine as JSON text, so a non-Unicode one is refused before any command runs.
|
||||
fn load_engine(args: &EngineArgs, err: &mut dyn Write) -> Result<Engine, CliError> {
|
||||
if let Some(bounce) = &args.bounce {
|
||||
unicode(bounce)?;
|
||||
}
|
||||
if let Some(save) = args.save_path() {
|
||||
unicode(&save)?;
|
||||
}
|
||||
if let Some(p) = &args.input {
|
||||
let mut e = Engine::default();
|
||||
let missing = soundcraft_engine::io::open_session(&mut e, p)?;
|
||||
warn_missing(err, &missing);
|
||||
Ok(e)
|
||||
} else if args.demo {
|
||||
Ok(soundcraft_engine::demo::demo_engine())
|
||||
} else {
|
||||
Ok(Engine::default())
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(e: &mut Engine, args: &EngineArgs, out: &mut dyn Write) -> Result<(), CliError> {
|
||||
if let Some(path) = &args.bounce {
|
||||
let mut p = json!({"path": unicode(path)?});
|
||||
if let Some(s) = &args.start {
|
||||
p["start"] = s.to_json();
|
||||
}
|
||||
if let Some(s) = &args.end {
|
||||
p["end"] = s.to_json();
|
||||
}
|
||||
// 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));
|
||||
}
|
||||
let v = e.execute("file.bounce_mix", &p)?;
|
||||
outln!(out, "{v}");
|
||||
}
|
||||
if let Some(path) = args.save_path() {
|
||||
let v = e.execute("session.save", &json!({"path": unicode(&path)?, "replace": true}))?;
|
||||
outln!(out, "{v}");
|
||||
}
|
||||
if args.inspect {
|
||||
print_json(out, &soundcraft_engine::inspect::session(e, false), args.compact);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn run_cmds(args: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result<(), CliError> {
|
||||
let mut e = load_engine(&args.engine, err)?;
|
||||
for CmdSpec { id, params } in &args.cmd {
|
||||
match e.execute(id, params) {
|
||||
Ok(v) => outln!(out, "{id}: {v}"),
|
||||
Err(source) => return Err(CliError::Command { id: id.clone(), source }),
|
||||
}
|
||||
}
|
||||
finish(&mut e, &args.engine, out)
|
||||
}
|
||||
|
||||
pub(crate) fn script(args: &ScriptArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result<(), CliError> {
|
||||
let path = &args.file;
|
||||
let text = std::fs::read_to_string(path).map_err(|source| CliError::File { path: path.clone(), source })?;
|
||||
let mut e = load_engine(&args.engine, err)?;
|
||||
let keep = args.keep_going;
|
||||
let mut failed = false;
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let (id, p) = line.split_once(char::is_whitespace).unwrap_or((line, "{}"));
|
||||
let params = serde_json::from_str::<Value>(p.trim()).map_err(|source| CliError::ScriptJson { path: path.clone(), line: n + 1, source })?;
|
||||
match e.execute(id, ¶ms) {
|
||||
Ok(v) => outln!(out, "{id}: {v}"),
|
||||
Err(source) => {
|
||||
let failure = CliError::ScriptLine { path: path.clone(), line: n + 1, id: id.to_owned(), source };
|
||||
if !keep {
|
||||
return Err(failure);
|
||||
}
|
||||
outln!(err, "{failure}");
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
finish(&mut e, &args.engine, out)?;
|
||||
if failed { Err(CliError::ScriptFailed) } else { Ok(()) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_media_gets_one_warning_each() {
|
||||
let mut err = Vec::new();
|
||||
warn_missing(&mut err, &[]);
|
||||
assert!(err.is_empty());
|
||||
warn_missing(&mut err, &["a.wav".to_string(), "b.wav: decode failed".to_string()]);
|
||||
assert_eq!(String::from_utf8(err).unwrap(), "warning: missing media: a.wav\nwarning: missing media: b.wav: decode failed\n");
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
//! `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).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
fn bin() -> Command {
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
|
||||
cmd.env("SOUNDCRAFT_NO_PREFS", "1");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> Output {
|
||||
bin().args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("sc-cli-roots-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Send `calls` (tool name, arguments) to `soundcraft-cli mcp <args>`; returns whether it exited
|
||||
/// cleanly, its stderr, and the stdout lines after `initialize`, one per call.
|
||||
fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
|
||||
let mut child = bin().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
||||
for (i, (name, arguments)) in calls.iter().enumerate() {
|
||||
let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}});
|
||||
writeln!(stdin, "{call}").unwrap();
|
||||
}
|
||||
}
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
(out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies)
|
||||
}
|
||||
|
||||
fn text(reply: &Value) -> &str {
|
||||
reply["result"]["content"][0]["text"].as_str().unwrap()
|
||||
}
|
||||
|
||||
/// A short WAV made by the binary itself.
|
||||
fn make_wav(path: &std::path::Path) {
|
||||
let o = run(&["run", "--demo", "--bounce", path.to_str().unwrap(), "--start", "0", "--end", "0.1"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_automation_roots_confine_the_server() {
|
||||
let base = tmpdir("roots");
|
||||
let (work, elsewhere) = (base.join("work"), base.join("elsewhere"));
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
std::fs::create_dir_all(&elsewhere).unwrap();
|
||||
make_wav(&work.join("in.wav"));
|
||||
make_wav(&elsewhere.join("secret.wav"));
|
||||
let work_s = work.to_str().unwrap();
|
||||
let secret = elsewhere.join("secret.wav").to_str().unwrap().to_string();
|
||||
|
||||
// Both spellings of the flags; inside works, outside is refused with a readable message.
|
||||
let read_flag = format!("--automation-read-root={work_s}");
|
||||
let calls = [
|
||||
("import_audio", serde_json::json!({"path": work.join("in.wav").to_str().unwrap()})),
|
||||
("import_audio", serde_json::json!({"path": secret})),
|
||||
("save_session", serde_json::json!({"path": work.join("saved.scraft").to_str().unwrap()})),
|
||||
("save_session", serde_json::json!({"path": elsewhere.join("saved.scraft").to_str().unwrap()})),
|
||||
("bounce_mix", serde_json::json!({"path": elsewhere.join("mix.wav").to_str().unwrap()})),
|
||||
];
|
||||
let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls);
|
||||
assert!(success, "{stderr}");
|
||||
assert!(stderr.contains("files confined"), "{stderr}");
|
||||
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
|
||||
assert_eq!(replies[1]["result"]["isError"], true);
|
||||
assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1]));
|
||||
assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]);
|
||||
assert!(work.join("saved.scraft").exists());
|
||||
assert_eq!(replies[3]["result"]["isError"], true);
|
||||
assert!(text(&replies[3]).contains("outside the write root"), "{}", text(&replies[3]));
|
||||
assert_eq!(replies[4]["result"]["isError"], true);
|
||||
assert!(text(&replies[4]).contains("outside the write root"), "{}", text(&replies[4]));
|
||||
assert!(!elsewhere.join("saved.scraft").exists() && !elsewhere.join("mix.wav").exists());
|
||||
|
||||
// A root left out grants none of its access.
|
||||
let (_, _, replies) = mcp_session(&[&read_flag], &calls[2..3]);
|
||||
assert_eq!(replies[0]["result"]["isError"], true);
|
||||
assert!(text(&replies[0]).contains("write authority is absent"), "{}", text(&replies[0]));
|
||||
let (_, _, replies) = mcp_session(&["--automation-write-root", work_s], &calls[0..1]);
|
||||
assert_eq!(replies[0]["result"]["isError"], true);
|
||||
assert!(text(&replies[0]).contains("read authority is absent"), "{}", text(&replies[0]));
|
||||
|
||||
// No flags: nothing is confined.
|
||||
let (success, _, replies) = mcp_session(&[], &calls[1..2]);
|
||||
assert!(success);
|
||||
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
let base = tmpdir("roots-args");
|
||||
let dir = base.to_str().unwrap();
|
||||
let missing = base.join("missing");
|
||||
let fails = |args: &[&str], needle: &str| {
|
||||
let mut full = vec!["mcp"];
|
||||
full.extend_from_slice(args);
|
||||
let o = run(&full);
|
||||
assert!(!o.status.success(), "{args:?}");
|
||||
let err = String::from_utf8_lossy(&o.stderr).into_owned();
|
||||
assert!(err.contains(needle), "{args:?}: {err}");
|
||||
};
|
||||
// The flags confine the in-process server, so they cannot be combined with --connect.
|
||||
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
|
||||
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
|
||||
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
|
||||
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
|
||||
fails(&["--automation-read-root"], "needs a folder");
|
||||
fails(&["--automation-write-root", "--demo"], "needs a folder");
|
||||
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
fails(&["--automation-write-root="], "needs a non-empty folder");
|
||||
// A misspelled root flag must not start an unconfined server.
|
||||
fails(&["--automation-read-roots", dir], "unknown mcp option");
|
||||
// A file is not a folder.
|
||||
std::fs::write(base.join("f.txt"), "x").unwrap();
|
||||
fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_lists_the_root_flags() {
|
||||
let o = run(&["--help"]);
|
||||
let help = String::from_utf8_lossy(&o.stdout).into_owned();
|
||||
assert!(help.contains("--automation-read-root DIR") && help.contains("--automation-write-root DIR"), "{help}");
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! `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 craft_audio::{AudioBuffer, BitDepth, BwfInfo, EncodeOptions};
|
||||
|
||||
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::new(craft_audio::EncodeFormat::Wav).with_bit_depth(BitDepth::Int16);
|
||||
std::fs::write(path, craft_audio::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) = craft_audio::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::new(craft_audio::EncodeFormat::Wav).with_bit_depth(BitDepth::Int24);
|
||||
std::fs::write(&src, craft_audio::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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_keeps_the_bwf_time_stamp() {
|
||||
let dir = tmpdir("bext");
|
||||
let src = dir.join("take.wav");
|
||||
// 01:00:00:00 at 48 kHz.
|
||||
let bwf = BwfInfo::default()
|
||||
.with_description("take 3")
|
||||
.with_originator("recorder")
|
||||
.with_origination("2026-11-03", "10:00:00")
|
||||
.with_time_reference(172_800_000);
|
||||
let buf = AudioBuffer::new(48_000, 1, 4800);
|
||||
let opts = EncodeOptions::default().with_bwf(bwf.clone());
|
||||
std::fs::write(&src, craft_audio::encode(&buf, &opts).unwrap()).unwrap();
|
||||
let bext = |p: &Path| craft_audio::probe(&std::fs::read(p).unwrap(), None).unwrap().bwf;
|
||||
|
||||
let out = dir.join("16.wav");
|
||||
convert(&src, &out, &["--bit-depth", "16"]);
|
||||
assert_eq!(bext(&out), Some(bwf.clone()));
|
||||
// Resampled, the stamp still points at 01:00:00:00: 158,760,000 samples at 44.1 kHz.
|
||||
let out = dir.join("44k.wav");
|
||||
convert(&src, &out, &["--rate", "44100"]);
|
||||
assert_eq!(bext(&out), Some(bwf.with_time_reference(158_760_000)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Twelve MPEG-1 Layer I frames (mono, 32 kHz, 48 bytes each) of silence. The seventh has the
|
||||
/// forbidden bit allocation 15 in every subband: the decoder writes silence for it and counts it.
|
||||
fn mp1_with_a_damaged_frame() -> Vec<u8> {
|
||||
let header = [0xFF, 0xFF, 0x18, 0xC0];
|
||||
let good = [&header[..], &[0; 44]].concat();
|
||||
let bad = [&header[..], &[0xFF; 16], &[0; 28]].concat();
|
||||
(0..12).flat_map(|i| if i == 6 { bad.clone() } else { good.clone() }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_warns_about_damaged_packets_and_never_replaces_the_source_with_them() {
|
||||
let dir = tmpdir("damaged");
|
||||
let src = dir.join("take.mp1");
|
||||
let bytes = mp1_with_a_damaged_frame();
|
||||
std::fs::write(&src, &bytes).unwrap();
|
||||
let run = |args: &[&std::ffi::OsStr]| {
|
||||
Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")).env("SOUNDCRAFT_NO_PREFS", "1").arg("convert").args(args).output().unwrap()
|
||||
};
|
||||
// To another file: written, with a warning that says how much was lost.
|
||||
let out = dir.join("take.wav");
|
||||
let o = run(&[src.as_os_str(), out.as_os_str()]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let warning = format!("warning: {}: 1 damaged packet(s) decoded as silence\n", src.display());
|
||||
assert_eq!(String::from_utf8_lossy(&o.stderr), warning);
|
||||
assert_eq!(steps16(&out).len(), 12 * 384);
|
||||
// Over the source: refused, and the source is untouched.
|
||||
let o = run(&[src.as_os_str(), src.as_os_str(), "--in-place".as_ref()]);
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(stderr.starts_with(&format!("error: {}: 1 damaged packet(s) would be written as silence", src.display())), "{stderr}");
|
||||
assert_eq!(std::fs::read(&src).unwrap(), bytes);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_keeps_the_speaker_layout() {
|
||||
use craft_audio::ChannelLayout;
|
||||
let dir = tmpdir("layout");
|
||||
let (src, out) = (dir.join("side.wav"), dir.join("out.wav"));
|
||||
// 5.1 with side surrounds: not the layout a reader assumes for six channels.
|
||||
let opts = EncodeOptions::default().with_layout(ChannelLayout::SURROUND_5_1_SIDE);
|
||||
std::fs::write(&src, craft_audio::encode(&AudioBuffer::new(48_000, 6, 480), &opts).unwrap()).unwrap();
|
||||
let layout = |p: &Path| craft_audio::probe(&std::fs::read(p).unwrap(), None).unwrap().layout;
|
||||
assert_eq!(layout(&src), ChannelLayout::SURROUND_5_1_SIDE);
|
||||
convert(&src, &out, &[]);
|
||||
assert_eq!(layout(&out), ChannelLayout::SURROUND_5_1_SIDE);
|
||||
let flac = dir.join("out.flac");
|
||||
convert(&src, &flac, &[]);
|
||||
assert_eq!(layout(&flac), ChannelLayout::SURROUND_5_1_SIDE);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
//! How `soundcraft-cli` parses its arguments and reports results today, run as the real binary.
|
||||
//! A change to the command line's behaviour updates them on purpose.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use craft_audio::{AudioBuffer, BitDepth, EncodeOptions};
|
||||
|
||||
/// What one run of the binary produced.
|
||||
struct Invoke {
|
||||
code: i32,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
/// Runs the binary in `dir` with empty stdin and no control-port variable.
|
||||
fn invoke(dir: &Path, args: &[&str]) -> Invoke {
|
||||
invoke_env(dir, args, None)
|
||||
}
|
||||
|
||||
fn invoke_env(dir: &Path, args: &[&str], control_port: Option<&str>) -> Invoke {
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
|
||||
cmd.env("SOUNDCRAFT_NO_PREFS", "1").env_remove("SOUNDCRAFT_CONTROL_PORT");
|
||||
if let Some(port) = control_port {
|
||||
cmd.env("SOUNDCRAFT_CONTROL_PORT", port);
|
||||
}
|
||||
let out = cmd.current_dir(dir).args(args).stdin(Stdio::null()).output().unwrap();
|
||||
Invoke { code: out.status.code().unwrap(), stdout: String::from_utf8(out.stdout).unwrap(), stderr: String::from_utf8(out.stderr).unwrap() }
|
||||
}
|
||||
|
||||
/// A fresh directory holding `in.wav` (200 samples, 16-bit mono, 48 kHz) and `s.txt` (a one-command script).
|
||||
fn sandbox(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("sc-cli-behaviour-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut buf = AudioBuffer::new(48_000, 1, 200);
|
||||
for (i, d) in buf.channels[0].iter_mut().enumerate() {
|
||||
*d = i as f32 / 400.0;
|
||||
}
|
||||
let opts = EncodeOptions::new(craft_audio::EncodeFormat::Wav).with_bit_depth(BitDepth::Int16);
|
||||
std::fs::write(dir.join("in.wav"), craft_audio::encode(&buf, &opts).unwrap()).unwrap();
|
||||
std::fs::write(dir.join("s.txt"), "track.new {}\n").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// The sample rate of the WAV `name` in `dir`.
|
||||
fn rate_of(dir: &Path, name: &str) -> u32 {
|
||||
craft_audio::probe(&std::fs::read(dir.join(name)).unwrap(), None).unwrap().sample_rate
|
||||
}
|
||||
|
||||
/// Bit depth of the WAV `name` in `dir`, as the probe reports the sample format.
|
||||
fn format_of(dir: &Path, name: &str) -> String {
|
||||
format!("{:?}", craft_audio::probe(&std::fs::read(dir.join(name)).unwrap(), None).unwrap().sample_format)
|
||||
}
|
||||
|
||||
/// Exit 1, nothing on stdout, and exactly `error: {message}` on stderr.
|
||||
#[track_caller]
|
||||
fn assert_fails(got: &Invoke, message: &str) {
|
||||
assert_eq!(got.code, 1, "stderr: {}", got.stderr);
|
||||
assert_eq!(got.stdout, "");
|
||||
assert_eq!(got.stderr, format!("error: {message}\n"));
|
||||
}
|
||||
|
||||
/// Exit 0 with nothing on either stream.
|
||||
#[track_caller]
|
||||
fn assert_silent_success(got: &Invoke) {
|
||||
assert_eq!((got.code, got.stdout.as_str(), got.stderr.as_str()), (0, "", ""));
|
||||
}
|
||||
|
||||
/// Exit 2, nothing on stdout, and a clap usage error starting with `error: {message}` on stderr.
|
||||
#[track_caller]
|
||||
fn assert_usage_error(got: &Invoke, message: &str) {
|
||||
assert_eq!(got.code, 2, "stderr: {}", got.stderr);
|
||||
assert_eq!(got.stdout, "");
|
||||
assert!(got.stderr.starts_with(&format!("error: {message}")), "{}", got.stderr);
|
||||
}
|
||||
|
||||
/// The OS's message for a missing file: error 2 is `ENOENT` on Unix and `ERROR_FILE_NOT_FOUND` on Windows.
|
||||
fn not_found() -> std::io::Error {
|
||||
std::io::Error::from_raw_os_error(2)
|
||||
}
|
||||
|
||||
/// Exit 1 with the refused-connection message for `port`; the OS's text for the refusal differs by platform.
|
||||
#[track_caller]
|
||||
fn assert_refused(got: &Invoke, port: u16) {
|
||||
assert_eq!((got.code, got.stdout.as_str()), (1, ""));
|
||||
let prefix = format!("error: cannot reach SoundCraft at 127.0.0.1:{port}: ");
|
||||
assert!(got.stderr.starts_with(&prefix) && got.stderr.contains(" (os error ") && got.stderr.ends_with(")\n"), "{}", got.stderr);
|
||||
}
|
||||
|
||||
// ---- logging ----
|
||||
|
||||
/// Warnings SoundCraft logs (a plugin that fails to load, a backup it can't write) go to standard
|
||||
/// error. Without a logger they were dropped.
|
||||
#[test]
|
||||
fn logged_warnings_reach_standard_error() {
|
||||
let dir = sandbox("log");
|
||||
let first = invoke(&dir, &["run", "--save", "s.scraft"]);
|
||||
assert_eq!(first.code, 0, "{}", first.stderr);
|
||||
// A file where the backups folder should go: the next save that changes the session can't
|
||||
// back the old one up.
|
||||
std::fs::write(dir.join("Backups"), b"not a folder").unwrap();
|
||||
let again = invoke(&dir, &["run", "--in", "s.scraft", "--cmd", r#"track.new={"count":1}"#, "--save", "s.scraft", "--in-place"]);
|
||||
assert_eq!(again.code, 0, "{}", again.stderr);
|
||||
assert!(again.stderr.contains("warning: no backup of"), "{}", again.stderr);
|
||||
assert!(!again.stdout.contains("no backup"), "results only on standard output: {}", again.stdout);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---- unknown flags ----
|
||||
|
||||
#[test]
|
||||
fn unknown_flags_stop_the_work_before_it_starts() {
|
||||
let dir = sandbox("unknown-flag-reject");
|
||||
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "out.wav", "--bogus"]), "unexpected argument '--bogus' found");
|
||||
assert_usage_error(&invoke(&dir, &["run", "--bogus", "--save", "s.scraft"]), "unexpected argument '--bogus' found");
|
||||
assert_usage_error(&invoke(&dir, &["script", "s.txt", "--bogus"]), "unexpected argument '--bogus' found");
|
||||
// A single-dash word is a flag too.
|
||||
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "out.wav", "-x"]), "unexpected argument '-x' found");
|
||||
assert!(!dir.join("out.wav").exists() && !dir.join("s.scraft").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_flags_may_come_before_or_between_the_positionals() {
|
||||
let dir = sandbox("convert-order");
|
||||
let got = invoke(&dir, &["convert", "--rate", "44100", "in.wav", "a.wav"]);
|
||||
assert_eq!((got.code, got.stdout.as_str(), got.stderr.as_str()), (0, "wrote a.wav (1 ch, 44100 Hz, 0.00 s)\n", ""));
|
||||
let got = invoke(&dir, &["convert", "in.wav", "--rate", "44100", "b.wav"]);
|
||||
assert_eq!((got.code, got.stdout.as_str(), got.stderr.as_str()), (0, "wrote b.wav (1 ch, 44100 Hz, 0.00 s)\n", ""));
|
||||
assert_eq!((rate_of(&dir, "a.wav"), rate_of(&dir, "b.wav")), (44_100, 44_100));
|
||||
}
|
||||
|
||||
// ---- value flags ----
|
||||
|
||||
#[test]
|
||||
fn convert_value_flags_take_a_separate_or_inline_value() {
|
||||
let dir = sandbox("convert-values");
|
||||
assert_eq!(invoke(&dir, &["convert", "in.wav", "c.wav", "--rate", "44100"]).stdout, "wrote c.wav (1 ch, 44100 Hz, 0.00 s)\n");
|
||||
assert_eq!(invoke(&dir, &["convert", "in.wav", "d.wav", "--rate=44100"]).stdout, "wrote d.wav (1 ch, 44100 Hz, 0.00 s)\n");
|
||||
assert_eq!(rate_of(&dir, "c.wav"), 44_100);
|
||||
assert_eq!(rate_of(&dir, "d.wav"), 44_100);
|
||||
assert_eq!(invoke(&dir, &["convert", "in.wav", "e.wav", "--bit-depth", "16"]).code, 0);
|
||||
assert_eq!(invoke(&dir, &["convert", "in.wav", "f.wav", "--bit-depth=32f"]).code, 0);
|
||||
assert_eq!(format_of(&dir, "e.wav"), "Int16");
|
||||
assert_eq!(format_of(&dir, "f.wav"), "Float32");
|
||||
assert_eq!(invoke(&dir, &["convert", "in.wav", "h.wav", "--bit-depth", "32"]).code, 0);
|
||||
assert_eq!(format_of(&dir, "h.wav"), "Float32");
|
||||
assert_eq!(invoke(&dir, &["convert", "in.wav", "g.wav"]).code, 0);
|
||||
assert_eq!(format_of(&dir, "g.wav"), "Int24");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_value_flag_without_a_value_or_with_a_bad_one_fails() {
|
||||
let dir = sandbox("convert-bad-values");
|
||||
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "o.wav", "--rate"]), "a value is required for '--rate <HZ>' but none was supplied");
|
||||
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "o.wav", "--bit-depth"]), "a value is required for '--bit-depth <16|24|32f>'");
|
||||
// A following flag is the next flag, not the value.
|
||||
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "o.wav", "--rate", "--normalize"]), "a value is required for '--rate <HZ>'");
|
||||
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "o.wav", "--normalize=1"]), "unexpected value '1' for '--normalize' found");
|
||||
assert!(!dir.join("o.wav").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_flags_are_usage_errors_in_either_spelling() {
|
||||
let dir = sandbox("repeat");
|
||||
for args in [
|
||||
&["convert", "in.wav", "a.wav", "--rate", "44100", "--rate", "22050"][..],
|
||||
&["convert", "in.wav", "a.wav", "--rate=22050", "--rate", "44100"],
|
||||
&["convert", "in.wav", "a.wav", "--bit-depth", "16", "--bit-depth", "24"],
|
||||
&["convert", "in.wav", "a.wav", "--normalize", "--normalize"],
|
||||
&["run", "--save", "a.scraft", "--save", "b.scraft"],
|
||||
&["run", "--demo", "--demo"],
|
||||
&["run", "--demo", "--sample"],
|
||||
&["script", "s.txt", "--keep-going", "--keep-going"],
|
||||
&["app", "--port", "1", "--port", "2", "x"],
|
||||
&["mcp", "--demo", "--demo"],
|
||||
] {
|
||||
let got = invoke(&dir, args);
|
||||
assert_usage_error(&got, "the argument '--");
|
||||
assert_eq!(got.code, 2, "{args:?}");
|
||||
}
|
||||
assert!(!dir.join("a.wav").exists() && !dir.join("a.scraft").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_input_and_output_errors() {
|
||||
let dir = sandbox("convert-io");
|
||||
assert_fails(&invoke(&dir, &["convert", "nope.wav", "o.wav"]), &format!("nope.wav: {}", not_found()));
|
||||
assert_fails(
|
||||
&invoke(&dir, &["convert", "in.wav", "o.xyz"]),
|
||||
"o.xyz: cannot write `.xyz` files (supported: wav, wave, bwf, aif, aiff, aifc, flac)",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_value_flags_take_a_separate_or_inline_value() {
|
||||
let dir = sandbox("run-values");
|
||||
let tracks = "track.new: {\"tracks\":[1]}\n";
|
||||
assert_eq!(invoke(&dir, &["run", "--cmd", "track.new"]).stdout, tracks);
|
||||
assert_eq!(invoke(&dir, &["run", "--cmd=track.new"]).stdout, tracks);
|
||||
let save = invoke(&dir, &["run", "--save", "s1.scraft"]);
|
||||
assert_eq!((save.code, save.stdout.as_str()), (0, "{\"audio_files_written\":0,\"path\":\"s1.scraft\"}\n"));
|
||||
let save = invoke(&dir, &["run", "--save=s2.scraft"]);
|
||||
assert_eq!((save.code, save.stdout.as_str()), (0, "{\"audio_files_written\":0,\"path\":\"s2.scraft\"}\n"));
|
||||
assert!(dir.join("s1.scraft").exists() && dir.join("s2.scraft").exists());
|
||||
// --in in both spellings loads the saved session.
|
||||
for args in [["run", "--in", "s1.scraft", "--inspect"], ["run", "--in=s1.scraft", "--inspect", ""]] {
|
||||
let args: Vec<&str> = args.into_iter().filter(|a| !a.is_empty()).collect();
|
||||
let got = invoke(&dir, &args);
|
||||
assert_eq!(got.code, 0, "{args:?}: {}", got.stderr);
|
||||
assert!(got.stdout.contains("\"dirty\": false"), "{args:?}: {}", got.stdout);
|
||||
}
|
||||
// --bounce with --start/--end in both spellings.
|
||||
let spaced = invoke(&dir, &["run", "--demo", "--bounce", "b1.wav", "--start", "0", "--end", "0.1"]);
|
||||
assert_eq!(spaced.code, 0, "{}", spaced.stderr);
|
||||
assert!(spaced.stdout.contains("\"path\":\"b1.wav\"") && spaced.stdout.contains("\"seconds\":0.1"), "{}", spaced.stdout);
|
||||
let inline = invoke(&dir, &["run", "--demo", "--bounce=b2.wav", "--start=0", "--end=0.1"]);
|
||||
assert_eq!(inline.code, 0, "{}", inline.stderr);
|
||||
assert!(inline.stdout.contains("\"path\":\"b2.wav\"") && inline.stdout.contains("\"seconds\":0.1"), "{}", inline.stdout);
|
||||
assert!(dir.join("b1.wav").exists() && dir.join("b2.wav").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_cmd_repeats_and_runs_every_occurrence_in_order() {
|
||||
let dir = sandbox("run-repeat");
|
||||
assert_eq!(invoke(&dir, &["run", "--cmd", "track.new", "--cmd=track.new"]).stdout, "track.new: {\"tracks\":[1]}\ntrack.new: {\"tracks\":[2]}\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_times_are_values_but_other_dash_words_are_not() {
|
||||
let dir = sandbox("negative-time");
|
||||
// The engine counts a position before 0, so the bounce starts a second ahead of the session.
|
||||
for args in [&["--start", "-1"][..], &["--start=-1"]] {
|
||||
let mut full = vec!["run", "--demo", "--end", "0.1", "--bounce", "b.wav"];
|
||||
full.extend(args);
|
||||
let got = invoke(&dir, &full);
|
||||
assert_eq!(got.code, 0, "{}", got.stderr);
|
||||
assert!(got.stdout.contains("\"seconds\":1.1"), "{}", got.stdout);
|
||||
}
|
||||
assert_usage_error(&invoke(&dir, &["run", "--bounce", "-x.wav"]), "unexpected argument '-x' found");
|
||||
assert_usage_error(&invoke(&dir, &["run", "--cmd", "-x"]), "unexpected argument '-x' found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_runs_lines_and_takes_options_before_or_after_the_file() {
|
||||
let dir = sandbox("script");
|
||||
let got = invoke(&dir, &["script", "s.txt"]);
|
||||
assert_eq!((got.code, got.stdout.as_str(), got.stderr.as_str()), (0, "track.new: {\"tracks\":[1]}\n", ""));
|
||||
let got = invoke(&dir, &["script", "s.txt", "--save", "sv.scraft"]);
|
||||
assert_eq!(got.stdout, "track.new: {\"tracks\":[1]}\n{\"audio_files_written\":0,\"path\":\"sv.scraft\"}\n");
|
||||
assert!(dir.join("sv.scraft").exists());
|
||||
assert_eq!(invoke(&dir, &["script", "s.txt", "--keep-going"]).stdout, "track.new: {\"tracks\":[1]}\n");
|
||||
assert_eq!(invoke(&dir, &["script", "--keep-going", "s.txt"]).stdout, "track.new: {\"tracks\":[1]}\n");
|
||||
assert_fails(&invoke(&dir, &["script", "nope.txt"]), &format!("nope.txt: {}", not_found()));
|
||||
assert_fails(&invoke(&dir, &["script", "s.txt", "--in=nope.scraft"]), &format!("nope.scraft: {}", not_found()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_line_errors_stop_unless_keep_going() {
|
||||
let dir = sandbox("script-errors");
|
||||
std::fs::write(dir.join("bad.txt"), "nosuch.cmd {}\ntrack.new {}\n").unwrap();
|
||||
std::fs::write(dir.join("badjson.txt"), "track.new {x\n").unwrap();
|
||||
// Kept-going line errors stay bare; a final error summary supplies the failing status.
|
||||
let stop = invoke(&dir, &["script", "bad.txt"]);
|
||||
assert_eq!((stop.code, stop.stdout.as_str()), (1, ""));
|
||||
assert_eq!(stop.stderr, "error: bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\n");
|
||||
let keep = invoke(&dir, &["script", "bad.txt", "--keep-going"]);
|
||||
assert_eq!((keep.code, keep.stdout.as_str()), (1, "track.new: {\"tracks\":[1]}\n"));
|
||||
assert_eq!(keep.stderr, "bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\nerror: script finished with command errors\n");
|
||||
assert_fails(&invoke(&dir, &["script", "badjson.txt"]), "badjson.txt:1: bad JSON: key must be a string at line 1 column 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_prints_json_for_audio_and_fails_for_missing_files() {
|
||||
let dir = sandbox("info");
|
||||
let got = invoke(&dir, &["info", "in.wav"]);
|
||||
assert_eq!((got.code, got.stderr.as_str()), (0, ""));
|
||||
assert!(
|
||||
got.stdout.contains("\"channels\": 1")
|
||||
&& got.stdout.contains("\"sample_rate\": 48000")
|
||||
&& got.stdout.contains("\"sample_format\": \"Int16\""),
|
||||
"{}",
|
||||
got.stdout
|
||||
);
|
||||
assert_fails(&invoke(&dir, &["info", "nope.wav"]), &format!("nope.wav: {}", not_found()));
|
||||
}
|
||||
|
||||
/// The extension decides a session in any case, as the desktop app's `open_path` does.
|
||||
#[test]
|
||||
fn info_reads_a_session_whatever_the_extension_case() {
|
||||
let dir = sandbox("info-session-case");
|
||||
assert_eq!(invoke(&dir, &["run", "--demo", "--save", "S.SCRAFT"]).code, 0);
|
||||
let got = invoke(&dir, &["info", "S.SCRAFT"]);
|
||||
assert_eq!((got.code, got.stderr.as_str()), (0, ""));
|
||||
assert!(got.stdout.starts_with("SESSION NAME:"), "{}", got.stdout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_compact_is_the_same_json_on_one_line() {
|
||||
let dir = sandbox("info-compact");
|
||||
let (pretty, compact) = (invoke(&dir, &["info", "in.wav"]), invoke(&dir, &["info", "in.wav", "--compact"]));
|
||||
assert_eq!((compact.code, compact.stderr.as_str()), (0, ""));
|
||||
assert_eq!(compact.stdout.trim_end().lines().count(), 1, "{}", compact.stdout);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&pretty.stdout).unwrap(),
|
||||
serde_json::from_str::<serde_json::Value>(&compact.stdout).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_writes_a_report_with_write_in_both_spellings() {
|
||||
let dir = sandbox("parity");
|
||||
let stdout_report = invoke(&dir, &["parity"]);
|
||||
assert_eq!(stdout_report.code, 0);
|
||||
assert!(stdout_report.stdout.starts_with("# Parity report\n"), "{}", stdout_report.stdout);
|
||||
for (flag, name) in [(vec!["--write", "p1.md"], "p1.md"), (vec!["--write=p2.md"], "p2.md")] {
|
||||
let mut args = vec!["parity"];
|
||||
args.extend(flag);
|
||||
let got = invoke(&dir, &args);
|
||||
assert_eq!(got.code, 0, "{}", got.stderr);
|
||||
assert!(got.stdout.starts_with(&format!("wrote {name}: ")), "{}", got.stdout);
|
||||
assert!(std::fs::read_to_string(dir.join(name)).unwrap().starts_with("# Parity report\n"));
|
||||
}
|
||||
assert_usage_error(&invoke(&dir, &["parity", "--write"]), "a value is required for '--write <PATH>' but none was supplied");
|
||||
}
|
||||
|
||||
// ---- outputs that replace an input ----
|
||||
|
||||
/// `in.wav`, `s.txt` and a saved session `s.scraft`.
|
||||
fn sandbox_with_session(tag: &str) -> PathBuf {
|
||||
let dir = sandbox(tag);
|
||||
assert_eq!(invoke(&dir, &["run", "--save", "s.scraft"]).code, 0);
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_output_that_is_an_input_is_refused_before_any_work() {
|
||||
let dir = sandbox_with_session("out-is-in");
|
||||
std::fs::hard_link(dir.join("in.wav"), dir.join("hard.wav")).unwrap();
|
||||
let (wav, session, script) =
|
||||
(std::fs::read(dir.join("in.wav")).unwrap(), std::fs::read(dir.join("s.scraft")).unwrap(), std::fs::read(dir.join("s.txt")).unwrap());
|
||||
let in_wav = dir.join("in.wav");
|
||||
let absolute = in_wav.to_str().unwrap();
|
||||
for out in ["in.wav", "./in.wav", "../{}/in.wav", absolute, "hard.wav"] {
|
||||
let out = out.replace("{}", dir.file_name().unwrap().to_str().unwrap());
|
||||
let got = invoke(&dir, &["convert", "in.wav", &out, "--rate", "44100"]);
|
||||
assert_usage_error(&got, &format!("<OUT> {out} is the <IN> file, which it would replace: choose another file, or pass --in-place"));
|
||||
}
|
||||
// Nothing ran: no --cmd result, no bounce, no save.
|
||||
let refused: &[(&[&str], &str)] = &[
|
||||
(&["run", "--in", "s.scraft", "--cmd", "track.new", "--save", "s.scraft"], "--save s.scraft is the --in session"),
|
||||
(&["run", "--in=./s.scraft", "--cmd", "track.new", "--bounce", "s.scraft"], "--bounce s.scraft is the --in session"),
|
||||
// --in-place lets only --save replace the session.
|
||||
(
|
||||
&["run", "--in", "s.scraft", "--in-place", "--cmd", "track.new", "--bounce", "./s.scraft"],
|
||||
"--bounce ./s.scraft is the --in session, which it would replace with audio: choose another file",
|
||||
),
|
||||
(&["script", "s.txt", "--in", "s.scraft", "--in-place", "--bounce", "s.scraft"], "--bounce s.scraft is the --in session"),
|
||||
(&["script", "s.txt", "--in", "s.scraft", "--save", "./s.scraft"], "--save ./s.scraft is the --in session"),
|
||||
(&["script", "s.txt", "--save", "./s.txt"], "--save ./s.txt is the script file, which it would replace: choose another file"),
|
||||
(&["script", "s.txt", "--in", "s.scraft", "--in-place", "--bounce", "s.txt"], "--bounce s.txt is the script file"),
|
||||
(&["run", "--demo", "--cmd", "track.new", "--bounce", "b.wav", "--save", "./b.wav"], "--bounce and --save both name ./b.wav"),
|
||||
];
|
||||
for (args, message) in refused {
|
||||
let got = invoke(&dir, args);
|
||||
assert_usage_error(&got, message);
|
||||
assert!(got.stderr.contains("\nUsage: soundcraft-cli "), "{}", got.stderr);
|
||||
}
|
||||
assert_eq!(std::fs::read(dir.join("in.wav")).unwrap(), wav);
|
||||
assert_eq!(std::fs::read(dir.join("s.scraft")).unwrap(), session);
|
||||
assert_eq!(std::fs::read(dir.join("s.txt")).unwrap(), script);
|
||||
assert!(!dir.join("b.wav").exists());
|
||||
assert_usage_error(
|
||||
&invoke(&dir, &["run", "--in-place", "--save", "s.scraft"]),
|
||||
"the following required arguments were not provided:\n --in <S.scraft>",
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_output_linked_to_the_input_is_refused() {
|
||||
let dir = sandbox_with_session("out-is-in-link");
|
||||
std::os::unix::fs::symlink("in.wav", dir.join("link.wav")).unwrap();
|
||||
std::os::unix::fs::symlink(".", dir.join("here")).unwrap();
|
||||
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "link.wav"]), "<OUT> link.wav is the <IN> file");
|
||||
assert_usage_error(&invoke(&dir, &["run", "--in", "s.scraft", "--save", "here/s.scraft"]), "--save here/s.scraft is the --in session");
|
||||
// Two outputs that don't exist yet, one through a linked folder.
|
||||
assert_usage_error(&invoke(&dir, &["run", "--bounce", "here/b.wav", "--save", "b.wav"]), "--bounce and --save both name b.wav");
|
||||
assert_eq!(rate_of(&dir, "in.wav"), 48_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_place_replaces_the_input_and_other_files_are_still_replaced() {
|
||||
let dir = sandbox_with_session("in-place");
|
||||
let got = invoke(&dir, &["convert", "in.wav", "./in.wav", "--rate", "44100", "--in-place"]);
|
||||
assert_eq!(got.code, 0, "{}", got.stderr);
|
||||
assert_eq!(rate_of(&dir, "in.wav"), 44_100);
|
||||
let got = invoke(&dir, &["run", "--in", "s.scraft", "--cmd", "track.new", "--save", "s.scraft", "--in-place"]);
|
||||
assert_eq!(got.stdout, "track.new: {\"tracks\":[1]}\n{\"audio_files_written\":0,\"path\":\"s.scraft\"}\n", "{}", got.stderr);
|
||||
// The saved session holds that track, so the next one is the second.
|
||||
assert_eq!(invoke(&dir, &["run", "--in", "s.scraft", "--cmd", "track.new"]).stdout, "track.new: {\"tracks\":[2]}\n");
|
||||
// An existing file that is no input is replaced without --in-place.
|
||||
for rate in ["32000", "22050"] {
|
||||
let got = invoke(&dir, &["convert", "in.wav", "o.wav", "--rate", rate]);
|
||||
assert_eq!(got.code, 0, "{}", got.stderr);
|
||||
}
|
||||
assert_eq!(rate_of(&dir, "o.wav"), 22_050);
|
||||
// other.scraft gets the script's track, then is replaced by s.scraft as it is.
|
||||
let next_track = |session: &str| invoke(&dir, &["run", "--in", session, "--cmd", "track.new"]).stdout;
|
||||
let got = invoke(&dir, &["script", "s.txt", "--in", "s.scraft", "--save", "other.scraft"]);
|
||||
assert_eq!(got.code, 0, "{}", got.stderr);
|
||||
assert_eq!(next_track("other.scraft"), "track.new: {\"tracks\":[3]}\n");
|
||||
let got = invoke(&dir, &["run", "--in", "s.scraft", "--save", "other.scraft"]);
|
||||
assert_eq!(got.code, 0, "{}", got.stderr);
|
||||
assert_eq!(next_track("other.scraft"), "track.new: {\"tracks\":[2]}\n");
|
||||
}
|
||||
|
||||
/// A write that fails leaves the input as it was: the new bytes go to a new file first.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn failed_in_place_convert_keeps_the_input() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = sandbox("in-place-fails");
|
||||
let before = std::fs::read(dir.join("in.wav")).unwrap();
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
let can_create = std::fs::File::create(dir.join("probe")).is_ok();
|
||||
if !can_create {
|
||||
let got = invoke(&dir, &["convert", "in.wav", "in.wav", "--rate", "44100", "--in-place"]);
|
||||
assert_eq!(got.code, 1, "{}", got.stderr);
|
||||
assert_eq!(std::fs::read(dir.join("in.wav")).unwrap(), before);
|
||||
}
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(std::fs::read_dir(&dir).unwrap().all(|e| !e.unwrap().file_name().to_string_lossy().ends_with(".part")));
|
||||
}
|
||||
|
||||
/// An in-place output that is a link to the input replaces the input, and the link stays a link.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn in_place_through_a_link_replaces_the_linked_input() {
|
||||
let dir = sandbox_with_session("in-place-link");
|
||||
std::os::unix::fs::symlink("in.wav", dir.join("link.wav")).unwrap();
|
||||
std::os::unix::fs::symlink("s.scraft", dir.join("link.scraft")).unwrap();
|
||||
let got = invoke(&dir, &["convert", "in.wav", "link.wav", "--rate", "44100", "--in-place"]);
|
||||
assert_eq!(got.stdout, "wrote link.wav (1 ch, 44100 Hz, 0.00 s)\n", "{}", got.stderr);
|
||||
assert_eq!(rate_of(&dir, "in.wav"), 44_100);
|
||||
let got = invoke(&dir, &["run", "--in", "s.scraft", "--cmd", "track.new", "--save", "link.scraft", "--in-place"]);
|
||||
assert_eq!(got.code, 0, "{}", got.stderr);
|
||||
// s.scraft holds that track, so the next one is the second.
|
||||
assert_eq!(invoke(&dir, &["run", "--in", "s.scraft", "--cmd", "track.new"]).stdout, "track.new: {\"tracks\":[2]}\n");
|
||||
for link in ["link.wav", "link.scraft"] {
|
||||
assert!(dir.join(link).symlink_metadata().unwrap().file_type().is_symlink(), "{link}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- app ----
|
||||
|
||||
#[test]
|
||||
fn app_port_comes_from_the_flag_then_the_environment() {
|
||||
let dir = sandbox("app-env");
|
||||
assert_refused(&invoke_env(&dir, &["app", "session.inspect"], Some("1")), 1);
|
||||
assert_refused(&invoke_env(&dir, &["app", "--port", "2", "session.inspect"], Some("1")), 2);
|
||||
// The flag wins before the variable is read, so a bad one doesn't matter.
|
||||
assert_refused(&invoke_env(&dir, &["app", "--port", "2", "session.inspect"], Some("abc")), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_takes_an_empty_port_variable_as_unset() {
|
||||
if std::net::TcpStream::connect("127.0.0.1:7979").is_ok() {
|
||||
eprintln!("skipped: something listens on 127.0.0.1:7979, the default port");
|
||||
return;
|
||||
}
|
||||
let dir = sandbox("app-env-empty");
|
||||
for empty in ["", " "] {
|
||||
assert_refused(&invoke_env(&dir, &["app", "session.inspect"], Some(empty)), 7979);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_port_variable_is_a_usage_error_naming_it() {
|
||||
let dir = sandbox("app-env-bad");
|
||||
for bad in ["abc", "70000", ":80"] {
|
||||
assert_usage_error(
|
||||
&invoke_env(&dir, &["app", "session.inspect"], Some(bad)),
|
||||
&format!("invalid value '{bad}' for 'SOUNDCRAFT_CONTROL_PORT': expected a port, 0-65535, or host:port"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_help_names_the_port_variable() {
|
||||
let got = invoke(&sandbox("app-help"), &["app", "--help"]);
|
||||
assert_eq!(got.code, 0, "{}", got.stderr);
|
||||
assert!(got.stdout.contains("[env: SOUNDCRAFT_CONTROL_PORT=]\n [default: 7979]\n"), "{}", got.stdout);
|
||||
}
|
||||
|
||||
// ---- mcp ----
|
||||
|
||||
#[test]
|
||||
fn mcp_accepts_its_flags_in_both_spellings_and_exits_cleanly_on_empty_stdin() {
|
||||
let dir = sandbox("mcp-ok");
|
||||
for args in [vec!["mcp"], vec!["mcp", "--demo"], vec!["mcp", "--connect", "1"], vec!["mcp", "--connect=1"]] {
|
||||
assert_silent_success(&invoke(&dir, &args));
|
||||
}
|
||||
// A root prints the confinement line to stderr (never stdout).
|
||||
for args in [&["mcp", "--automation-read-root", "."][..], &["mcp", "--automation-read-root=."][..]] {
|
||||
let got = invoke(&dir, args);
|
||||
assert_eq!((got.code, got.stdout.as_str()), (0, ""));
|
||||
assert!(got.stderr.starts_with("soundcraft-cli: files confined: read ") && got.stderr.ends_with("; write none\n"), "{}", got.stderr);
|
||||
}
|
||||
// A repeated write-root flag is accepted in either spelling.
|
||||
let twice = invoke(&dir, &["mcp", "--automation-write-root=.", "--automation-write-root", "."]);
|
||||
assert_eq!(twice.code, 0);
|
||||
assert!(
|
||||
twice.stderr.starts_with("soundcraft-cli: files confined: read none; write ") && !twice.stderr.contains("; write none"),
|
||||
"{}",
|
||||
twice.stderr
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_demo_with_connect_is_a_usage_error() {
|
||||
let dir = sandbox("mcp-demo-connect");
|
||||
assert_usage_error(&invoke(&dir, &["mcp", "--demo", "--connect", "1"]), "the argument '--demo' cannot be used with '--connect <PORT>'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_roots_with_connect_are_a_usage_error() {
|
||||
let dir = sandbox("mcp-roots-connect");
|
||||
// Refused before any folder is opened, so a missing one isn't reported.
|
||||
assert_usage_error(
|
||||
&invoke(&dir, &["mcp", "--connect", "1", "--automation-write-root", "missing"]),
|
||||
"the argument '--connect <PORT>' cannot be used with '--automation-write-root <DIR>'",
|
||||
);
|
||||
assert_usage_error(
|
||||
&invoke(&dir, &["mcp", "--automation-read-root=.", "--connect=1"]),
|
||||
"the argument '--automation-read-root <DIR>' cannot be used with '--connect <PORT>'",
|
||||
);
|
||||
}
|
||||
|
||||
/// Native file paths round-trip on filesystems that accept non-Unicode names. macOS rejects
|
||||
/// these names with EILSEQ before the tool can write them. Run in-process: no shell is needed.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn non_unicode_file_names() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let dir = sandbox("nonunicode");
|
||||
let latin1 = |ext: &str| {
|
||||
let mut n = b"caf\xe9.".to_vec();
|
||||
n.extend_from_slice(ext.as_bytes());
|
||||
dir.join(OsString::from_vec(n))
|
||||
};
|
||||
let run = |args: Vec<OsString>| {
|
||||
let (mut out, mut err) = (Vec::new(), Vec::new());
|
||||
let code = soundcraft_cli::run(args, &mut out, &mut err);
|
||||
(code, String::from_utf8_lossy(&err).into_owned())
|
||||
};
|
||||
let (code, err) = run(vec!["convert".into(), dir.join("in.wav").into(), latin1("flac").into()]);
|
||||
assert_eq!(code, 0, "{err}");
|
||||
assert!(std::fs::metadata(latin1("flac")).unwrap().len() > 0);
|
||||
// A session opens by path; only paths that go into the engine's JSON must be Unicode.
|
||||
let (code, err) = run(vec!["run".into(), "--demo".into(), "--save".into(), dir.join("s.scraft").into()]);
|
||||
assert_eq!(code, 0, "{err}");
|
||||
std::fs::rename(dir.join("s.scraft"), latin1("scraft")).unwrap();
|
||||
let (code, err) = run(vec!["info".into(), latin1("scraft").into()]);
|
||||
assert_eq!(code, 0, "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Engine output paths enter JSON, so invalid Unicode must be refused even on a filesystem
|
||||
/// that cannot create such a file (including macOS).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_session_outputs_are_rejected() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let dir = sandbox("nonunicode-outputs");
|
||||
for (flag, name) in [("--bounce", b"caf\xe9.wav".as_slice()), ("--save", b"caf\xe9.scraft".as_slice())] {
|
||||
let path = dir.join(OsString::from_vec(name.to_vec()));
|
||||
let (mut out, mut err) = (Vec::new(), Vec::new());
|
||||
let code = soundcraft_cli::run(vec!["run".into(), "--demo".into(), flag.into(), path.into_os_string()], &mut out, &mut err);
|
||||
let err = String::from_utf8_lossy(&err);
|
||||
assert_eq!(code, 1, "{err}");
|
||||
assert!(err.contains("caf\u{FFFD}.") && err.contains("not valid Unicode"), "{err}");
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! `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"], "a value is required");
|
||||
fails(&["--automation-read-root"], "a value is required");
|
||||
fails(&["--automation-write-root", "--demo"], "a value is required");
|
||||
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
fails(&["--automation-write-root="], "a value is required");
|
||||
// A misspelled root flag must not start an unconfined server.
|
||||
fails(&["--automation-read-roots", dir], "unexpected argument '--automation-read-roots'");
|
||||
// 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(&["mcp", "--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);
|
||||
}
|
||||
|
||||
/// `commands --json` lists every command with the details `describe` prints for one (#744).
|
||||
#[test]
|
||||
fn commands_json_lists_what_describe_prints() {
|
||||
let json = |o: &Output| -> Value {
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
serde_json::from_slice(&o.stdout).unwrap()
|
||||
};
|
||||
let all = json(&run(&["commands", "--json"]));
|
||||
let all = all.as_array().unwrap();
|
||||
let table = run(&["commands"]);
|
||||
assert_eq!(all.len(), String::from_utf8_lossy(&table.stdout).lines().count());
|
||||
let bounce = json(&run(&["describe", "file.bounce_mix"]));
|
||||
assert!(all.contains(&bounce), "{bounce}");
|
||||
assert!(all.iter().all(|c| c["id"].is_string() && c["params"].is_string() && c["menu"].is_array()));
|
||||
// The filter applies, and --compact prints one line.
|
||||
let compact = run(&["commands", "bounce", "--json", "--compact"]);
|
||||
assert_eq!(String::from_utf8_lossy(&compact.stdout).lines().count(), 1);
|
||||
let some = json(&compact);
|
||||
let some = some.as_array().unwrap();
|
||||
assert!(some.contains(&bounce) && some.len() < all.len(), "{some:?}");
|
||||
// No match is an empty list, still JSON.
|
||||
assert_eq!(json(&run(&["commands", "zzzzqq", "--json"])), Value::Array(Vec::new()));
|
||||
// --compact shapes JSON output only.
|
||||
let bad = run(&["commands", "--compact"]);
|
||||
assert_eq!(bad.status.code(), Some(2));
|
||||
assert!(String::from_utf8_lossy(&bad.stderr).contains("--json"), "{}", String::from_utf8_lossy(&bad.stderr));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod behaviour;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod cli;
|
||||
mod closed_stdout;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod man_and_completions;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod run;
|
||||
mod script_status;
|
||||
@@ -0,0 +1,55 @@
|
||||
//! The clap definition is consistent, and every shell's completions and every man page
|
||||
//! (`examples/man-and-completions.rs`) can be generated from it.
|
||||
|
||||
use clap::ValueEnum;
|
||||
use clap_complete::Shell;
|
||||
|
||||
// The example itself, so the test writes the files exactly as the packages get them.
|
||||
#[allow(dead_code)]
|
||||
#[path = "../../examples/man-and-completions.rs"]
|
||||
mod example;
|
||||
|
||||
#[test]
|
||||
fn command_is_consistent() {
|
||||
soundcraft_cli::command().debug_assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completions_generate_for_every_shell() {
|
||||
for &shell in Shell::value_variants() {
|
||||
let mut out = Vec::new();
|
||||
clap_complete::generate(shell, &mut soundcraft_cli::command(), "soundcraft-cli", &mut out);
|
||||
assert!(!out.is_empty(), "{shell}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn man_pages_render_for_the_command_and_every_subcommand() {
|
||||
let cmd = soundcraft_cli::command();
|
||||
for page in std::iter::once(&cmd).chain(cmd.get_subcommands()) {
|
||||
let mut out = Vec::new();
|
||||
clap_mangen::Man::new(page.clone()).render(&mut out).unwrap();
|
||||
assert!(!out.is_empty(), "{}", page.get_name());
|
||||
}
|
||||
}
|
||||
|
||||
/// The example writes the five completion files under the names `packaging/linux/package.sh` and
|
||||
/// `packaging/freebsd/package.sh` install, `soundcraft-cli.1` and a page per subcommand, and no
|
||||
/// pages for the `help` subcommand.
|
||||
#[test]
|
||||
fn the_example_writes_the_files_the_packages_install() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-cli-{}-man-and-completions", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
example::generate(&dir).unwrap();
|
||||
|
||||
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
|
||||
written.sort();
|
||||
let completions = ["soundcraft-cli.bash", "_soundcraft-cli", "soundcraft-cli.fish", "soundcraft-cli.elv", "_soundcraft-cli.ps1"];
|
||||
let subcommands = ["info", "convert", "run", "script", "commands", "describe", "app", "mcp", "parity", "plugins"];
|
||||
let mut expected: Vec<String> = completions.map(String::from).into();
|
||||
expected.push("soundcraft-cli.1".into());
|
||||
expected.extend(subcommands.iter().map(|s| format!("soundcraft-cli-{s}.1")));
|
||||
expected.sort();
|
||||
assert_eq!(written, expected);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
//! The CLI run in-process through `soundcraft_cli::run`: the tests that need no files, working
|
||||
//! directory, environment or standard input. Those that do run the real binary (`behaviour.rs`).
|
||||
//! A change to the command line's behaviour updates them on purpose.
|
||||
|
||||
use soundcraft_cli::run;
|
||||
|
||||
/// What [`run`] returned and wrote.
|
||||
struct Invoke {
|
||||
code: i32,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
fn invoke(args: &[&str]) -> Invoke {
|
||||
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
|
||||
let code = run(args.iter().copied(), &mut stdout, &mut stderr);
|
||||
Invoke { code, stdout: String::from_utf8(stdout).unwrap(), stderr: String::from_utf8(stderr).unwrap() }
|
||||
}
|
||||
|
||||
/// Exit 1, nothing on stdout, and exactly `error: {message}` on stderr.
|
||||
#[track_caller]
|
||||
fn assert_fails(got: &Invoke, message: &str) {
|
||||
assert_eq!(got.code, 1, "stderr: {}", got.stderr);
|
||||
assert_eq!(got.stdout, "");
|
||||
assert_eq!(got.stderr, format!("error: {message}\n"));
|
||||
}
|
||||
|
||||
/// Exit 0 with nothing on either stream.
|
||||
#[track_caller]
|
||||
fn assert_silent_success(got: &Invoke) {
|
||||
assert_eq!((got.code, got.stdout.as_str(), got.stderr.as_str()), (0, "", ""));
|
||||
}
|
||||
|
||||
/// Exit 2, nothing on stdout, and a clap usage error on stderr: `error: ` with `message`, ending
|
||||
/// with the `--help` hint.
|
||||
#[track_caller]
|
||||
fn assert_usage_error(got: &Invoke, message: &str) {
|
||||
assert_eq!(got.code, 2, "stderr: {}", got.stderr);
|
||||
assert_eq!(got.stdout, "");
|
||||
assert!(got.stderr.starts_with(&format!("error: {message}")), "{}", got.stderr);
|
||||
assert!(got.stderr.ends_with("\n\nFor more information, try '--help'.\n"), "{}", got.stderr);
|
||||
}
|
||||
|
||||
/// Exit 0, help on stdout, nothing on stderr.
|
||||
#[track_caller]
|
||||
fn assert_help(got: &Invoke) -> &str {
|
||||
assert_eq!((got.code, got.stderr.as_str()), (0, ""), "{}", got.stdout);
|
||||
&got.stdout
|
||||
}
|
||||
|
||||
/// The OS's message for a missing file: error 2 is `ENOENT` on Unix and `ERROR_FILE_NOT_FOUND` on Windows.
|
||||
fn not_found() -> std::io::Error {
|
||||
std::io::Error::from_raw_os_error(2)
|
||||
}
|
||||
|
||||
/// Exit 1 with the refused-connection message for `port`; the OS's text for the refusal differs by platform.
|
||||
#[track_caller]
|
||||
fn assert_refused(got: &Invoke, port: u16) {
|
||||
assert_eq!((got.code, got.stdout.as_str()), (1, ""));
|
||||
let prefix = format!("error: cannot reach SoundCraft at 127.0.0.1:{port}: ");
|
||||
assert!(got.stderr.starts_with(&prefix) && got.stderr.contains(" (os error ") && got.stderr.ends_with(")\n"), "{}", got.stderr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_help_lists_every_subcommand_on_stdout() {
|
||||
let long = invoke(&["--help"]);
|
||||
let help = assert_help(&long);
|
||||
assert!(help.starts_with("soundcraft-cli: SoundCraft without a window\n\nUsage: soundcraft-cli <COMMAND>\n"), "{help}");
|
||||
for sub in ["info", "convert", "run", "script", "commands", "describe", "app", "mcp", "parity", "plugins", "help"] {
|
||||
assert!(help.contains(&format!("\n {sub} ")), "{sub}: {help}");
|
||||
}
|
||||
assert!(help.contains("-V, --version") && !help.contains("\n version "), "{help}");
|
||||
for spelling in ["-h", "help"] {
|
||||
let got = invoke(&[spelling]);
|
||||
assert_eq!((got.code, got.stdout, got.stderr), (0, long.stdout.clone(), String::new()), "{spelling}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_flags_print_the_long_version_to_stdout() {
|
||||
let expected = format!("soundcraft-cli {} ({} {})\n", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
|
||||
for spelling in ["--version", "-V"] {
|
||||
let got = invoke(&[spelling]);
|
||||
assert_eq!((got.code, got.stdout, got.stderr), (0, expected.clone(), String::new()), "{spelling}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_is_not_a_subcommand() {
|
||||
assert_usage_error(&invoke(&["version"]), "unrecognized subcommand 'version'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_subcommand_shows_that_subcommands_help() {
|
||||
let got = invoke(&["help", "convert"]);
|
||||
let help = assert_help(&got);
|
||||
assert!(help.starts_with("Convert an audio file"), "{help}");
|
||||
assert_eq!(help, assert_help(&invoke(&["convert", "--help"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_names_every_format_the_command_reads_and_writes() {
|
||||
let read = format!("({})", craft_audio::DECODE_EXTENSIONS.join(", "));
|
||||
let write = format!("its extension picks the format ({})", craft_audio::ENCODE_EXTENSIONS.join(", "));
|
||||
let convert = invoke(&["convert", "--help"]);
|
||||
let convert = assert_help(&convert);
|
||||
assert!(convert.contains(&read) && convert.contains(&write), "{convert}");
|
||||
let info = invoke(&["info", "--help"]);
|
||||
assert!(assert_help(&info).contains(&read), "{}", info.stdout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_arguments_prints_the_help_to_stderr_and_exits_2() {
|
||||
let got = invoke(&[]);
|
||||
assert_eq!((got.code, got.stdout.as_str()), (2, ""));
|
||||
assert_eq!(got.stderr, invoke(&["--help"]).stdout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_subcommand_is_a_usage_error() {
|
||||
assert_usage_error(&invoke(&["bogus"]), "unrecognized subcommand 'bogus'");
|
||||
assert_usage_error(&invoke(&["bogus", "--help"]), "unrecognized subcommand 'bogus'");
|
||||
// Subcommand names are case sensitive and not abbreviated.
|
||||
assert_usage_error(&invoke(&["INFO"]), "unrecognized subcommand 'INFO'");
|
||||
assert!(invoke(&["plug"]).stderr.contains("tip: a similar subcommand exists: 'plugins'"));
|
||||
assert_usage_error(&invoke(&["--bogus"]), "unexpected argument '--bogus' found");
|
||||
}
|
||||
|
||||
/// Each subcommand's help: its usage line, then every argument and flag it takes.
|
||||
#[test]
|
||||
fn every_subcommand_has_help_listing_its_arguments() {
|
||||
let engine = [
|
||||
"--in <S.scraft>",
|
||||
"--demo",
|
||||
"--sample",
|
||||
"--bounce <OUT>",
|
||||
"--start <T>",
|
||||
"--end <T>",
|
||||
"--save <S.scraft>",
|
||||
"--inspect",
|
||||
"--compact",
|
||||
"--in-place",
|
||||
];
|
||||
let cases: &[(&str, &str, &[&str])] = &[
|
||||
("info", "info [OPTIONS] <FILE>", &["<FILE>", "--compact"]),
|
||||
(
|
||||
"convert",
|
||||
"convert [OPTIONS] <IN> <OUT>",
|
||||
&["<IN>", "<OUT>", "--bit-depth <16|24|32f>", "--rate <HZ>", "--normalize", "--dither", "--no-dither", "--in-place"],
|
||||
),
|
||||
("run", "run [OPTIONS]", &["--cmd <ID[=JSON]>"]),
|
||||
("script", "script [OPTIONS] <FILE>", &["<FILE>", "--keep-going"]),
|
||||
("commands", "commands [OPTIONS] [FILTER]", &["[FILTER]", "--json", "--compact"]),
|
||||
("describe", "describe [OPTIONS] <ID>", &["<ID>", "--compact"]),
|
||||
(
|
||||
"app",
|
||||
"app [OPTIONS] <METHOD> [JSON]",
|
||||
&["--port <P>", "--compact", "<METHOD>", "[JSON]", "[env: SOUNDCRAFT_CONTROL_PORT", "[default: 7979]"],
|
||||
),
|
||||
(
|
||||
"mcp",
|
||||
"mcp [OPTIONS]",
|
||||
&["--connect <PORT>", "--demo", "--automation-read-root <DIR>", "--automation-write-root <DIR>", "conflict with --connect"],
|
||||
),
|
||||
("parity", "parity [OPTIONS]", &["--write <PATH>"]),
|
||||
("plugins", "plugins", &[]),
|
||||
];
|
||||
for &(sub, usage, args) in cases {
|
||||
let long = invoke(&[sub, "--help"]);
|
||||
let help = assert_help(&long);
|
||||
assert!(help.contains(&format!("\nUsage: soundcraft-cli {usage}\n")), "{sub}: {help}");
|
||||
let shared: &[&str] = if matches!(sub, "run" | "script") { &engine } else { &[] };
|
||||
for arg in args.iter().chain(shared).chain(&["-h, --help"]) {
|
||||
assert!(help.contains(arg), "{sub} lacks {arg}: {help}");
|
||||
}
|
||||
// `help SUB` is `SUB --help`; `-h` is the one-line-per-flag summary.
|
||||
assert_eq!(invoke(&["help", sub]).stdout, long.stdout, "help {sub}");
|
||||
assert!(assert_help(&invoke(&[sub, "-h"])).contains(&format!("\nUsage: soundcraft-cli {usage}\n")), "{sub} -h");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_wins_over_a_mistake_before_it() {
|
||||
let help = invoke(&["run", "--help"]).stdout;
|
||||
assert_eq!(assert_help(&invoke(&["run", "--bounce", "--help"])), help);
|
||||
assert_eq!(assert_help(&invoke(&["run", "--bogus", "--help"])), help);
|
||||
assert!(assert_help(&invoke(&["convert", "--rate", "-h"])).contains("Usage: soundcraft-cli convert"));
|
||||
assert!(assert_help(&invoke(&["mcp", "--connect", "--help"])).contains("Usage: soundcraft-cli mcp"));
|
||||
// After `--`, `--help` is a value.
|
||||
assert_eq!(invoke(&["info", "--", "--help"]).code, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_and_script_value_flags_without_a_value_are_usage_errors() {
|
||||
for (flag, name) in [("--cmd", "ID[=JSON]"), ("--in", "S.scraft"), ("--save", "S.scraft"), ("--bounce", "OUT"), ("--start", "T"), ("--end", "T")]
|
||||
{
|
||||
let message = format!("a value is required for '{flag} <{name}>' but none was supplied");
|
||||
assert_usage_error(&invoke(&["run", flag]), &message);
|
||||
if flag != "--cmd" {
|
||||
assert_usage_error(&invoke(&["script", "s.txt", flag]), &message);
|
||||
}
|
||||
}
|
||||
// A following flag is not the value.
|
||||
assert_usage_error(&invoke(&["run", "--bounce", "--inspect"]), "a value is required for '--bounce <OUT>'");
|
||||
assert_usage_error(&invoke(&["script", "s.txt", "--demo", "--bounce"]), "a value is required for '--bounce <OUT>'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boolean_flags_reject_an_inline_value() {
|
||||
assert_usage_error(&invoke(&["run", "--demo=1"]), "unexpected value '1' for '--demo' found; no more were expected");
|
||||
assert_usage_error(&invoke(&["run", "--inspect=1"]), "unexpected value '1' for '--inspect' found; no more were expected");
|
||||
assert_usage_error(&invoke(&["script", "s.txt", "--keep-going=1"]), "unexpected value '1' for '--keep-going' found; no more were expected");
|
||||
assert_usage_error(&invoke(&["mcp", "--demo=1"]), "unexpected value '1' for '--demo' found; no more were expected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_extra_positionals_are_usage_errors() {
|
||||
assert_usage_error(&invoke(&["info"]), "the following required arguments were not provided:\n <FILE>");
|
||||
assert_usage_error(&invoke(&["convert", "in.wav"]), "the following required arguments were not provided:\n <OUT>");
|
||||
assert_usage_error(&invoke(&["script"]), "the following required arguments were not provided:\n <FILE>");
|
||||
assert_usage_error(&invoke(&["describe"]), "the following required arguments were not provided:\n <ID>");
|
||||
assert_usage_error(&invoke(&["app", "--port", "1"]), "the following required arguments were not provided:\n <METHOD>");
|
||||
for args in [
|
||||
&["info", "a", "b"][..],
|
||||
&["convert", "in.wav", "out.wav", "b"],
|
||||
&["script", "s.txt", "b"],
|
||||
&["run", "b"],
|
||||
&["commands", "bounce", "b"],
|
||||
&["describe", "file.bounce_mix", "b"],
|
||||
&["app", "--port", "1", "session.inspect", "{}", "b"],
|
||||
&["mcp", "b"],
|
||||
&["parity", "b"],
|
||||
&["plugins", "b"],
|
||||
] {
|
||||
assert_usage_error(&invoke(args), "unexpected argument 'b' found");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_flags_are_usage_errors_on_every_subcommand() {
|
||||
for args in [
|
||||
&["info", "in.wav", "--bogus"][..],
|
||||
&["convert", "in.wav", "out.wav", "--bogus"],
|
||||
&["run", "--bogus"],
|
||||
&["script", "s.txt", "--bogus"],
|
||||
&["commands", "--bogus"],
|
||||
&["describe", "--bogus"],
|
||||
&["app", "--bogus", "session.inspect"],
|
||||
&["mcp", "--bogus"],
|
||||
&["parity", "--bogus"],
|
||||
&["plugins", "--bogus"],
|
||||
] {
|
||||
assert_usage_error(&invoke(args), "unexpected argument '--bogus' found");
|
||||
}
|
||||
// A single-dash word is a short flag, and there are none but -h.
|
||||
assert_usage_error(&invoke(&["run", "-x"]), "unexpected argument '-x' found");
|
||||
assert_usage_error(&invoke(&["mcp", "--automation-bogus=x"]), "unexpected argument '--automation-bogus' found");
|
||||
}
|
||||
|
||||
/// `args_os`: text that isn't valid Unicode reaches clap instead of aborting the process.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
|
||||
let code = run([OsString::from("describe"), OsString::from_vec(b"\xe9.wav".to_vec())], &mut stdout, &mut stderr);
|
||||
assert_eq!((code, stdout.as_slice()), (2, &b""[..]));
|
||||
assert!(String::from_utf8_lossy(&stderr).starts_with("error: invalid UTF-8 was detected in one or more arguments"));
|
||||
}
|
||||
|
||||
/// A bounce or save path goes to the engine as JSON text: a non-Unicode one fails before any command runs.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_non_unicode_output_fails_before_the_commands_run() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
for flag in ["--bounce", "--save"] {
|
||||
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
|
||||
let args = ["run", "--cmd", "track.new", flag].map(OsString::from);
|
||||
let code = run(args.into_iter().chain([OsString::from_vec(b"\xe9.out".to_vec())]), &mut stdout, &mut stderr);
|
||||
assert_eq!((code, stdout.as_slice()), (1, &b""[..]), "{flag}: {}", String::from_utf8_lossy(&stderr));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_demo_and_sample_are_the_same_flag() {
|
||||
let demo = invoke(&["run", "--demo", "--inspect"]);
|
||||
let sample = invoke(&["run", "--sample", "--inspect"]);
|
||||
assert_eq!((demo.code, sample.code), (0, 0));
|
||||
assert_eq!(demo.stdout, sample.stdout);
|
||||
assert!(demo.stdout.contains("\"name\": \"Verb\""), "{}", demo.stdout);
|
||||
}
|
||||
|
||||
/// `--compact` prints the same JSON on one line.
|
||||
#[track_caller]
|
||||
fn assert_compact_matches_pretty(pretty: &[&str], compact: &[&str]) {
|
||||
let (p, c) = (invoke(pretty), invoke(compact));
|
||||
assert_eq!((p.code, c.code), (0, 0), "{} {}", p.stderr, c.stderr);
|
||||
assert!(p.stdout.trim_end().contains('\n'), "pretty form spans lines: {}", p.stdout);
|
||||
assert_eq!(c.stdout.trim_end().lines().count(), 1, "{}", c.stdout);
|
||||
assert!(c.stdout.ends_with('\n'));
|
||||
let (p, c): (serde_json::Value, serde_json::Value) = (serde_json::from_str(&p.stdout).unwrap(), serde_json::from_str(&c.stdout).unwrap());
|
||||
assert_eq!(p, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_prints_describe_and_run_inspect_on_one_line() {
|
||||
assert_compact_matches_pretty(&["describe", "file.bounce_mix"], &["describe", "file.bounce_mix", "--compact"]);
|
||||
assert_compact_matches_pretty(&["run", "--demo", "--inspect"], &["run", "--demo", "--inspect", "--compact"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_without_inspect_is_a_usage_error() {
|
||||
assert_usage_error(&invoke(&["run", "--compact"]), "the following required arguments were not provided:\n --inspect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_command_failures() {
|
||||
assert_fails(&invoke(&["run", "--cmd", "nosuch.cmd"]), "nosuch.cmd: unknown command `nosuch.cmd`");
|
||||
assert_fails(&invoke(&["run", "--in", "nope.scraft"]), &format!("nope.scraft: {}", not_found()));
|
||||
assert_fails(&invoke(&["run", "--in=nope.scraft"]), &format!("nope.scraft: {}", not_found()));
|
||||
}
|
||||
|
||||
/// Relations and typed values are checked by clap: exit 2, the message names the flag, and nothing runs.
|
||||
#[test]
|
||||
fn flag_relations_and_typed_values_are_usage_errors() {
|
||||
for (args, message) in [
|
||||
(&["convert", "in.wav", "o.wav", "--dither", "--no-dither"][..], "the argument '--dither' cannot be used with '--no-dither'"),
|
||||
(&["convert", "in.wav", "o.wav", "--rate", "0"], "invalid value '0' for '--rate <HZ>'"),
|
||||
(&["convert", "in.wav", "o.wav", "--rate=abc"], "invalid value 'abc' for '--rate <HZ>'"),
|
||||
(&["convert", "in.wav", "o.wav", "--bit-depth", "8"], "invalid value '8' for '--bit-depth <16|24|32f>'"),
|
||||
(&["run", "--in", "s.scraft", "--demo"], "the argument '--in <S.scraft>' cannot be used with '--demo'"),
|
||||
(&["script", "s.txt", "--in", "s.scraft", "--sample"], "the argument '--in <S.scraft>' cannot be used with '--demo'"),
|
||||
(&["run", "--start", "1"], "the following required arguments were not provided:\n --bounce <OUT>"),
|
||||
(&["script", "s.txt", "--end", "1"], "the following required arguments were not provided:\n --bounce <OUT>"),
|
||||
(&["run", "--bounce", "b.wav", "--start", ""], "invalid value '' for '--start <T>'"),
|
||||
(&["run", "--bounce", "b.wav", "--start", "nan"], "invalid value 'nan' for '--start <T>': expected a finite number"),
|
||||
(&["run", "--bounce", "b.wav", "--end", "inf"], "invalid value 'inf' for '--end <T>': expected a finite number"),
|
||||
(&["run", "--bounce", "b.wav", "--end", "1e999"], "invalid value '1e999' for '--end <T>': expected a finite number"),
|
||||
(&["run", "--cmd", "track.new={", "--bounce", "b.wav"], "invalid value 'track.new={' for '--cmd <ID[=JSON]>': bad JSON:"),
|
||||
(&["run", "--cmd", "=1"], "invalid value '=1' for '--cmd <ID[=JSON]>'"),
|
||||
(&["describe", ""], "a value is required for '<ID>'"),
|
||||
(&["commands", ""], "a value is required for '[FILTER]'"),
|
||||
(&["app", ""], "a value is required for '<METHOD>'"),
|
||||
] {
|
||||
let got = invoke(args);
|
||||
assert_usage_error(&got, message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_later_cmd_runs_none_of_them() {
|
||||
assert_usage_error(&invoke(&["run", "--cmd", "track.new", "--cmd", "track.new={"]), "invalid value 'track.new={'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_filters_by_id_or_label() {
|
||||
let all = invoke(&["commands"]);
|
||||
assert_eq!((all.code, all.stderr.as_str()), (0, ""));
|
||||
assert!(all.stdout.starts_with("session.new "), "{}", all.stdout);
|
||||
let bounce = invoke(&["commands", "bounce"]);
|
||||
assert!(bounce.stdout.contains("file.bounce_mix ") && !bounce.stdout.contains("session.new"), "{}", bounce.stdout);
|
||||
// Matching is case-insensitive on the label only: an upper-case filter finds the labels, not the ids.
|
||||
assert!(invoke(&["commands", "BOUNCE"]).stdout.contains("Bounce Mix..."));
|
||||
assert_silent_success(&invoke(&["commands", "zzzzqq"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_prints_json_for_a_known_id_and_fails_for_an_unknown_one() {
|
||||
let got = invoke(&["describe", "file.bounce_mix"]);
|
||||
assert_eq!((got.code, got.stderr.as_str()), (0, ""));
|
||||
assert!(got.stdout.starts_with("{\n \"id\": \"file.bounce_mix\",\n \"label\": \"Bounce Mix...\""), "{}", got.stdout);
|
||||
assert_fails(&invoke(&["describe", "nosuch"]), "unknown command `nosuch`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_lists_one_line_per_plugin() {
|
||||
let got = invoke(&["plugins"]);
|
||||
assert_eq!((got.code, got.stderr.as_str()), (0, ""));
|
||||
assert!(got.stdout.starts_with("eq_1band EQ 1-Band Eq\n"), "{}", got.stdout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_reports_a_refused_connection_and_checks_arguments_first() {
|
||||
for args in [
|
||||
vec!["app", "--port", "1", "session.inspect"],
|
||||
vec!["app", "--port=1", "session.inspect"],
|
||||
vec!["app", "session.inspect", "--port", "1"],
|
||||
vec!["app", "--port", "1", "session.inspect", "{}"],
|
||||
] {
|
||||
assert_refused(&invoke(&args), 1);
|
||||
}
|
||||
assert_usage_error(
|
||||
&invoke(&["app", "--port", "1", "session.inspect", "{bad"]),
|
||||
"invalid value '{bad' for '[JSON]': bad JSON: key must be a string at line 1 column 2",
|
||||
);
|
||||
for bad in ["abc", "", "70000", ":80", "host:", "host:abc"] {
|
||||
assert_usage_error(
|
||||
&invoke(&["app", "--port", bad, "session.inspect"]),
|
||||
&format!("invalid value '{bad}' for '--port <P>': expected a port, 0-65535, or host:port"),
|
||||
);
|
||||
assert_usage_error(&invoke(&["mcp", "--connect", bad]), &format!("invalid value '{bad}' for '--connect <PORT>': expected a port"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_argument_errors_fail_before_reading_stdin() {
|
||||
assert_usage_error(&invoke(&["mcp", "--connect"]), "a value is required for '--connect <PORT>' but none was supplied");
|
||||
assert_usage_error(&invoke(&["mcp", "--connect", "--demo"]), "a value is required for '--connect <PORT>' but none was supplied");
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
let message = format!("a value is required for '{flag} <DIR>' but none was supplied");
|
||||
assert_usage_error(&invoke(&["mcp", flag]), &message);
|
||||
assert_usage_error(&invoke(&["mcp", flag, "--demo"]), &message);
|
||||
assert_usage_error(&invoke(&["mcp", &format!("{flag}=")]), &message);
|
||||
}
|
||||
assert_fails(
|
||||
&invoke(&["mcp", "--automation-read-root", "missing-dir"]),
|
||||
&format!("cannot open the automation read root `missing-dir`: {}", not_found()),
|
||||
);
|
||||
assert_usage_error(
|
||||
&invoke(&["mcp", "--automation-read-root", ".", "--connect", "1"]),
|
||||
"the argument '--automation-read-root <DIR>' cannot be used with '--connect <PORT>'",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let sequence = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("soundcraft-script-status-{}-{stamp}-{sequence}", std::process::id()));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn invoke(dir: &Path, args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")).current_dir(dir).env("SOUNDCRAFT_NO_PREFS", "1").args(args).output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_going_runs_later_commands_and_finishes_but_reports_failure() {
|
||||
let dir = Scratch::new();
|
||||
std::fs::write(
|
||||
dir.0.join("audit.txt"),
|
||||
"audit.deliberately_invalid {}\ntrack.new {\"name\":\"After failure\",\"count\":1,\"format\":\"stereo\"}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let stop = invoke(&dir.0, &["script", "audit.txt", "--save", "stopped.scraft"]);
|
||||
assert_eq!(stop.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&stop.stderr).contains("audit.deliberately_invalid"));
|
||||
assert!(!String::from_utf8_lossy(&stop.stdout).contains("track.new:"));
|
||||
assert!(!dir.0.join("stopped.scraft").exists());
|
||||
|
||||
let keep = invoke(
|
||||
&dir.0,
|
||||
&["script", "audit.txt", "--keep-going", "--bounce", "after.wav", "--start", "0", "--end", "0.01", "--save", "after.scraft", "--inspect"],
|
||||
);
|
||||
assert_eq!(keep.status.code(), Some(1), "{}", String::from_utf8_lossy(&keep.stderr));
|
||||
assert!(String::from_utf8_lossy(&keep.stderr).contains("audit.deliberately_invalid"));
|
||||
let stdout = String::from_utf8_lossy(&keep.stdout);
|
||||
assert!(stdout.contains("track.new:") && stdout.contains("After failure"), "{stdout}");
|
||||
assert!(dir.0.join("after.wav").is_file());
|
||||
let info = invoke(&dir.0, &["info", "after.scraft"]);
|
||||
assert!(info.status.success(), "{}", String::from_utf8_lossy(&info.stderr));
|
||||
assert!(String::from_utf8_lossy(&info.stdout).contains("After failure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_script_and_parsing_or_finish_failures_keep_their_status() {
|
||||
let dir = Scratch::new();
|
||||
std::fs::write(dir.0.join("good.txt"), "track.new {\"name\":\"Good\",\"count\":1,\"format\":\"stereo\"}\n").unwrap();
|
||||
let success = invoke(&dir.0, &["script", "good.txt", "--keep-going"]);
|
||||
assert!(success.status.success(), "{}", String::from_utf8_lossy(&success.stderr));
|
||||
|
||||
std::fs::write(dir.0.join("bad.txt"), "track.new {bad\ntrack.new {\"name\":\"Too late\"}\n").unwrap();
|
||||
let malformed = invoke(&dir.0, &["script", "bad.txt", "--keep-going", "--save", "malformed.scraft"]);
|
||||
assert_eq!(malformed.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&malformed.stderr).contains("bad JSON"));
|
||||
assert!(!String::from_utf8_lossy(&malformed.stdout).contains("track.new:"));
|
||||
assert!(!dir.0.join("malformed.scraft").exists());
|
||||
|
||||
std::fs::write(dir.0.join("parent"), "not a directory").unwrap();
|
||||
for (option, path) in [("--save", "parent/out.scraft"), ("--bounce", "parent/out.wav")] {
|
||||
let fail = invoke(&dir.0, &["script", "good.txt", "--keep-going", option, path]);
|
||||
assert_eq!(fail.status.code(), Some(1), "{option}: {}", String::from_utf8_lossy(&fail.stderr));
|
||||
assert!(String::from_utf8_lossy(&fail.stderr).contains("parent"));
|
||||
}
|
||||
|
||||
std::fs::write(dir.0.join("engine.txt"), "session.save {}\ntrack.new {\"name\":\"After engine failure\",\"count\":1}\n").unwrap();
|
||||
let fail = invoke(&dir.0, &["script", "engine.txt", "--keep-going", "--save", "engine.scraft"]);
|
||||
assert_eq!(fail.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&fail.stderr).contains("session.save"));
|
||||
assert!(String::from_utf8_lossy(&fail.stdout).contains("track.new:"));
|
||||
assert!(dir.0.join("engine.scraft").is_file());
|
||||
|
||||
let both_fail = invoke(&dir.0, &["script", "engine.txt", "--keep-going", "--save", "parent/out.scraft"]);
|
||||
assert_eq!(both_fail.status.code(), Some(1));
|
||||
let errors = String::from_utf8_lossy(&both_fail.stderr);
|
||||
assert!(errors.contains("session.save") && errors.contains("parent"), "{errors}");
|
||||
assert!(!errors.contains("script finished with command errors"), "{errors}");
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! 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!("unexpected argument '{bad}' found")),
|
||||
"{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!("a value is required for '{bad} ")),
|
||||
"{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("a value is required for '--bounce <OUT>'"), "{}", 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("unexpected argument '--bounces' found"), "{}", 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,10 +15,11 @@ path = "src/main.rs"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
soundcraft-engine = { workspace = true }
|
||||
soundcraft-ui-egui = { workspace = true }
|
||||
craft-audio.workspace = true
|
||||
soundcraft-ui-egui.workspace = true
|
||||
soundcraft-playback = { workspace = true }
|
||||
soundcraft-model = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -67,8 +67,6 @@ enum Purpose {
|
||||
}
|
||||
|
||||
const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION];
|
||||
const AUDIO_EXTS: &[&str] =
|
||||
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"];
|
||||
const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"];
|
||||
|
||||
impl Purpose {
|
||||
@@ -94,11 +92,11 @@ impl Purpose {
|
||||
}
|
||||
}
|
||||
|
||||
fn filter(self) -> (&'static str, &'static [&'static str]) {
|
||||
fn filter(self) -> (&'static str, Vec<&'static str>) {
|
||||
match self {
|
||||
Self::Session => ("SoundCraft Session", SESSION_EXTS),
|
||||
Self::Audio => ("Audio", AUDIO_EXTS),
|
||||
Self::Midi => ("MIDI", MIDI_EXTS),
|
||||
Self::Session => ("SoundCraft Session", SESSION_EXTS.to_vec()),
|
||||
Self::Audio => ("Audio", craft_audio::DECODE_EXTENSIONS.to_vec()),
|
||||
Self::Midi => ("MIDI", MIDI_EXTS.to_vec()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,18 +128,19 @@ impl WebShell {
|
||||
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 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(ctx);
|
||||
ctx.input_mut(|i| i.raw.dropped_files.clear());
|
||||
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 {
|
||||
@@ -208,8 +207,10 @@ impl eframe::App for WebShell {
|
||||
self.take_path_prompt(ctx);
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.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) {
|
||||
@@ -225,17 +226,15 @@ fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
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() {
|
||||
let ext = name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase());
|
||||
opts.set_type(match ext.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",
|
||||
ext => ext.and_then(craft_audio::encode_format_for).map_or("application/octet-stream", craft_audio::EncodeFormat::mime_type),
|
||||
});
|
||||
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)?;
|
||||
|
||||
@@ -16,12 +16,23 @@ asio = ["soundcraft-playback/asio"]
|
||||
|
||||
[dependencies]
|
||||
soundcraft-engine.workspace = true
|
||||
craft-audio.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
|
||||
soundcraft-automation.workspace = true
|
||||
clap.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(any(target_os = "macos", target_os = "linux"))))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Preserve X11 fallback while enabling font-free Wayland client-side controls.
|
||||
winit = { version = "0.30", default-features = false, features = ["x11", "wayland-csd-adwaita-notitle"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd.workspace = true
|
||||
@@ -31,6 +42,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 }
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
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";
|
||||
@@ -15,21 +17,13 @@ enum Platform {
|
||||
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> {
|
||||
/// SoundCraft's settings folder, resolved from the variable lookup `get` (see `Env::from_lookup`).
|
||||
/// A nonempty `$SOUNDCRAFT_CONFIG_DIR` overrides the platform default and is the complete
|
||||
/// directory; no application-name suffix is appended. An explicitly empty value means no settings
|
||||
/// folder instead of a fallback to a personal profile (the documented exception to RFC-0012 rule
|
||||
/// 1.7). 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 resolve_config_dir(get: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
let platform = if cfg!(target_os = "macos") {
|
||||
Platform::Mac
|
||||
} else if cfg!(windows) {
|
||||
@@ -37,11 +31,12 @@ fn resolve(exe_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
} else {
|
||||
Platform::Other
|
||||
};
|
||||
config_dir_with(platform, |name| std::env::var_os(name), exe_dir)
|
||||
let exe = std::env::current_exe().ok();
|
||||
config_dir_with(platform, get, exe.as_deref().and_then(Path::parent))
|
||||
}
|
||||
|
||||
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") {
|
||||
if let Some(dir) = env(crate::env::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()) {
|
||||
@@ -86,21 +81,21 @@ fn read_ui(path: &std::path::Path, recover_docking: bool) -> Result<Option<sound
|
||||
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}"))?;
|
||||
docking::reset_old_layouts(&mut saved);
|
||||
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::<soundcraft_ui_egui::docking::Workspace>(value.clone()) {
|
||||
Ok(workspace) => if mix { workspace.validate_mix() } else { workspace.validate() }.is_err(),
|
||||
Err(_) => 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}"))?;
|
||||
state.docking.validate()?;
|
||||
state.mix_docking.validate_mix()?;
|
||||
docking::validate(&state.docking)?;
|
||||
docking::validate_mix(&state.mix_docking)?;
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
@@ -111,11 +106,12 @@ pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Resu
|
||||
use std::io::Write as _;
|
||||
// Loading can recover a future layout for this session, but saving must preserve its source.
|
||||
read_ui(path, false)?;
|
||||
ui.docking.validate()?;
|
||||
ui.mix_docking.validate_mix()?;
|
||||
docking::validate(&ui.docking)?;
|
||||
docking::validate_mix(&ui.mix_docking)?;
|
||||
let mut saved = ui.clone();
|
||||
saved.plugin_windows.clear();
|
||||
saved.status.clear();
|
||||
saved.docking_version = docking::LAYOUT_VERSION;
|
||||
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());
|
||||
@@ -160,18 +156,18 @@ mod config_tests {
|
||||
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.panels.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]);
|
||||
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.panels.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]);
|
||||
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::Workspace::default();
|
||||
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());
|
||||
recovered.mix_docking.validate_mix().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();
|
||||
@@ -201,8 +197,8 @@ mod config_tests {
|
||||
let recovered = load_ui(&path).unwrap().unwrap();
|
||||
assert_eq!(recovered.theme, app.ui.theme);
|
||||
assert!(recovered.narrow_mix);
|
||||
recovered.docking.validate().unwrap();
|
||||
recovered.mix_docking.validate_mix().unwrap();
|
||||
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());
|
||||
@@ -212,6 +208,35 @@ mod config_tests {
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_from_before_dock_columns_load_with_the_default_layouts() {
|
||||
let directory = std::env::temp_dir().join(format!("soundcraft-split-frame-prefs-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
let path = directory.join("ui.json");
|
||||
let split_frames = serde_json::json!({"layout": {"root": {"Split": {
|
||||
"axis": "Horizontal",
|
||||
"size": {"FixedFirst": 200.0},
|
||||
"first": {"Tabs": {"panels": ["tracks"], "active": 0}},
|
||||
"second": {"Tabs": {"panels": ["canvas"], "active": 0}}
|
||||
}}, "floating": []}});
|
||||
let ui = soundcraft_ui_egui::UiState { theme: soundcraft_ui_egui::theme::ThemeMode::Light, narrow_mix: true, ..Default::default() };
|
||||
let mut saved = serde_json::to_value(&ui).unwrap();
|
||||
let fields = saved.as_object_mut().unwrap();
|
||||
fields.remove("docking_version");
|
||||
fields.insert("docking".into(), split_frames.clone());
|
||||
fields.insert("mix_docking".into(), split_frames);
|
||||
std::fs::write(&path, serde_json::to_vec(&saved).unwrap()).unwrap();
|
||||
let loaded = load_ui(&path).unwrap().unwrap();
|
||||
assert_eq!(loaded.docking, docking::edit_default());
|
||||
assert_eq!(loaded.mix_docking, docking::mix_default());
|
||||
assert_eq!(loaded.theme, soundcraft_ui_egui::theme::ThemeMode::Light);
|
||||
assert!(loaded.narrow_mix);
|
||||
save_ui(&path, &loaded).unwrap();
|
||||
let written: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(written["docking_version"], serde_json::json!(docking::LAYOUT_VERSION));
|
||||
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()));
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). The largest payload a request
|
||||
@@ -22,9 +22,15 @@ const MAX_LINE: usize = soundcraft_engine::model::b64::MAX_DECODED_BYTES.div_cei
|
||||
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) => {
|
||||
@@ -52,7 +58,9 @@ impl Drop for Slot {
|
||||
}
|
||||
}
|
||||
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
/// 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 {
|
||||
@@ -94,7 +102,9 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
/// 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;
|
||||
let mut reader = BufReader::new(read);
|
||||
@@ -119,15 +129,19 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
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}")}),
|
||||
};
|
||||
@@ -175,7 +189,7 @@ mod tests {
|
||||
fn serve_echo() -> std::net::SocketAddr {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! The environment variables the app reads, read once at startup into [`Env`]. Library crates
|
||||
//! never read these; `main` passes the values down. Platform variables (`HOME`, `DISPLAY`, ...)
|
||||
//! and test-only ones stay with the code that needs them.
|
||||
//!
|
||||
//! `RUST_LOG` is not a field: the logger reads it ([`rust_log`]) before anything else, so that
|
||||
//! the warnings [`Switch`] logs for a malformed value reach the log file.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::switch::Switch;
|
||||
|
||||
pub(crate) const CONFIG_DIR: &str = "SOUNDCRAFT_CONFIG_DIR";
|
||||
pub(crate) const NO_PREFS: &str = "SOUNDCRAFT_NO_PREFS";
|
||||
pub(crate) const NO_SYSTEM_FONTS: &str = "SOUNDCRAFT_NO_SYSTEM_FONTS";
|
||||
pub(crate) const WAYLAND: &str = "SOUNDCRAFT_WAYLAND";
|
||||
pub(crate) const RUST_LOG: &str = "RUST_LOG";
|
||||
|
||||
/// The variables and their `--help` lines, in the order `--help` lists them.
|
||||
pub(crate) const VARS: &[(&str, &str)] = &[
|
||||
// Read by clap (`env =` on `--control`), not by `Env`.
|
||||
("SOUNDCRAFT_CONTROL_PORT", "The --control fallback: a port or host:port, ignored for another host"),
|
||||
(CONFIG_DIR, "The settings folder (ui.json, Autosave, Presets, logs); empty: none (unlike the others, where empty counts as unset)"),
|
||||
(NO_PREFS, "1 doesn't read or write preferences and logs to stderr only; 0 or unset is off"),
|
||||
(NO_SYSTEM_FONTS, "1 uses the built-in fonts only; 0 or unset is off"),
|
||||
(WAYLAND, "Linux: 1 keeps the native Wayland backend instead of XWayland; 0 or unset is off"),
|
||||
(RUST_LOG, "Log filter, env_logger style: debug, warn,soundcraft_mix=trace"),
|
||||
];
|
||||
|
||||
/// The `--help` Environment section, generated from [`VARS`].
|
||||
pub(crate) fn help() -> String {
|
||||
let width = VARS.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
|
||||
let mut out = String::from("Environment:\n");
|
||||
for (name, line) in VARS {
|
||||
out.push_str(&format!(" {name:<width$} {line}\n"));
|
||||
}
|
||||
out.push_str(
|
||||
" The on/off variables take 1/on/true/yes or 0/off/false/no, in any case; any other value is\n ignored with a warning in the log.\n",
|
||||
);
|
||||
out.truncate(out.trim_end().len());
|
||||
out
|
||||
}
|
||||
|
||||
/// The app's variables, as read at startup.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct Env {
|
||||
/// The settings folder (ui.json, Autosave, Presets, logs), resolved once: `SOUNDCRAFT_CONFIG_DIR`,
|
||||
/// else the portable data folder, else the platform's. `None` when the variable is set but
|
||||
/// empty, or no platform folder is available. Every other variable treats empty as unset
|
||||
/// (RFC-0012 rule 1.7); this one doesn't.
|
||||
pub(crate) config_dir: Option<PathBuf>,
|
||||
pub(crate) no_prefs: Switch,
|
||||
pub(crate) no_system_fonts: Switch,
|
||||
#[cfg(any(test, all(unix, not(target_os = "macos"))))]
|
||||
pub(crate) wayland: Switch,
|
||||
}
|
||||
|
||||
impl Env {
|
||||
/// Reads the process environment. Call after the logger is installed, so malformed switches are logged.
|
||||
pub(crate) fn read() -> Env {
|
||||
Env::from_lookup(|name| std::env::var_os(name))
|
||||
}
|
||||
|
||||
pub(crate) fn from_lookup(get: impl Fn(&str) -> Option<OsString>) -> Env {
|
||||
let switch = |name: &str| match get(name) {
|
||||
None => Switch::Default,
|
||||
Some(v) => Switch::parse(name, Some(&v.to_string_lossy())),
|
||||
};
|
||||
Env {
|
||||
config_dir: crate::config::resolve_config_dir(&get),
|
||||
no_prefs: switch(NO_PREFS),
|
||||
no_system_fonts: switch(NO_SYSTEM_FONTS),
|
||||
#[cfg(any(test, all(unix, not(target_os = "macos"))))]
|
||||
wayland: switch(WAYLAND),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
|
||||
pub(crate) fn prefs_enabled(&self) -> bool {
|
||||
self.no_prefs != Switch::On
|
||||
}
|
||||
|
||||
pub(crate) fn system_fonts(&self) -> bool {
|
||||
self.no_system_fonts != Switch::On
|
||||
}
|
||||
|
||||
/// Whether to run through XWayland: winit has no file drag-and-drop on Wayland, so dropping
|
||||
/// files from the file manager showed a "no" cursor. Needs a nonempty `DISPLAY`
|
||||
/// (`display`); `SOUNDCRAFT_WAYLAND=1` keeps the native backend.
|
||||
#[cfg(any(test, all(unix, not(target_os = "macos"))))]
|
||||
pub(crate) fn use_xwayland(&self, display: Option<&OsString>) -> bool {
|
||||
display.is_some_and(|d| !d.is_empty()) && self.wayland != Switch::On
|
||||
}
|
||||
}
|
||||
|
||||
/// The `RUST_LOG` filter, `None` when unset or blank.
|
||||
pub(crate) fn rust_log() -> Option<String> {
|
||||
std::env::var(RUST_LOG).ok().filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn lookup(vars: &'static [(&'static str, &'static str)]) -> Env {
|
||||
Env::from_lookup(|name| vars.iter().find(|(n, _)| *n == name).map(|(_, v)| OsString::from(v)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_are_read_into_typed_fields() {
|
||||
let env = lookup(&[(CONFIG_DIR, "/c"), (NO_PREFS, "yes"), (NO_SYSTEM_FONTS, "0"), (WAYLAND, "bogus")]);
|
||||
assert_eq!(env.config_dir, Some(PathBuf::from("/c")));
|
||||
assert_eq!((env.no_prefs, env.no_system_fonts, env.wayland), (Switch::On, Switch::Off, Switch::Default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_folder_is_resolved_into_env() {
|
||||
// Empty means no folder, even with a platform folder available.
|
||||
assert_eq!(lookup(&[(CONFIG_DIR, ""), ("HOME", "/h"), ("XDG_CONFIG_HOME", "/x")]).config_dir, None);
|
||||
assert_eq!(lookup(&[(CONFIG_DIR, "/c"), ("HOME", "/h")]).config_dir, Some(PathBuf::from("/c")));
|
||||
let unset = lookup(&[("HOME", "/h"), ("APPDATA", "/h"), ("XDG_CONFIG_HOME", "/x")]).config_dir;
|
||||
assert!(unset.is_some_and(|d| d.ends_with("SoundCraft") || d.ends_with("soundcraft") || d.ends_with("SoundCraftData")));
|
||||
// Disabling preferences doesn't depend on the folder.
|
||||
let env = lookup(&[(CONFIG_DIR, "/c"), (NO_PREFS, "1")]);
|
||||
assert!(env.config_dir.is_some() && !env.prefs_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_prefs_and_no_system_fonts_turn_features_off() {
|
||||
assert!(Env::default().prefs_enabled() && Env::default().system_fonts());
|
||||
let env = Env { no_prefs: Switch::On, no_system_fonts: Switch::On, ..Env::default() };
|
||||
assert!(!env.prefs_enabled() && !env.system_fonts());
|
||||
assert!(Env { no_prefs: Switch::Off, ..Env::default() }.prefs_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xwayland_needs_a_display_and_wayland_not_forced() {
|
||||
let display = OsString::from(":0");
|
||||
assert!(Env::default().use_xwayland(Some(&display)));
|
||||
assert!(Env { wayland: Switch::Off, ..Env::default() }.use_xwayland(Some(&display)));
|
||||
assert!(!Env { wayland: Switch::On, ..Env::default() }.use_xwayland(Some(&display)));
|
||||
assert!(!Env::default().use_xwayland(None));
|
||||
assert!(!Env::default().use_xwayland(Some(&OsString::new())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_every_variable() {
|
||||
let help = help();
|
||||
for (name, line) in VARS {
|
||||
assert!(help.contains(name) && help.contains(line), "{name} missing from\n{help}");
|
||||
}
|
||||
assert_eq!(help.matches("0/off/false/no").count(), 1, "{help}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! The desktop app's command line, as clap types: `soundcraft --help` comes from these doc
|
||||
//! comments, so the help and the parser can't disagree. Anything they don't declare is a usage
|
||||
//! error naming it (exit status 2), as is a bad `--control` or `SOUNDCRAFT_CONTROL_PORT`. A file
|
||||
//! whose name starts with `-` goes after `--`. Another host's `host:port` is a usage error from
|
||||
//! `--control` and ignored from the variable ([`control_port`]).
|
||||
//!
|
||||
//! The control flag is `soundcraft-automation`'s, shared with `soundcraft-cli`.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::builder::StyledStr;
|
||||
use clap::error::{ContextKind, ContextValue, ErrorKind};
|
||||
use clap::parser::ValueSource;
|
||||
use clap::{Command, CommandFactory, FromArgMatches, Parser};
|
||||
use soundcraft_automation::ControlTarget;
|
||||
use soundcraft_automation::args::{ControlServerArgs, blank_env_is_unset, name_env_vars};
|
||||
|
||||
/// Opens SoundCraft with the given files
|
||||
#[derive(Parser)]
|
||||
#[command(name = "soundcraft", version = soundcraft_automation::args::long_version(), after_help = crate::env::help())]
|
||||
pub(crate) struct LaunchArgs {
|
||||
/// Sessions, audio and MIDI files to open. Put `--` before a name that starts with `-`
|
||||
pub(crate) files: Vec<PathBuf>,
|
||||
/// Start with the demo session
|
||||
#[arg(long, visible_alias = "sample")]
|
||||
pub(crate) demo: bool,
|
||||
/// Don't open an audio device
|
||||
#[arg(long)]
|
||||
pub(crate) no_audio: bool,
|
||||
#[command(flatten)]
|
||||
control: ControlServerArgs,
|
||||
/// The port the control server starts on, from `control` ([`control_port`]).
|
||||
#[arg(skip)]
|
||||
pub(crate) control_port: Option<u16>,
|
||||
}
|
||||
|
||||
/// The command line, where an empty or blank variable counts as unset.
|
||||
pub(crate) fn command() -> Command {
|
||||
LaunchArgs::command().mut_args(blank_env_is_unset)
|
||||
}
|
||||
|
||||
/// Parses the launch arguments (without the program name) with `cmd`, from [`command`]. macOS
|
||||
/// passes a process serial number (`-psn_…`) when an app is started from Finder on old versions:
|
||||
/// it is dropped first, unless it comes after `--`. A bad value from a variable is a usage error
|
||||
/// naming the variable.
|
||||
pub(crate) fn parse(mut cmd: Command, args: impl IntoIterator<Item = OsString>) -> Result<LaunchArgs, clap::Error> {
|
||||
let mut options = true;
|
||||
let args: Vec<OsString> = args
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
options &= a != "--";
|
||||
!(options && a.to_str().is_some_and(|s| s.starts_with("-psn_")))
|
||||
})
|
||||
.collect();
|
||||
let argv = std::iter::once(OsString::from("soundcraft")).chain(args.iter().cloned());
|
||||
let matches = cmd.try_get_matches_from_mut(argv).map_err(|e| name_env_vars(e, &cmd, &args))?;
|
||||
let mut launch = LaunchArgs::from_arg_matches(&matches)?;
|
||||
launch.control_port = control_port(launch.control.control.take(), matches.value_source("control")).map_err(|e| e.into_clap(&mut cmd))?;
|
||||
Ok(launch)
|
||||
}
|
||||
|
||||
/// The port the control server starts on, from `--control` or `SOUNDCRAFT_CONTROL_PORT`
|
||||
/// (`source`). The server listens on 127.0.0.1 only, so it can't serve another host's
|
||||
/// `host:port`. From the flag that is a usage error. The variable is `soundcraft-cli app`'s too and
|
||||
/// may point it at another machine, so from there it is ignored with a warning and the app opens
|
||||
/// without a control server.
|
||||
fn control_port(value: Option<ControlTarget>, source: Option<ValueSource>) -> Result<Option<u16>, RemoteHost> {
|
||||
match value {
|
||||
None => Ok(None),
|
||||
Some(target) if target.is_loopback() => Ok(Some(target.port)),
|
||||
Some(target) if source == Some(ValueSource::EnvVariable) => {
|
||||
log::warn!("SOUNDCRAFT_CONTROL_PORT={target} names another host, so no control server starts: it listens on 127.0.0.1 only");
|
||||
Ok(None)
|
||||
}
|
||||
Some(target) => Err(RemoteHost(target)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `--control` `host:port` naming another host.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct RemoteHost(ControlTarget);
|
||||
|
||||
impl RemoteHost {
|
||||
/// clap's invalid-value error for `--control`, with the reason as a tip.
|
||||
fn into_clap(self, cmd: &mut Command) -> clap::Error {
|
||||
let flag = cmd.get_arguments().find(|a| a.get_id() == "control").map_or_else(|| "--control".to_owned(), ToString::to_string);
|
||||
let tip =
|
||||
StyledStr::from("the control server listens on 127.0.0.1 only: give a port, or host:port with a loopback host (127.0.0.1, localhost)");
|
||||
let mut error = clap::Error::new(ErrorKind::ValueValidation).with_cmd(cmd);
|
||||
error.insert(ContextKind::InvalidArg, ContextValue::String(flag));
|
||||
error.insert(ContextKind::InvalidValue, ContextValue::String(self.0.to_string()));
|
||||
error.insert(ContextKind::Suggested, ContextValue::StyledStrs(vec![tip]));
|
||||
error.insert(ContextKind::Usage, ContextValue::StyledStr(cmd.render_usage()));
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::error::ErrorKind;
|
||||
|
||||
/// Parse without the variable, so one exported where the tests run can't change them (the
|
||||
/// process tests in `tests/launch_flags.rs` cover it).
|
||||
fn parse_args(args: &[&str]) -> Result<LaunchArgs, clap::Error> {
|
||||
parse(command().mut_args(|a| a.env(None::<&str>)), args.iter().map(OsString::from))
|
||||
}
|
||||
|
||||
fn usage_error(args: &[&str]) -> String {
|
||||
let Err(e) = parse_args(args) else { panic!("{args:?} parsed") };
|
||||
assert_eq!(e.exit_code(), 2, "{args:?}: {e}");
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_is_consistent() {
|
||||
command().debug_assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_and_files_are_parsed() {
|
||||
let a = parse_args(&["--demo", "--no-audio", "--control", "50494", "a.scraft", "b.wav"]).unwrap();
|
||||
assert!(a.demo && a.no_audio);
|
||||
assert_eq!(a.control_port, Some(50494));
|
||||
assert_eq!(a.files, [PathBuf::from("a.scraft"), PathBuf::from("b.wav")]);
|
||||
assert!(parse_args(&["--sample"]).unwrap().demo);
|
||||
let none = parse_args(&[]).unwrap();
|
||||
assert!(!none.demo && !none.no_audio && none.control_port.is_none() && none.files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_and_help_stop_parsing() {
|
||||
for flag in ["--version", "-V"] {
|
||||
let e = parse_args(&[flag, "--control"]).err().unwrap();
|
||||
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayVersion, 0));
|
||||
assert_eq!(e.to_string(), format!("soundcraft {}\n", soundcraft_automation::args::long_version()));
|
||||
}
|
||||
let e = parse_args(&["-h", "--bogus"]).err().unwrap();
|
||||
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayHelp, 0));
|
||||
let help = e.to_string();
|
||||
for text in ["--demo", "--sample", "--no-audio", "--control <PORT>"] {
|
||||
assert!(help.contains(text), "{text}: {help}");
|
||||
}
|
||||
let help = command().render_help().to_string();
|
||||
assert!(help.contains("[env: SOUNDCRAFT_CONTROL_PORT"), "{help}");
|
||||
for var in ["SOUNDCRAFT_CONFIG_DIR", "SOUNDCRAFT_NO_PREFS", "SOUNDCRAFT_NO_SYSTEM_FONTS", "SOUNDCRAFT_WAYLAND", "RUST_LOG"] {
|
||||
assert!(help.contains(&format!("\n {var} ")), "{var}: {help}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_control_port_is_a_usage_error_naming_it() {
|
||||
assert!(usage_error(&["--control", "78787"]).contains("invalid value '78787' for '--control <PORT>'"));
|
||||
assert!(usage_error(&["--control", "nope"]).contains("invalid value 'nope'"));
|
||||
assert!(usage_error(&["--control"]).contains("a value is required for '--control <PORT>'"));
|
||||
assert!(usage_error(&["--control", "--demo"]).contains("a value is required for '--control <PORT>'"));
|
||||
assert!(usage_error(&["--control", "-1"]).contains("invalid value '-1' for '--control <PORT>'"));
|
||||
let remote = usage_error(&["--control", "10.0.0.5:7979"]);
|
||||
assert!(
|
||||
remote.starts_with("error: invalid value '10.0.0.5:7979' for '--control <PORT>'\n\n tip: the control server listens on 127.0.0.1 only"),
|
||||
"{remote}"
|
||||
);
|
||||
assert!(remote.contains("\nUsage: soundcraft "), "{remote}");
|
||||
assert!(usage_error(&["--control", "::1:7979"]).contains("invalid value '::1:7979' for '--control <PORT>'"));
|
||||
}
|
||||
|
||||
/// The variable may point `soundcraft-cli app` at another machine: the app then opens without
|
||||
/// a control server.
|
||||
#[test]
|
||||
fn another_host_is_ignored_only_from_the_variable() {
|
||||
let remote = || ControlTarget { host: Some("10.0.0.5".to_owned()), port: 7979 };
|
||||
assert_eq!(control_port(Some(remote()), Some(ValueSource::EnvVariable)), Ok(None));
|
||||
assert_eq!(control_port(Some(remote()), Some(ValueSource::CommandLine)), Err(RemoteHost(remote())));
|
||||
let local = ControlTarget { host: None, port: 7979 };
|
||||
assert_eq!(control_port(Some(local), Some(ValueSource::EnvVariable)), Ok(Some(7979)));
|
||||
assert_eq!(control_port(None, None), Ok(None));
|
||||
}
|
||||
|
||||
/// The variable `soundcraft-cli app` reads may hold `host:port`: a loopback one gives its port.
|
||||
#[test]
|
||||
fn a_loopback_host_and_port_gives_its_port() {
|
||||
for value in ["127.0.0.1:50494", "localhost:50494"] {
|
||||
assert_eq!(parse_args(&["--control", value]).unwrap().control_port, Some(50494), "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The control server binds 127.0.0.1, so `[::1]` is another host.
|
||||
#[test]
|
||||
fn an_ipv6_loopback_host_is_not_loopback() {
|
||||
assert!(usage_error(&["--control", "[::1]:50494"]).contains("invalid value '[::1]:50494' for '--control <PORT>'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_flags_and_repeats_are_usage_errors() {
|
||||
assert!(usage_error(&["--bogus"]).contains("unexpected argument '--bogus'"));
|
||||
assert!(usage_error(&["-x"]).contains("unexpected argument '-x'"));
|
||||
assert!(usage_error(&["--demo", "--demo"]).contains("cannot be used multiple times"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_dash_ends_options() {
|
||||
let a = parse_args(&["a.wav", "--", "--demo", "-x"]).unwrap();
|
||||
assert!(!a.demo);
|
||||
assert_eq!(a.files, [PathBuf::from("a.wav"), PathBuf::from("--demo"), PathBuf::from("-x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finder_process_serial_number_is_dropped() {
|
||||
assert_eq!(parse_args(&["-psn_0_12345", "a.wav"]).unwrap().files, [PathBuf::from("a.wav")]);
|
||||
assert_eq!(parse_args(&["--", "-psn_x.wav"]).unwrap().files, [PathBuf::from("-psn_x.wav")]);
|
||||
}
|
||||
|
||||
/// A file name that isn't valid Unicode reaches `files` as it is.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_file_names_are_kept() {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
let latin1 = OsString::from_vec(b"caf\xe9.wav".to_vec());
|
||||
let a = parse(command().mut_args(|a| a.env(None::<&str>)), [latin1.clone()]).unwrap();
|
||||
assert_eq!(a.files, [PathBuf::from(latin1)]);
|
||||
}
|
||||
}
|
||||
+30
-396
@@ -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 {
|
||||
@@ -405,13 +413,12 @@ impl log::Log for AppLogger {
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
|
||||
/// Install the logger (filter from `rust_log`, the `RUST_LOG` value, else [`DEFAULT_FILTER`]); `None` when another
|
||||
/// logger was installed first. Call [`AppLogger::attach_dir`] or [`AppLogger::no_file`] once the
|
||||
/// settings directory is known.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
pub fn install(rust_log: Option<&str>) -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
||||
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
|
||||
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
|
||||
let filter = Filter::parse(rust_log.unwrap_or(DEFAULT_FILTER));
|
||||
let max = filter.max();
|
||||
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
||||
log::set_logger(logger).ok()?;
|
||||
@@ -420,377 +427,4 @@ pub fn install() -> Option<&'static AppLogger> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use log::Log;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("soundcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &str) {
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
}
|
||||
|
||||
/// Run `f` on a thread the playback crate marks as the realtime audio thread.
|
||||
fn on_audio_thread<T: Send>(f: impl FnOnce() -> T + Send) -> T {
|
||||
std::thread::scope(|s| {
|
||||
s.spawn(|| {
|
||||
soundcraft_playback::mark_audio_thread();
|
||||
f()
|
||||
})
|
||||
.join()
|
||||
.expect("audio thread")
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_soundcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("soundcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("soundcraft::control_server"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("soundcraft_playback"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("soundcraft_clap_host::ffi"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("cpal"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,soundcraft_mix=trace,soundcraft_mix::pdc=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("soundcraft_mix::render"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("soundcraft_mix::pdc"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("warn,soundcraft_ui*=debug").level_for("soundcraft_ui_egui::mix_window"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("warn,soundcraft_ui*=debug").level_for("soundcraft_engine"), LevelFilter::Warn);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive; the last bare level wins.
|
||||
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "a".repeat(100_000);
|
||||
let specs = [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"soundcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=debug",
|
||||
"a=b=c",
|
||||
"warn,,soundcraft=DEBUG",
|
||||
" \t ",
|
||||
"\u{0}",
|
||||
"é*=trace",
|
||||
&long,
|
||||
];
|
||||
for spec in specs {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["soundcraft", "", "🦀", "é", "a::b"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,soundcraft=DEBUG").level_for("soundcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
|
||||
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
|
||||
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("soundcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("soundcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("soundcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
assert!(rotate(&dir.join("below")).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line(
|
||||
"2026-10-08T07:59:17.728Z",
|
||||
log::Level::Warn,
|
||||
"main",
|
||||
"soundcraft_mix",
|
||||
"eq_7band: the plugin did not accept its stored state"
|
||||
),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] soundcraft_mix: eq_7band: the plugin did not accept its stored state\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_max_pending_lines_drop_nothing() {
|
||||
let dir = temp_dir("exact");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
record(&logger, log::Level::Info, "soundcraft", "SoundCraft starting");
|
||||
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(&logger, log::Level::Debug, "soundcraft", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("soundcraft: SoundCraft starting")), "{text}");
|
||||
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("soundcraft_engine").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// A second launch rotates: the first log becomes `.1`.
|
||||
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
|
||||
assert_eq!(read(&dir.join("soundcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
let dir = temp_dir("threads");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
std::thread::scope(|s| {
|
||||
for _ in 0..4 {
|
||||
s.spawn(|| {
|
||||
for _ in 0..100 {
|
||||
record(&logger, log::Level::Info, "soundcraft", "from a worker");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 400);
|
||||
assert!(text.lines().all(|l| l.ends_with("soundcraft: from a worker")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
record(&logger, log::Level::Info, "soundcraft", "kept for a file");
|
||||
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
|
||||
assert!(e.contains(&dir.display().to_string()), "{e}");
|
||||
record(&logger, log::Level::Error, "x", "still fine");
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
|
||||
drop(sink);
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_without_a_log_file_keeps_nothing_for_one() {
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
record(&logger, log::Level::Info, "soundcraft", "early");
|
||||
logger.no_file();
|
||||
record(&logger, log::Level::Info, "soundcraft", "late");
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.file.is_none() && sink.pending.is_empty() && sink.dropped == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_lock_does_not_stop_logging() {
|
||||
let dir = temp_dir("poison");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _guard = logger.sink.lock();
|
||||
panic!("poison the sink");
|
||||
}));
|
||||
assert!(logger.sink.is_poisoned());
|
||||
record(&logger, log::Level::Info, "soundcraft", "after the panic");
|
||||
assert!(read(&path).contains("after the panic"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// Realtime audio: the audio thread never takes the sink lock or touches the file.
|
||||
|
||||
#[test]
|
||||
fn an_audio_thread_record_never_waits_for_the_sink() {
|
||||
let dir = temp_dir("rt-nowait");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
// Another thread holds the sink (a slow disk, say) while the audio thread logs.
|
||||
let held = logger.sink.lock().expect("sink");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::scope(|s| {
|
||||
s.spawn(|| {
|
||||
soundcraft_playback::mark_audio_thread();
|
||||
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: device unplugged");
|
||||
let _ = tx.send(());
|
||||
});
|
||||
let done = rx.recv_timeout(Duration::from_secs(10)).is_ok();
|
||||
drop(held);
|
||||
assert!(done, "the audio thread blocked on the sink lock");
|
||||
});
|
||||
assert_eq!(read(&path), "", "nothing is written from the audio thread itself");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_audio_thread_record_is_written_later_with_a_count_of_the_rest() {
|
||||
let dir = temp_dir("rt-report");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
on_audio_thread(|| {
|
||||
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: device unplugged");
|
||||
for _ in 0..41 {
|
||||
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: still unplugged");
|
||||
}
|
||||
// Filtered out: neither kept nor counted.
|
||||
record(&logger, log::Level::Debug, "soundcraft_clap_host::plugin", "process failed");
|
||||
});
|
||||
assert_eq!(read(&path), "");
|
||||
logger.report_audio_thread();
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines.first().is_some_and(|l| l.contains(" WARN [audio] soundcraft_playback: audio stream error: device unplugged")), "{text}");
|
||||
assert!(lines.get(1).is_some_and(|l| l.contains("41 more log records from the realtime audio thread were not written")), "{text}");
|
||||
// Reported once.
|
||||
logger.report_audio_thread();
|
||||
assert_eq!(read(&path), text);
|
||||
// The next record from any other thread reports what the audio thread kept since, first.
|
||||
on_audio_thread(|| record(&logger, log::Level::Error, "soundcraft_clap_host::ffi", "clap plugin: out of voices"));
|
||||
record(&logger, log::Level::Info, "soundcraft", "UI thread");
|
||||
let text = read(&path);
|
||||
let tail: Vec<&str> = text.lines().skip(2).collect();
|
||||
assert_eq!(tail.len(), 2, "{text}");
|
||||
assert!(tail.first().is_some_and(|l| l.contains(" ERROR [audio] soundcraft_clap_host::ffi: clap plugin: out of voices")), "{text}");
|
||||
assert!(tail.get(1).is_some_and(|l| l.ends_with("soundcraft: UI thread")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_audio_thread_record_is_cut_at_a_character_boundary() {
|
||||
let dir = temp_dir("rt-long");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let long = "é".repeat(REALTIME_MESSAGE_BYTES);
|
||||
on_audio_thread(|| record(&logger, log::Level::Warn, "soundcraft", &long));
|
||||
logger.report_audio_thread();
|
||||
let text = read(&path);
|
||||
let message = text.split("soundcraft: ").nth(1).expect("message");
|
||||
assert!(message.ends_with("…\n"), "{text}");
|
||||
assert!(message.len() <= REALTIME_MESSAGE_BYTES + "…\n".len(), "{}", message.len());
|
||||
assert!(message.trim_end_matches(['…', '\n']).chars().all(|c| c == 'é'));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
use super::*;
|
||||
use log::Log;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("soundcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &str) {
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
}
|
||||
|
||||
/// Run `f` on a thread the playback crate marks as the realtime audio thread.
|
||||
fn on_audio_thread<T: Send>(f: impl FnOnce() -> T + Send) -> T {
|
||||
std::thread::scope(|s| {
|
||||
s.spawn(|| {
|
||||
soundcraft_playback::mark_audio_thread();
|
||||
f()
|
||||
})
|
||||
.join()
|
||||
.expect("audio thread")
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_soundcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("soundcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("soundcraft::control_server"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("soundcraft_playback"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("soundcraft_clap_host::ffi"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("cpal"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,soundcraft_mix=trace,soundcraft_mix::pdc=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("soundcraft_mix::render"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("soundcraft_mix::pdc"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("warn,soundcraft_ui*=debug").level_for("soundcraft_ui_egui::mix_window"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("warn,soundcraft_ui*=debug").level_for("soundcraft_engine"), LevelFilter::Warn);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive; the last bare level wins.
|
||||
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "a".repeat(100_000);
|
||||
let specs = [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"soundcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=debug",
|
||||
"a=b=c",
|
||||
"warn,,soundcraft=DEBUG",
|
||||
" \t ",
|
||||
"\u{0}",
|
||||
"é*=trace",
|
||||
&long,
|
||||
];
|
||||
for spec in specs {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["soundcraft", "", "🦀", "é", "a::b"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,soundcraft=DEBUG").level_for("soundcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
|
||||
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
|
||||
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
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]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("soundcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("soundcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("soundcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
assert!(rotate(&dir.join("below")).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "soundcraft_mix", "eq_7band: the plugin did not accept its stored state"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] soundcraft_mix: eq_7band: the plugin did not accept its stored state\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_max_pending_lines_drop_nothing() {
|
||||
let dir = temp_dir("exact");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
record(&logger, log::Level::Info, "soundcraft", "SoundCraft starting");
|
||||
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(&logger, log::Level::Debug, "soundcraft", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("soundcraft: SoundCraft starting")), "{text}");
|
||||
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("soundcraft_engine").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// A second launch rotates: the first log becomes `.1`.
|
||||
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
|
||||
assert_eq!(read(&dir.join("soundcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
let dir = temp_dir("threads");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
std::thread::scope(|s| {
|
||||
for _ in 0..4 {
|
||||
s.spawn(|| {
|
||||
for _ in 0..100 {
|
||||
record(&logger, log::Level::Info, "soundcraft", "from a worker");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 400);
|
||||
assert!(text.lines().all(|l| l.ends_with("soundcraft: from a worker")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
record(&logger, log::Level::Info, "soundcraft", "kept for a file");
|
||||
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
|
||||
assert!(e.contains(&dir.display().to_string()), "{e}");
|
||||
record(&logger, log::Level::Error, "x", "still fine");
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
|
||||
drop(sink);
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_without_a_log_file_keeps_nothing_for_one() {
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
record(&logger, log::Level::Info, "soundcraft", "early");
|
||||
logger.no_file();
|
||||
record(&logger, log::Level::Info, "soundcraft", "late");
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.file.is_none() && sink.pending.is_empty() && sink.dropped == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_lock_does_not_stop_logging() {
|
||||
let dir = temp_dir("poison");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _guard = logger.sink.lock();
|
||||
panic!("poison the sink");
|
||||
}));
|
||||
assert!(logger.sink.is_poisoned());
|
||||
record(&logger, log::Level::Info, "soundcraft", "after the panic");
|
||||
assert!(read(&path).contains("after the panic"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// Realtime audio: the audio thread never takes the sink lock or touches the file.
|
||||
|
||||
#[test]
|
||||
fn an_audio_thread_record_never_waits_for_the_sink() {
|
||||
let dir = temp_dir("rt-nowait");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
// Another thread holds the sink (a slow disk, say) while the audio thread logs.
|
||||
let held = logger.sink.lock().expect("sink");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::scope(|s| {
|
||||
s.spawn(|| {
|
||||
soundcraft_playback::mark_audio_thread();
|
||||
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: device unplugged");
|
||||
let _ = tx.send(());
|
||||
});
|
||||
let done = rx.recv_timeout(Duration::from_secs(10)).is_ok();
|
||||
drop(held);
|
||||
assert!(done, "the audio thread blocked on the sink lock");
|
||||
});
|
||||
assert_eq!(read(&path), "", "nothing is written from the audio thread itself");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_audio_thread_record_is_written_later_with_a_count_of_the_rest() {
|
||||
let dir = temp_dir("rt-report");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
on_audio_thread(|| {
|
||||
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: device unplugged");
|
||||
for _ in 0..41 {
|
||||
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: still unplugged");
|
||||
}
|
||||
// Filtered out: neither kept nor counted.
|
||||
record(&logger, log::Level::Debug, "soundcraft_clap_host::plugin", "process failed");
|
||||
});
|
||||
assert_eq!(read(&path), "");
|
||||
logger.report_audio_thread();
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines.first().is_some_and(|l| l.contains(" WARN [audio] soundcraft_playback: audio stream error: device unplugged")), "{text}");
|
||||
assert!(lines.get(1).is_some_and(|l| l.contains("41 more log records from the realtime audio thread were not written")), "{text}");
|
||||
// Reported once.
|
||||
logger.report_audio_thread();
|
||||
assert_eq!(read(&path), text);
|
||||
// The next record from any other thread reports what the audio thread kept since, first.
|
||||
on_audio_thread(|| record(&logger, log::Level::Error, "soundcraft_clap_host::ffi", "clap plugin: out of voices"));
|
||||
record(&logger, log::Level::Info, "soundcraft", "UI thread");
|
||||
let text = read(&path);
|
||||
let tail: Vec<&str> = text.lines().skip(2).collect();
|
||||
assert_eq!(tail.len(), 2, "{text}");
|
||||
assert!(tail.first().is_some_and(|l| l.contains(" ERROR [audio] soundcraft_clap_host::ffi: clap plugin: out of voices")), "{text}");
|
||||
assert!(tail.get(1).is_some_and(|l| l.ends_with("soundcraft: UI thread")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_audio_thread_record_is_cut_at_a_character_boundary() {
|
||||
let dir = temp_dir("rt-long");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let long = "é".repeat(REALTIME_MESSAGE_BYTES);
|
||||
on_audio_thread(|| record(&logger, log::Level::Warn, "soundcraft", &long));
|
||||
logger.report_audio_thread();
|
||||
let text = read(&path);
|
||||
let message = text.split("soundcraft: ").nth(1).expect("message");
|
||||
assert!(message.ends_with("…\n"), "{text}");
|
||||
assert!(message.len() <= REALTIME_MESSAGE_BYTES + "…\n".len(), "{}", message.len());
|
||||
assert!(message.trim_end_matches(['…', '\n']).chars().all(|c| c == 'é'));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
+72
-86
@@ -1,6 +1,6 @@
|
||||
//! SoundCraft desktop app.
|
||||
//!
|
||||
//! Usage: `soundcraft [--demo] [--control <port>] [--no-audio] [session.scraft | audio files…]`
|
||||
//! Usage: `soundcraft [--demo] [--control <port>] [--no-audio] [session.scraft | audio files…]`; `soundcraft --help` lists the flags and `SOUNDCRAFT_CONTROL_PORT`.
|
||||
//!
|
||||
//! `--control <port>` (or `SOUNDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server
|
||||
//! (`0` picks a free port and prints it): `{"id":1,"method":"ui.inspect","params":{}}` →
|
||||
@@ -13,21 +13,25 @@
|
||||
|
||||
mod config;
|
||||
mod control_server;
|
||||
mod env;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod launch_args;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod switch;
|
||||
|
||||
use env::Env;
|
||||
use soundcraft_engine::Engine;
|
||||
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>);
|
||||
struct App(SoundApp, Option<&'static logging::AppLogger>, Env, #[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::new("window_title");
|
||||
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));
|
||||
@@ -35,26 +39,28 @@ fn set_title(ctx: &egui::Context, title: String) {
|
||||
}
|
||||
|
||||
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 {
|
||||
logger.report_audio_thread();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(menu) = &mut self.2 {
|
||||
if let Some(menu) = &mut self.3 {
|
||||
menu.process(&mut self.0);
|
||||
}
|
||||
self.0.guarded(|app| app.logic(ctx));
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(menu) = &mut self.2 {
|
||||
if let Some(menu) = &mut self.3 {
|
||||
menu.sync(&self.0);
|
||||
}
|
||||
// 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> =
|
||||
self.0.take_dropped_files(ctx).iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect();
|
||||
for p in dropped {
|
||||
open_path(&mut self.0, &p);
|
||||
for f in self.0.take_dropped_files() {
|
||||
if !f.path().as_os_str().is_empty() {
|
||||
dialogs::open_path(&mut self.0, f.path());
|
||||
}
|
||||
}
|
||||
if self.0.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
@@ -70,7 +76,7 @@ impl eframe::App for App {
|
||||
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);
|
||||
save_prefs(&self.2, &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.
|
||||
@@ -86,59 +92,39 @@ impl eframe::App for App {
|
||||
};
|
||||
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.guarded(|app| app.ui(ui));
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
self.0.close_autosave();
|
||||
save_prefs(&self.0.ui);
|
||||
save_prefs(&self.2, &self.0.ui);
|
||||
if let Some(logger) = self.1 {
|
||||
logger.report_audio_thread();
|
||||
}
|
||||
// The system Quit (⌘Q) ends the process from inside the event loop, so `run_native` never
|
||||
// returns: release the player's plugin instances and shut plugin hosting down here.
|
||||
// returns: release the player's plugin instances and shut plugin hosting down here, and
|
||||
// start the helper of an update that waits for the app to close.
|
||||
self.0.player = None;
|
||||
soundcraft_engine::shutdown_plugin_hosts();
|
||||
self.0.start_pending_update();
|
||||
}
|
||||
}
|
||||
|
||||
fn open_path(app: &mut SoundApp, p: &str) {
|
||||
let lower = p.to_ascii_lowercase();
|
||||
let r = if lower.ends_with(".scraft") {
|
||||
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 {
|
||||
let at = app.engine.session().edit.selection.start;
|
||||
app.run("file.import_audio", serde_json::json!({"path": p, "at": at}))
|
||||
};
|
||||
if let Err(e) = r {
|
||||
log::warn!("{p}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-user settings directory: `ui.json`, `Autosave/`, `Presets/` and `logs/` live here.
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
config::config_dir()
|
||||
}
|
||||
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
config_dir().map(|b| b.join("ui.json"))
|
||||
fn prefs_path(env: &Env) -> Option<std::path::PathBuf> {
|
||||
env.config_dir.as_ref().map(|b| b.join("ui.json"))
|
||||
}
|
||||
|
||||
/// Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
|
||||
fn prefs_enabled() -> bool {
|
||||
std::env::var_os("SOUNDCRAFT_NO_PREFS").is_none()
|
||||
}
|
||||
|
||||
fn load_prefs() -> Option<UiState> {
|
||||
if !prefs_enabled() {
|
||||
fn load_prefs(env: &Env) -> Option<UiState> {
|
||||
if !env.prefs_enabled() {
|
||||
return None;
|
||||
}
|
||||
let path = prefs_path()?;
|
||||
let path = prefs_path(env)?;
|
||||
match config::load_ui(&path) {
|
||||
Ok(state) => state,
|
||||
Err(error) => {
|
||||
@@ -148,11 +134,11 @@ fn load_prefs() -> Option<UiState> {
|
||||
}
|
||||
}
|
||||
|
||||
fn save_prefs(ui: &UiState) {
|
||||
if !prefs_enabled() {
|
||||
fn save_prefs(env: &Env, ui: &UiState) {
|
||||
if !env.prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
if let Some(path) = prefs_path()
|
||||
if let Some(path) = prefs_path(env)
|
||||
&& let Err(error) = config::save_ui(&path, ui)
|
||||
{
|
||||
log::warn!("{error}");
|
||||
@@ -166,30 +152,24 @@ fn services() -> Services {
|
||||
let d = match purpose {
|
||||
"session.open" => d.add_filter("SoundCraft Session", &["scraft"]),
|
||||
"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", "m4b", "aac", "mp4", "caf", "mkv", "webm"],
|
||||
),
|
||||
_ => d.add_filter("Audio", craft_audio::DECODE_EXTENSIONS),
|
||||
};
|
||||
d.pick_file().map(|p| p.to_string_lossy().into_owned())
|
||||
d.pick_file()
|
||||
})),
|
||||
pick_save: Some(Box::new(|purpose: &str, default: &str| {
|
||||
let path = std::path::Path::new(default);
|
||||
pick_save: Some(Box::new(|purpose: &str, path: &std::path::Path| {
|
||||
let mut d = match purpose {
|
||||
"file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", &["wav", "aif", "aiff", "flac"]),
|
||||
"file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", craft_audio::ENCODE_EXTENSIONS),
|
||||
_ => 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(|n| n.to_str()) {
|
||||
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())
|
||||
d.save_file()
|
||||
})),
|
||||
pick_folder: Some(Box::new(|start: &std::path::Path| rfd::FileDialog::new().set_directory(start).pick_folder())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,36 +184,29 @@ 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(&soundcraft_ui_egui::UPDATE_APP, &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();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
||||
println!("SoundCraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
let demo = args.iter().any(|a| a == "--demo" || a == "--sample");
|
||||
let no_audio = args.iter().any(|a| a == "--no-audio");
|
||||
let control_port: Option<u16> = args
|
||||
.iter()
|
||||
.position(|a| a == "--control")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|p| p.parse().ok())
|
||||
.or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()));
|
||||
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"))))
|
||||
.map(|(_, a)| a.clone())
|
||||
.collect();
|
||||
log::info!("SoundCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
|
||||
let logger = logging::install(env::rust_log().as_deref());
|
||||
// Help, version and usage errors print and exit here, before a window or a log file exists.
|
||||
let launch = launch_args::parse(launch_args::command(), std::env::args_os().skip(1)).unwrap_or_else(|e| e.exit());
|
||||
// After the arguments and the logger: reading resolves the settings folder, which creates it,
|
||||
// and a malformed switch is logged.
|
||||
let env = Env::read();
|
||||
let (demo, no_audio, control_port) = (launch.demo, launch.no_audio, launch.control_port);
|
||||
let files = launch.files;
|
||||
log::info!("SoundCraft {}", soundcraft_automation::args::long_version());
|
||||
// The log file lives in the settings directory; opened after the arguments, so `--version`
|
||||
// leaves no file behind. Records logged until now are written to it first. Runs without
|
||||
// preferences (agents' test runs) log to standard error only, so they don't rotate away the
|
||||
// user's own logs.
|
||||
if let Some(logger) = logger {
|
||||
match config_dir() {
|
||||
_ if !prefs_enabled() => logger.no_file(),
|
||||
match env.config_dir.as_deref() {
|
||||
_ if !env.prefs_enabled() => logger.no_file(),
|
||||
Some(dir) => match logger.attach_dir(&dir.join(logging::LOG_DIR)) {
|
||||
Ok(path) => log::info!("log file {}", path.display()),
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
@@ -246,7 +219,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
|
||||
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
|
||||
let prefs = load_prefs().unwrap_or_default();
|
||||
let prefs = load_prefs(&env).unwrap_or_default();
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("SoundCraft")
|
||||
@@ -263,6 +236,16 @@ fn main() -> eframe::Result {
|
||||
// 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(), 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 env.use_xwayland(std::env::var_os("DISPLAY").as_ref()) {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
eframe::run_native(
|
||||
"SoundCraft",
|
||||
options,
|
||||
@@ -270,9 +253,11 @@ fn main() -> eframe::Result {
|
||||
let player =
|
||||
if no_audio { None } else { Some(soundcraft_playback::Player::with_config(Arc::new(engine.session().clone()), &prefs.audio)) };
|
||||
let mut app = SoundApp::new(engine, player, services());
|
||||
app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave")));
|
||||
app.set_startup_update_check(true);
|
||||
app.set_system_fonts(env.system_fonts());
|
||||
app.autosave_dir = env.config_dir.as_ref().map(|d| d.join("Autosave"));
|
||||
app.leftovers = app.autosave_dir.as_deref().map(soundcraft_engine::autosave::leftovers).unwrap_or_default();
|
||||
app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets")));
|
||||
app.preset_dir = env.config_dir.as_ref().map(|d| d.join("Presets"));
|
||||
app.extra.ui_scale = prefs.ui_scale;
|
||||
app.ui = prefs;
|
||||
if let Some(port) = control_port {
|
||||
@@ -280,7 +265,7 @@ fn main() -> eframe::Result {
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
for f in &files {
|
||||
open_path(&mut app, f);
|
||||
dialogs::open_path(&mut app, f);
|
||||
}
|
||||
// If the system menu bar cannot be built, keep the egui menu bar rather than fail to start.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -297,6 +282,7 @@ fn main() -> eframe::Result {
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
logger,
|
||||
env,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu,
|
||||
)))
|
||||
|
||||
@@ -33,6 +33,7 @@ impl NativeMenu {
|
||||
let mut native = Self { menu, entries: Vec::new(), events, last_sync: None };
|
||||
let app_menu = Submenu::new("SoundCraft", true);
|
||||
native.add_item(app, &app_menu, "About SoundCraft", "", Some("window.about".into()))?;
|
||||
native.add_item(app, &app_menu, "Preferences...", "", Some("setup.preferences".into()))?;
|
||||
native.add_item(app, &app_menu, "Session Info", "", Some("window.session_info".into()))?;
|
||||
native.add_item(app, &app_menu, "Session Audio Health", "", Some("window.audio_health".into()))?;
|
||||
app_menu.append(&PredefinedMenuItem::separator())?;
|
||||
@@ -94,8 +95,8 @@ impl NativeMenu {
|
||||
&& let Some(command) = &entry.command
|
||||
&& menus::item_state(app, &entry.path, Some(command)).0
|
||||
{
|
||||
if entry.path.is_empty() && command.starts_with("window.") {
|
||||
// The application menu's About / Session Info open their window (never close it).
|
||||
if entry.path.is_empty() && (command.starts_with("window.") || command == "setup.preferences") {
|
||||
// Application-menu window entries open their window rather than toggling it.
|
||||
let _ = app.run(command, serde_json::json!({"value": true}));
|
||||
} else {
|
||||
menus::invoke_menu(app, command, &entry.path);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Boolean environment switches (`SOUNDCRAFT_WAYLAND`, `SOUNDCRAFT_NO_PREFS`, ...): one parser, so every
|
||||
//! variable accepts the same spellings.
|
||||
|
||||
/// `1/on/true/yes` or `0/off/false/no`, case- and space-insensitive. Blank or unset is `Default`;
|
||||
/// anything else is `Default` and logged.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum Switch {
|
||||
On,
|
||||
Off,
|
||||
#[default]
|
||||
Default,
|
||||
}
|
||||
|
||||
impl Switch {
|
||||
/// Parse the value of the variable `name`; `name` only appears in the warning.
|
||||
pub(crate) fn parse(name: &str, value: Option<&str>) -> Switch {
|
||||
let Some(value) = value else { return Switch::Default };
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "on" | "true" | "yes" => Switch::On,
|
||||
"0" | "off" | "false" | "no" => Switch::Off,
|
||||
"" => Switch::Default,
|
||||
_ => {
|
||||
log::warn!("{name}={value:?} is not 1/on/true/yes or 0/off/false/no; using the default");
|
||||
Switch::Default
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_every_spelling() {
|
||||
for v in ["1", "on", "true", "yes", "ON", "True", " Yes ", "\t1\n"] {
|
||||
assert_eq!(Switch::parse("X", Some(v)), Switch::On, "{v:?}");
|
||||
}
|
||||
for v in ["0", "off", "false", "no", "OFF", "False", " No ", " 0 "] {
|
||||
assert_eq!(Switch::parse("X", Some(v)), Switch::Off, "{v:?}");
|
||||
}
|
||||
for v in [None, Some(""), Some(" "), Some("maybe"), Some("2"), Some("onn")] {
|
||||
assert_eq!(Switch::parse("X", v), Switch::Default, "{v:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! The desktop binary's command line, run as a process: the variable fallback of `--control`.
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn soundcraft(args: &[&str], control_port: &str) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_soundcraft"))
|
||||
.args(args)
|
||||
.env("SOUNDCRAFT_NO_PREFS", "1")
|
||||
.env("SOUNDCRAFT_CONTROL_PORT", control_port)
|
||||
.env_remove("RUST_LOG")
|
||||
// Without a display the window cannot open, so a launch that passes parsing ends instead of
|
||||
// staying up.
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.output()
|
||||
.expect("run soundcraft")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_variable_is_a_usage_error_naming_it() {
|
||||
let out = soundcraft(&["--no-audio"], "nope");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "{stderr}");
|
||||
assert!(stderr.contains("invalid value 'nope' for 'SOUNDCRAFT_CONTROL_PORT'"), "{stderr}");
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn a_blank_variable_is_unset() {
|
||||
let out = soundcraft(&["--no-audio"], " ");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_ne!(out.status.code(), Some(2), "{stderr}");
|
||||
assert!(!stderr.contains("SOUNDCRAFT_CONTROL_PORT"), "{stderr}");
|
||||
}
|
||||
|
||||
/// `soundcraft-cli app` takes `host:port` from the same variable.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn a_loopback_host_and_port_variable_is_accepted() {
|
||||
let out = soundcraft(&["--no-audio"], "127.0.0.1:0");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_ne!(out.status.code(), Some(2), "{stderr}");
|
||||
assert!(!stderr.contains("SOUNDCRAFT_CONTROL_PORT"), "{stderr}");
|
||||
}
|
||||
|
||||
/// The variable may point `soundcraft-cli app` at another machine: the app opens, with a warning
|
||||
/// and no control server.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn another_host_in_the_variable_is_ignored() {
|
||||
let out = soundcraft(&["--no-audio"], "10.0.0.5:7979");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_ne!(out.status.code(), Some(2), "{stderr}");
|
||||
assert!(stderr.contains("SOUNDCRAFT_CONTROL_PORT=10.0.0.5:7979 names another host, so no control server starts"), "{stderr}");
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# SoundCraft app icon (placeholder)
|
||||
|
||||
**A nightingale singing**, as an engraving-style head-and-shoulders portrait in the Crafting Apps'
|
||||
owl-template framing (craftrules `standards/icon-design.md`): a full-bleed colour field, the bird
|
||||
owl-template framing: a full-bleed colour field, the bird
|
||||
in three-quarter view with its bill open and its eye on the viewer, its breast and wing running off
|
||||
the bottom and right edges, three song arcs leaving the bill.
|
||||
|
||||
|
||||
@@ -2,3 +2,8 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
# Contributor names for the About window.
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -55,3 +52,20 @@ sync_github_name = true
|
||||
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
|
||||
|
||||
[people.luigiPinna]
|
||||
real_name = "Luigi Pinna"
|
||||
sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.kk376]
|
||||
real_name = "Kushagra Kumar"
|
||||
sync_github_name = true
|
||||
|
||||
@@ -13,14 +13,16 @@ serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
# An isolated unsafe helper crate (craftrules never-crash), like soundcraft-vst3-host: this crate
|
||||
# An isolated unsafe helper crate, like soundcraft-vst3-host: this crate
|
||||
# opts out of the workspace `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere
|
||||
# and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
too_many_arguments = "deny"
|
||||
type_complexity = "allow"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! values are the Audio Unit's own (plain) values, ranges and units, so no mapping is needed.
|
||||
//! Boolean parameters become toggles, indexed ones starting at 0 become choices.
|
||||
//!
|
||||
//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate (craftrules never-crash):
|
||||
//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate:
|
||||
//! `unsafe` is denied crate-wide and allowed only in the private `ffi` module (hand-written
|
||||
//! AudioToolbox / CoreFoundation declarations). The public API is safe, returns
|
||||
//! `Result`/`Option` and never panics. Limits of in-process hosting: an Audio Unit that itself
|
||||
@@ -192,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()
|
||||
@@ -210,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()
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
[package]
|
||||
name = "soundcraft-audio-io"
|
||||
description = "SoundCraft audio file I/O: WAV/BWF/RF64, AIFF/AIFF-C native readers and writers, FLAC encoder, symphonia-backed decode of compressed formats, waveform peaks"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
symphonia.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,283 +0,0 @@
|
||||
//! 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, 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;
|
||||
const MAX_SAMPLE_RATE: f64 = 10_000_000.0;
|
||||
|
||||
pub(crate) struct AiffLayout {
|
||||
pub info: AudioInfo,
|
||||
pub kind: PcmKind,
|
||||
pub data_start: usize,
|
||||
pub data_len: usize,
|
||||
}
|
||||
|
||||
pub(crate) enum AiffParse {
|
||||
Native(AiffLayout),
|
||||
/// Compressed AIFF-C (ulaw, alaw, ima4, ...): handed to symphonia.
|
||||
Foreign,
|
||||
}
|
||||
|
||||
/// True when the bytes start like an AIFF or AIFF-C file.
|
||||
pub(crate) fn is_aiff(b: &[u8]) -> bool {
|
||||
tag(b, 0).is_some_and(|t| &t == b"FORM") && tag(b, 8).is_some_and(|t| &t == b"AIFF" || &t == b"AIFC")
|
||||
}
|
||||
|
||||
/// Decode an 80-bit IEEE 754 extended-precision float.
|
||||
pub(crate) fn extended_to_f64(b: &[u8]) -> Option<f64> {
|
||||
let se = be_u16(b, 0)?;
|
||||
let hi = u64::from(be_u32(b, 2)?);
|
||||
let lo = u64::from(be_u32(b, 6)?);
|
||||
let mant = (hi << 32) | lo;
|
||||
if mant == 0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let exp = i32::from(se & 0x7FFF);
|
||||
if exp == 0x7FFF {
|
||||
return None; // inf / NaN
|
||||
}
|
||||
let v = mant as f64 * 2f64.powi(exp - 16383 - 63);
|
||||
Some(if se & 0x8000 != 0 { -v } else { v })
|
||||
}
|
||||
|
||||
/// Encode a positive integer as an 80-bit IEEE 754 extended float (exact).
|
||||
pub(crate) fn u32_to_extended(r: u32) -> [u8; 10] {
|
||||
let mut out = [0u8; 10];
|
||||
if r == 0 {
|
||||
return out;
|
||||
}
|
||||
let e = 31 - r.leading_zeros();
|
||||
let exp = (16383 + e) as u16;
|
||||
let mant = u64::from(r) << (63 - e);
|
||||
out[..2].copy_from_slice(&exp.to_be_bytes());
|
||||
out[2..].copy_from_slice(&mant.to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn parse(b: &[u8]) -> Result<AiffParse> {
|
||||
if !is_aiff(b) {
|
||||
return Err(AudioError::Malformed("not an AIFF/AIFF-C file".into()));
|
||||
}
|
||||
let is_aifc = tag(b, 8).is_some_and(|t| &t == b"AIFC");
|
||||
let mut comm: Option<(i16, u32, i16, f64, [u8; 4])> = None;
|
||||
let mut ssnd: Option<(usize, usize)> = None;
|
||||
|
||||
let mut pos: usize = 12;
|
||||
let mut chunks = 0usize;
|
||||
while let (Some(id), Some(size32)) = (tag(b, pos), be_u32(b, pos + 4)) {
|
||||
chunks += 1;
|
||||
if chunks > 100_000 {
|
||||
break;
|
||||
}
|
||||
let body = pos.checked_add(8).ok_or_else(|| AudioError::Malformed("chunk offset overflow".into()))?;
|
||||
let remaining = b.len().saturating_sub(body);
|
||||
let declared = usize::try_from(size32).unwrap_or(usize::MAX);
|
||||
let len = declared.min(remaining);
|
||||
let c = b.get(body..body + len).unwrap_or_default();
|
||||
match &id {
|
||||
b"COMM" => {
|
||||
let short = || AudioError::Malformed("COMM chunk too short".into());
|
||||
let channels = be_u16(c, 0).ok_or_else(short)? as i16;
|
||||
let frames = be_u32(c, 2).ok_or_else(short)?;
|
||||
let bits = be_u16(c, 6).ok_or_else(short)? as i16;
|
||||
let rate = extended_to_f64(c.get(8..18).ok_or_else(short)?).ok_or_else(|| AudioError::Malformed("invalid sample rate".into()))?;
|
||||
let compression = if is_aifc { tag(c, 18).ok_or_else(short)? } else { *b"NONE" };
|
||||
comm = Some((channels, frames, bits, rate, compression));
|
||||
}
|
||||
b"SSND" if ssnd.is_none() => {
|
||||
let offset = usize::try_from(be_u32(c, 0).unwrap_or(0)).unwrap_or(usize::MAX);
|
||||
let start = body.saturating_add(8).saturating_add(offset).min(b.len());
|
||||
let data_len = len.saturating_sub(8).saturating_sub(offset);
|
||||
ssnd = Some((start, data_len));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if declared > remaining {
|
||||
break;
|
||||
}
|
||||
pos = match body.checked_add(declared).and_then(|p| p.checked_add(declared & 1)) {
|
||||
Some(p) => p,
|
||||
None => break,
|
||||
};
|
||||
}
|
||||
|
||||
let (channels, frames, bits, rate, compression) = comm.ok_or_else(|| AudioError::Malformed("AIFF has no COMM chunk".into()))?;
|
||||
if channels <= 0 {
|
||||
return Err(AudioError::Malformed("AIFF declares zero or negative channels".into()));
|
||||
}
|
||||
if !(1.0..=MAX_SAMPLE_RATE).contains(&rate) {
|
||||
return Err(AudioError::Malformed(format!("AIFF sample rate {rate} out of range")));
|
||||
}
|
||||
let sample_rate = rate.round() as u32;
|
||||
let int_kind = |big_endian: bool| -> Result<(PcmKind, SampleFormat)> {
|
||||
if !(1..=32).contains(&bits) {
|
||||
return Err(AudioError::Unsupported(format!("AIFF sample size {bits}")));
|
||||
}
|
||||
let bytes = (bits as usize).div_ceil(8);
|
||||
let sf = match bits {
|
||||
1..=8 => SampleFormat::Int8,
|
||||
9..=16 => SampleFormat::Int16,
|
||||
17..=24 => SampleFormat::Int24,
|
||||
_ => SampleFormat::Int32,
|
||||
};
|
||||
Ok((PcmKind::Int { bytes, big_endian, unsigned: false }, sf))
|
||||
};
|
||||
let (kind, sample_format) = match &compression {
|
||||
b"NONE" | b"twos" => int_kind(true)?,
|
||||
b"sowt" => int_kind(false)?,
|
||||
b"fl32" | b"FL32" => (PcmKind::Float { bytes: 4, big_endian: true }, SampleFormat::Float32),
|
||||
b"fl64" | b"FL64" => (PcmKind::Float { bytes: 8, big_endian: true }, SampleFormat::Float64),
|
||||
_ => return Ok(AiffParse::Foreign),
|
||||
};
|
||||
let (data_start, data_len) = ssnd.unwrap_or((b.len(), 0));
|
||||
let block = kind.bytes().saturating_mul(channels as usize).max(1);
|
||||
let frames = u64::from(frames).min((data_len / block) as u64);
|
||||
Ok(AiffParse::Native(AiffLayout {
|
||||
info: AudioInfo { format: FileFormat::Aiff, sample_format, sample_rate, channels: channels as u16, frames, bwf: None },
|
||||
kind,
|
||||
data_start,
|
||||
data_len,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn decode(b: &[u8], layout: &AiffLayout) -> Result<AudioBuffer> {
|
||||
let end = layout.data_start.saturating_add(layout.data_len).min(b.len());
|
||||
let data = b.get(layout.data_start..end).unwrap_or_default();
|
||||
let channels = deinterleave(data, layout.kind, usize::from(layout.info.channels), layout.info.frames)?;
|
||||
Ok(AudioBuffer { sample_rate: layout.info.sample_rate, channels })
|
||||
}
|
||||
|
||||
fn push_chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
|
||||
out.extend_from_slice(id);
|
||||
out.extend_from_slice(&u32::try_from(body.len()).unwrap_or(u32::MAX).to_be_bytes());
|
||||
out.extend_from_slice(body);
|
||||
if body.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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) = 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(sample_rate));
|
||||
if is_float {
|
||||
comm.extend_from_slice(b"fl32");
|
||||
let name = b"32-bit float";
|
||||
comm.push(name.len() as u8);
|
||||
comm.extend_from_slice(name);
|
||||
if (name.len() + 1) % 2 == 1 {
|
||||
comm.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
let mut head = Vec::with_capacity(128);
|
||||
head.extend_from_slice(b"FORM");
|
||||
head.extend_from_slice(&[0, 0, 0, 0]); // patched below
|
||||
head.extend_from_slice(if is_float { b"AIFC" } else { b"AIFF" });
|
||||
if is_float {
|
||||
push_chunk(&mut head, b"FVER", &AIFC_VERSION_1.to_be_bytes());
|
||||
}
|
||||
push_chunk(&mut head, b"COMM", &comm);
|
||||
head.extend_from_slice(b"SSND");
|
||||
let ssnd_len = u32::try_from(data_len.saturating_add(8)).map_err(|_| AudioError::TooLarge("AIFF data exceeds 4 GiB".into()))?;
|
||||
head.extend_from_slice(&ssnd_len.to_be_bytes());
|
||||
head.extend_from_slice(&[0u8; 8]); // offset, blockSize
|
||||
|
||||
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());
|
||||
}
|
||||
Ok(head)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
//! Planar `f32` audio buffer.
|
||||
|
||||
/// Planar f32 audio.
|
||||
///
|
||||
/// Every channel is expected to hold the same number of frames; [`AudioBuffer::frames`] reports the
|
||||
/// shortest channel so a ragged buffer never causes out-of-bounds access.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct AudioBuffer {
|
||||
/// Sample rate in Hz.
|
||||
pub sample_rate: u32,
|
||||
/// One `Vec` of samples per channel.
|
||||
pub channels: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
impl AudioBuffer {
|
||||
/// A silent buffer with `channels` channels of `frames` frames each.
|
||||
pub fn new(sample_rate: u32, channels: usize, frames: usize) -> Self {
|
||||
Self { sample_rate, channels: vec![vec![0.0; frames]; channels] }
|
||||
}
|
||||
|
||||
/// Number of frames (the length of the shortest channel; 0 when there are no channels).
|
||||
pub fn frames(&self) -> usize {
|
||||
self.channels.iter().map(Vec::len).min().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Number of channels.
|
||||
pub fn num_channels(&self) -> usize {
|
||||
self.channels.len()
|
||||
}
|
||||
|
||||
/// Duration in seconds (0 when the sample rate is 0).
|
||||
pub fn duration_secs(&self) -> f64 {
|
||||
if self.sample_rate == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.frames() as f64 / f64::from(self.sample_rate)
|
||||
}
|
||||
|
||||
/// Interleaved copy of the samples (frame-major: `L0 R0 L1 R1 ...`).
|
||||
pub fn interleaved(&self) -> Vec<f32> {
|
||||
let frames = self.frames();
|
||||
let mut out = Vec::with_capacity(frames.saturating_mul(self.channels.len()));
|
||||
for f in 0..frames {
|
||||
for ch in &self.channels {
|
||||
out.push(ch.get(f).copied().unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a planar buffer from interleaved samples. A trailing partial frame is dropped;
|
||||
/// `channels == 0` yields an empty buffer.
|
||||
pub fn from_interleaved(sr: u32, channels: usize, data: &[f32]) -> Self {
|
||||
if channels == 0 {
|
||||
return Self { sample_rate: sr, channels: Vec::new() };
|
||||
}
|
||||
let frames = data.len() / channels;
|
||||
let mut out: Vec<Vec<f32>> = (0..channels).map(|_| Vec::with_capacity(frames)).collect();
|
||||
for frame in data.chunks_exact(channels) {
|
||||
for (ch, &s) in out.iter_mut().zip(frame) {
|
||||
ch.push(s);
|
||||
}
|
||||
}
|
||||
Self { sample_rate: sr, channels: out }
|
||||
}
|
||||
|
||||
/// Absolute peak over all channels (NaN samples are ignored).
|
||||
pub fn peak(&self) -> f32 {
|
||||
self.channels.iter().flat_map(|c| c.iter()).fold(0.0f32, |acc, &s| acc.max(s.abs()))
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//! Size checks on CAF chunks before symphonia reads them: it allocates whatever a magic cookie,
|
||||
//! a packet table or a packet claims, and a failed allocation aborts the process.
|
||||
|
||||
use crate::pcm::{be_u16, be_u32, be_u64};
|
||||
use crate::{AudioError, Result};
|
||||
|
||||
/// Refuse the CAF stream at `at` if its magic cookie, packet table or packets claim more bytes
|
||||
/// than the file holds.
|
||||
pub(crate) fn check(b: &[u8], at: usize) -> Result<()> {
|
||||
if be_u16(b, at + 4) == Some(1) { chunks(b, at + 8) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn malformed(what: &str) -> AudioError {
|
||||
AudioError::Malformed(format!("CAF {what} is larger than the file"))
|
||||
}
|
||||
|
||||
/// Walk the chunks the way symphonia 0.5 reads them: it advances by the bytes it parses, not by
|
||||
/// the declared size, and after a `data` chunk of unknown size it reads on into the audio.
|
||||
fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
|
||||
// Whether the packet table lists each packet's size and frame count (variable per packet).
|
||||
let (mut lists_sizes, mut lists_frames, mut described) = (false, false, false);
|
||||
while let (Some(kind), Some(size)) = (b.get(pos..pos + 4), be_u64(b, pos + 4)) {
|
||||
let (body, size) = (pos + 12, size as i64);
|
||||
let used = match kind {
|
||||
b"desc" if size == 32 => {
|
||||
lists_sizes = be_u32(b, body + 16) == Some(0);
|
||||
lists_frames = be_u32(b, body + 20) == Some(0);
|
||||
described = true;
|
||||
Some(32)
|
||||
}
|
||||
b"data" if size == -1 => Some(4),
|
||||
b"chan" if size >= 12 => be_u32(b, body + 8).map(|n| 12 + u64::from(n) * 20),
|
||||
b"pakt" if size >= 24 && described => packet_table(b, body, lists_sizes, lists_frames)?,
|
||||
b"kuki" if size >= 0 && size as u64 > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
|
||||
b"desc" | b"pakt" | b"chan" => None,
|
||||
_ => u64::try_from(size).ok(),
|
||||
};
|
||||
match used.filter(|_| described).and_then(|n| usize::try_from(n).ok()).and_then(|n| body.checked_add(n)) {
|
||||
Some(next) if next < b.len() => pos = next,
|
||||
_ => return Ok(()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check a packet table's count and packet sizes; returns the bytes symphonia reads for it, or
|
||||
/// None where symphonia stops. It reserves room for every packet before reading any, so a table
|
||||
/// it would refuse after that, or that runs past the file, is refused first.
|
||||
fn packet_table(b: &[u8], body: usize, lists_sizes: bool, lists_frames: bool) -> Result<Option<u64>> {
|
||||
let valid = |at: usize| be_u64(b, at).filter(|&n| n as i64 >= 0);
|
||||
let (Some(packets), Some(_)) = (valid(body), valid(body + 8)) else { return Ok(None) };
|
||||
let mut at = body + 24;
|
||||
if packets > b.len().saturating_sub(at) as u64 {
|
||||
return Err(malformed("packet table"));
|
||||
}
|
||||
if !lists_sizes && !lists_frames {
|
||||
return if packets == 0 { Ok(Some(24)) } else { Err(AudioError::Malformed("a constant bit rate CAF lists packets".into())) };
|
||||
}
|
||||
for _ in 0..packets {
|
||||
if lists_sizes {
|
||||
let Some((size, len)) = varint(b, at) else { return Err(malformed("packet table")) };
|
||||
if size > b.len() as u64 {
|
||||
return Err(malformed("packet"));
|
||||
}
|
||||
at += len;
|
||||
}
|
||||
if lists_frames {
|
||||
let Some((_, len)) = varint(b, at) else { return Err(malformed("packet table")) };
|
||||
at += len;
|
||||
}
|
||||
}
|
||||
Ok(Some((at - body) as u64))
|
||||
}
|
||||
|
||||
/// A CAF variable-length integer at `at` and its length in bytes.
|
||||
fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> {
|
||||
let mut v = 0u64;
|
||||
for i in 0..9 {
|
||||
let x = *b.get(at + i)?;
|
||||
v |= u64::from(x & 0x7f);
|
||||
if x & 0x80 == 0 {
|
||||
return Some((v, i + 1));
|
||||
}
|
||||
v <<= 7;
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1,560 +0,0 @@
|
||||
//! 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. Decoding is symphonia's, after [`check_metadata`].
|
||||
|
||||
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;
|
||||
|
||||
struct BitWriter {
|
||||
out: Vec<u8>,
|
||||
acc: u64,
|
||||
nbits: u32,
|
||||
}
|
||||
|
||||
impl BitWriter {
|
||||
fn new() -> Self {
|
||||
Self { out: Vec::new(), acc: 0, nbits: 0 }
|
||||
}
|
||||
|
||||
/// Write the low `n` (≤ 32) bits of `v`, MSB first.
|
||||
fn write(&mut self, v: u64, n: u32) {
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let n = n.min(32);
|
||||
let mask = (1u64 << n) - 1;
|
||||
self.acc = (self.acc << n) | (v & mask);
|
||||
self.nbits += n;
|
||||
while self.nbits >= 8 {
|
||||
self.nbits -= 8;
|
||||
self.out.push((self.acc >> self.nbits) as u8);
|
||||
}
|
||||
self.acc &= (1u64 << self.nbits) - 1;
|
||||
}
|
||||
|
||||
fn write_signed(&mut self, v: i64, n: u32) {
|
||||
self.write(v as u64, n);
|
||||
}
|
||||
|
||||
/// `q` zero bits followed by a one bit.
|
||||
fn write_unary(&mut self, mut q: u64) {
|
||||
while q >= 32 {
|
||||
self.write(0, 32);
|
||||
q -= 32;
|
||||
}
|
||||
self.write(1, (q as u32) + 1);
|
||||
}
|
||||
|
||||
fn align(&mut self) {
|
||||
if self.nbits > 0 {
|
||||
let pad = 8 - self.nbits;
|
||||
self.write(0, pad);
|
||||
}
|
||||
}
|
||||
|
||||
fn into_bytes(mut self) -> Vec<u8> {
|
||||
self.align();
|
||||
self.out
|
||||
}
|
||||
}
|
||||
|
||||
fn crc8(data: &[u8]) -> u8 {
|
||||
let mut crc: u8 = 0;
|
||||
for &b in data {
|
||||
crc ^= b;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x80 != 0 { (crc << 1) ^ 0x07 } else { crc << 1 };
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
fn crc16(data: &[u8]) -> u16 {
|
||||
let mut crc: u16 = 0;
|
||||
for &b in data {
|
||||
crc ^= u16::from(b) << 8;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x8000 != 0 { (crc << 1) ^ 0x8005 } else { crc << 1 };
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// FLAC's UTF-8-like variable-length integer coding (up to 36 bits).
|
||||
fn write_utf8_number(w: &mut BitWriter, v: u64) {
|
||||
if v < 0x80 {
|
||||
w.write(v, 8);
|
||||
return;
|
||||
}
|
||||
// Payload bits available for an n-byte coding: 5n + 1 (n = 2..=6), 36 for n = 7.
|
||||
let n: u32 = (2..=7).find(|&n| if n == 7 { true } else { v < (1u64 << (5 * n + 1)) }).unwrap_or(7);
|
||||
let cont_bits = 6 * (n - 1);
|
||||
let lead_payload = if n == 7 { 0 } else { 7 - n };
|
||||
let prefix: u64 = (0xFFu64 << (8 - n)) & 0xFF;
|
||||
let lead = prefix | ((v >> cont_bits) & ((1u64 << lead_payload) - 1));
|
||||
w.write(lead, 8);
|
||||
for i in (0..n - 1).rev() {
|
||||
w.write(0x80 | ((v >> (6 * i)) & 0x3F), 8);
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_rate_code(r: u32) -> (u64, Option<(u64, u32)>) {
|
||||
match r {
|
||||
88_200 => (1, None),
|
||||
176_400 => (2, None),
|
||||
192_000 => (3, None),
|
||||
8_000 => (4, None),
|
||||
16_000 => (5, None),
|
||||
22_050 => (6, None),
|
||||
24_000 => (7, None),
|
||||
32_000 => (8, None),
|
||||
44_100 => (9, None),
|
||||
48_000 => (10, None),
|
||||
96_000 => (11, None),
|
||||
r if r % 1000 == 0 && r / 1000 <= 255 => (12, Some((u64::from(r / 1000), 8))),
|
||||
r if r <= 65_535 => (13, Some((u64::from(r), 16))),
|
||||
r if r % 10 == 0 && r / 10 <= 65_535 => (14, Some((u64::from(r / 10), 16))),
|
||||
_ => (0, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn bps_code(bps: u32) -> u64 {
|
||||
match bps {
|
||||
8 => 1,
|
||||
12 => 2,
|
||||
16 => 4,
|
||||
20 => 5,
|
||||
24 => 6,
|
||||
_ => 0, // from STREAMINFO
|
||||
}
|
||||
}
|
||||
|
||||
fn zigzag(r: i64) -> u64 {
|
||||
((r << 1) ^ (r >> 63)) as u64
|
||||
}
|
||||
|
||||
/// Fixed-predictor residuals for `order`, or `None` when any residual leaves the i32 range.
|
||||
fn fixed_residual(x: &[i64], order: usize) -> Option<Vec<i64>> {
|
||||
let mut res = Vec::with_capacity(x.len().saturating_sub(order));
|
||||
for n in order..x.len() {
|
||||
let g = |k: usize| x.get(n - k).copied().unwrap_or(0);
|
||||
let r = match order {
|
||||
0 => g(0),
|
||||
1 => g(0) - g(1),
|
||||
2 => g(0) - 2 * g(1) + g(2),
|
||||
3 => g(0) - 3 * g(1) + 3 * g(2) - g(3),
|
||||
_ => g(0) - 4 * g(1) + 6 * g(2) - 4 * g(3) + g(4),
|
||||
};
|
||||
if r < i64::from(i32::MIN) || r > i64::from(i32::MAX) {
|
||||
return None;
|
||||
}
|
||||
res.push(r);
|
||||
}
|
||||
Some(res)
|
||||
}
|
||||
|
||||
/// Best Rice parameter and its cost in bits for one partition.
|
||||
fn best_rice(part: &[u64]) -> (u32, u64) {
|
||||
if part.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
let sum: u64 = part.iter().sum();
|
||||
let mean = sum / part.len() as u64;
|
||||
let guess = if mean == 0 { 0 } else { 63 - mean.leading_zeros() };
|
||||
let mut best = (0u32, u64::MAX);
|
||||
for k in guess.saturating_sub(1)..=(guess + 1).min(30) {
|
||||
let cost = part.len() as u64 * u64::from(k + 1) + part.iter().map(|&u| u >> k).sum::<u64>();
|
||||
if cost < best.1 {
|
||||
best = (k, cost);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Residual coding plan: partition order, per-partition parameters, total bits.
|
||||
struct RicePlan {
|
||||
order: u32,
|
||||
params: Vec<u32>,
|
||||
bits: u64,
|
||||
wide: bool,
|
||||
}
|
||||
|
||||
fn plan_residual(u: &[u64], block_len: usize, pred_order: usize) -> RicePlan {
|
||||
let mut best: Option<RicePlan> = None;
|
||||
for p in 0..=MAX_PARTITION_ORDER {
|
||||
let parts = 1usize << p;
|
||||
if !block_len.is_multiple_of(parts) || block_len / parts <= pred_order {
|
||||
break;
|
||||
}
|
||||
let per = block_len / parts;
|
||||
let mut params = Vec::with_capacity(parts);
|
||||
let mut bits = 0u64;
|
||||
let mut start = 0usize;
|
||||
for i in 0..parts {
|
||||
let len = if i == 0 { per - pred_order } else { per };
|
||||
let part = u.get(start..start + len).unwrap_or_default();
|
||||
start += len;
|
||||
let (k, cost) = best_rice(part);
|
||||
params.push(k);
|
||||
bits += cost;
|
||||
}
|
||||
let wide = params.iter().any(|&k| k > 14);
|
||||
bits += 6 + parts as u64 * if wide { 5 } else { 4 };
|
||||
if best.as_ref().is_none_or(|b| bits < b.bits) {
|
||||
best = Some(RicePlan { order: p, params, bits, wide });
|
||||
}
|
||||
}
|
||||
best.unwrap_or(RicePlan { order: 0, params: vec![0], bits: u64::MAX, wide: false })
|
||||
}
|
||||
|
||||
fn write_residual(w: &mut BitWriter, u: &[u64], block_len: usize, pred_order: usize, plan: &RicePlan) {
|
||||
w.write(u64::from(plan.wide), 2);
|
||||
w.write(u64::from(plan.order), 4);
|
||||
let parts = 1usize << plan.order;
|
||||
let per = block_len / parts;
|
||||
let mut start = 0usize;
|
||||
for (i, &k) in plan.params.iter().enumerate() {
|
||||
let len = if i == 0 { per - pred_order } else { per };
|
||||
w.write(u64::from(k), if plan.wide { 5 } else { 4 });
|
||||
for &v in u.get(start..start + len).unwrap_or_default() {
|
||||
w.write_unary(v >> k);
|
||||
w.write(v, k);
|
||||
}
|
||||
start += len;
|
||||
}
|
||||
}
|
||||
|
||||
fn write_subframe(w: &mut BitWriter, x: &[i64], bps: u32) {
|
||||
let n = x.len();
|
||||
let first = x.first().copied().unwrap_or(0);
|
||||
if x.iter().all(|&s| s == first) {
|
||||
w.write(0, 1);
|
||||
w.write(0, 6);
|
||||
w.write(0, 1);
|
||||
w.write_signed(first, bps);
|
||||
return;
|
||||
}
|
||||
let verbatim_bits = n as u64 * u64::from(bps);
|
||||
let mut best: Option<(usize, Vec<u64>, RicePlan)> = None;
|
||||
for order in 0..=4usize.min(n.saturating_sub(1)) {
|
||||
let Some(res) = fixed_residual(x, order) else { continue };
|
||||
let u: Vec<u64> = res.into_iter().map(zigzag).collect();
|
||||
let plan = plan_residual(&u, n, order);
|
||||
let bits = plan.bits.saturating_add(order as u64 * u64::from(bps));
|
||||
if bits < verbatim_bits && best.as_ref().is_none_or(|b| bits < b.2.bits.saturating_add(b.0 as u64 * u64::from(bps))) {
|
||||
best = Some((order, u, plan));
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((order, u, plan)) => {
|
||||
w.write(0, 1);
|
||||
w.write(8 + order as u64, 6);
|
||||
w.write(0, 1);
|
||||
for &s in x.iter().take(order) {
|
||||
w.write_signed(s, bps);
|
||||
}
|
||||
write_residual(w, &u, n, order, &plan);
|
||||
}
|
||||
None => {
|
||||
w.write(0, 1);
|
||||
w.write(1, 6);
|
||||
w.write(0, 1);
|
||||
for &s in x {
|
||||
w.write_signed(s, bps);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 sample_rate > 655_350 {
|
||||
return Err(AudioError::Unsupported(format!("FLAC sample rate {sample_rate} exceeds 655350 Hz")));
|
||||
}
|
||||
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())),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
si.write(34, 24);
|
||||
si.write(BLOCK_SIZE as u64, 16);
|
||||
si.write(BLOCK_SIZE as u64, 16);
|
||||
si.write(0, 24);
|
||||
si.write(0, 24);
|
||||
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);
|
||||
si.write(total & 0xFFFF_FFFF, 32);
|
||||
for _ in 0..4 {
|
||||
si.write(0, 32); // MD5 unknown
|
||||
}
|
||||
si.into_bytes()
|
||||
}
|
||||
|
||||
/// 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(self.channels as u64 - 1, 4);
|
||||
w.write(bps_code(self.bps), 3);
|
||||
w.write(0, 1);
|
||||
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);
|
||||
}
|
||||
let header = w.into_bytes();
|
||||
let mut w = BitWriter::new();
|
||||
for &b in &header {
|
||||
w.write(u64::from(b), 8);
|
||||
}
|
||||
w.write(u64::from(crc8(&header)), 8);
|
||||
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());
|
||||
self.out.write_all(&frame)?;
|
||||
self.frame_no += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn crc_known_values() {
|
||||
// CRC-8/SMBUS and CRC-16/UMTS (FLAC's polynomials) of "123456789".
|
||||
assert_eq!(crc8(b"123456789"), 0xF4);
|
||||
assert_eq!(crc16(b"123456789"), 0xFEE8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_numbers() {
|
||||
let enc = |v| {
|
||||
let mut w = BitWriter::new();
|
||||
write_utf8_number(&mut w, v);
|
||||
w.into_bytes()
|
||||
};
|
||||
assert_eq!(enc(0x7F), vec![0x7F]);
|
||||
assert_eq!(enc(0x80), vec![0xC2, 0x80]);
|
||||
assert_eq!(enc(0x800), vec![0xE0, 0xA0, 0x80]);
|
||||
}
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
//! SoundCraft audio file I/O.
|
||||
//!
|
||||
//! Bytes in, bytes out (no filesystem access, so the crate also builds for `wasm32`):
|
||||
//!
|
||||
//! * [`detect_format`] / [`probe`] / [`decode`] read WAV (PCM 8/16/24/32, float 32/64,
|
||||
//! `WAVE_FORMAT_EXTENSIBLE`, RF64/BW64, BWF `bext`) and AIFF/AIFF-C (big-endian, `sowt`,
|
||||
//! `fl32`/`fl64`) with native readers, and everything else (FLAC, MP3, Ogg Vorbis, AAC/ALAC in
|
||||
//! 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)]
|
||||
pub enum AudioError {
|
||||
/// The format, codec or option is valid but not supported.
|
||||
#[error("unsupported audio: {0}")]
|
||||
Unsupported(String),
|
||||
/// The input is corrupt or not what it claims to be.
|
||||
#[error("malformed audio: {0}")]
|
||||
Malformed(String),
|
||||
/// The input (or requested output) exceeds a size limit.
|
||||
#[error("audio too large: {0}")]
|
||||
TooLarge(String),
|
||||
/// 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.
|
||||
pub type Result<T> = std::result::Result<T, AudioError>;
|
||||
|
||||
/// Container / codec family of an audio file.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FileFormat {
|
||||
Wav,
|
||||
Aiff,
|
||||
Flac,
|
||||
Mp3,
|
||||
Ogg,
|
||||
Aac,
|
||||
Alac,
|
||||
Caf,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Stored sample representation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum SampleFormat {
|
||||
Int8,
|
||||
Int16,
|
||||
Int24,
|
||||
Int32,
|
||||
Float32,
|
||||
Float64,
|
||||
/// A lossy codec (MP3, AAC, Vorbis, ...): no fixed bit depth.
|
||||
Compressed,
|
||||
}
|
||||
|
||||
/// Broadcast Wave (`bext`) metadata.
|
||||
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BwfInfo {
|
||||
pub description: String,
|
||||
pub originator: String,
|
||||
pub originator_reference: String,
|
||||
/// `yyyy-mm-dd`.
|
||||
pub origination_date: String,
|
||||
/// `hh:mm:ss`.
|
||||
pub origination_time: String,
|
||||
/// Sample count since midnight of the first sample (the timestamp Pro Tools spots to).
|
||||
pub time_reference: u64,
|
||||
}
|
||||
|
||||
/// What [`probe`] learns about a file without decoding all of it.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AudioInfo {
|
||||
pub format: FileFormat,
|
||||
pub sample_format: SampleFormat,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
pub frames: u64,
|
||||
pub bwf: Option<BwfInfo>,
|
||||
}
|
||||
|
||||
impl AudioInfo {
|
||||
/// Duration in seconds.
|
||||
pub fn duration_secs(&self) -> f64 {
|
||||
if self.sample_rate == 0 { 0.0 } else { self.frames as f64 / f64::from(self.sample_rate) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Bit depth for [`encode`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum BitDepth {
|
||||
Int16,
|
||||
Int24,
|
||||
Int32,
|
||||
Float32,
|
||||
}
|
||||
|
||||
/// Options for [`encode`].
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct EncodeOptions {
|
||||
/// `Wav`, `Aiff` or `Flac`; anything else is [`AudioError::Unsupported`].
|
||||
pub format: FileFormat,
|
||||
pub bit_depth: BitDepth,
|
||||
/// Add TPDF dither when quantizing to an integer depth.
|
||||
pub dither: bool,
|
||||
/// Write a BWF `bext` chunk (WAV only; ignored for other formats).
|
||||
pub bwf: Option<BwfInfo>,
|
||||
}
|
||||
|
||||
impl Default for EncodeOptions {
|
||||
fn default() -> Self {
|
||||
Self { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Size of an ID3v2 tag at the start of `b`, if there is one.
|
||||
fn id3v2_len(b: &[u8]) -> Option<usize> {
|
||||
if b.get(..3)? != b"ID3" {
|
||||
return None;
|
||||
}
|
||||
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 })
|
||||
}
|
||||
|
||||
fn detect_by_magic(b: &[u8], depth: u32) -> Option<FileFormat> {
|
||||
let head = |n: usize| b.get(..n);
|
||||
if wav::is_wav(b) {
|
||||
return Some(FileFormat::Wav);
|
||||
}
|
||||
if aiff::is_aiff(b) {
|
||||
return Some(FileFormat::Aiff);
|
||||
}
|
||||
match head(4)? {
|
||||
b"fLaC" => return Some(FileFormat::Flac),
|
||||
b"OggS" => return Some(FileFormat::Ogg),
|
||||
b"caff" => return Some(FileFormat::Caf),
|
||||
[0x1A, 0x45, 0xDF, 0xA3] => return Some(FileFormat::Other),
|
||||
_ => {}
|
||||
}
|
||||
if b.get(4..8) == Some(b"ftyp") {
|
||||
// MP4 / M4A: the sample entry inside `stsd` names the codec.
|
||||
let found = b.windows(4).position(|w| w == b"stsd");
|
||||
let codec = found.and_then(|p| b.get(p.checked_add(16)?..p.checked_add(20)?));
|
||||
return Some(if codec == Some(b"alac") { FileFormat::Alac } else { FileFormat::Aac });
|
||||
}
|
||||
if let Some(len) = id3v2_len(b) {
|
||||
if depth == 0
|
||||
&& let Some(f) = b.get(len..).and_then(|rest| detect_by_magic(rest, 1))
|
||||
{
|
||||
return Some(f);
|
||||
}
|
||||
return Some(FileFormat::Mp3);
|
||||
}
|
||||
let (b0, b1) = (*b.first()?, *b.get(1)?);
|
||||
if b0 == 0xFF && b1 & 0xE0 == 0xE0 {
|
||||
let layer = (b1 >> 1) & 0x3;
|
||||
if b1 & 0xF0 == 0xF0 && layer == 0 {
|
||||
return Some(FileFormat::Aac); // ADTS
|
||||
}
|
||||
if layer != 0 {
|
||||
return Some(FileFormat::Mp3);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn detect_by_ext(ext: &str) -> FileFormat {
|
||||
match ext.trim_start_matches('.').to_ascii_lowercase().as_str() {
|
||||
"wav" | "wave" | "bwf" | "rf64" => FileFormat::Wav,
|
||||
"aif" | "aiff" | "aifc" => FileFormat::Aiff,
|
||||
"flac" => FileFormat::Flac,
|
||||
"mp3" | "mp2" | "mp1" => FileFormat::Mp3,
|
||||
"ogg" | "oga" => FileFormat::Ogg,
|
||||
"aac" | "m4a" | "m4b" | "mp4" | "adts" => FileFormat::Aac,
|
||||
"caf" => FileFormat::Caf,
|
||||
_ => FileFormat::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Identify a file by its magic bytes, falling back to the extension hint (`"wav"`, `".flac"`).
|
||||
pub fn detect_format(bytes: &[u8], ext_hint: Option<&str>) -> FileFormat {
|
||||
detect_by_magic(bytes, 0).unwrap_or_else(|| ext_hint.map(detect_by_ext).unwrap_or(FileFormat::Other))
|
||||
}
|
||||
|
||||
/// Read a file's format, sample rate, channel count, length and BWF metadata.
|
||||
pub fn probe(bytes: &[u8], ext_hint: Option<&str>) -> Result<AudioInfo> {
|
||||
let format = detect_format(bytes, ext_hint);
|
||||
match format {
|
||||
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, 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, format),
|
||||
},
|
||||
_ => symph::probe(bytes, format),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a whole file to planar `f32`.
|
||||
pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioBuffer)> {
|
||||
let format = detect_format(bytes, ext_hint);
|
||||
match format {
|
||||
FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? {
|
||||
wav::WavParse::Native(l) => {
|
||||
let buf = wav::decode(bytes, &l)?;
|
||||
let mut info = l.info;
|
||||
info.frames = buf.frames() as u64;
|
||||
Ok((info, buf))
|
||||
}
|
||||
wav::WavParse::Foreign { bwf } => {
|
||||
let (mut info, buf) = symph::decode(bytes, format)?;
|
||||
info.bwf = bwf;
|
||||
Ok((info, buf))
|
||||
}
|
||||
},
|
||||
FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? {
|
||||
aiff::AiffParse::Native(l) => {
|
||||
let buf = aiff::decode(bytes, &l)?;
|
||||
let mut info = l.info;
|
||||
info.frames = buf.frames() as u64;
|
||||
Ok((info, buf))
|
||||
}
|
||||
aiff::AiffParse::Foreign => symph::decode(bytes, format),
|
||||
},
|
||||
_ => symph::decode(bytes, format),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode `buf` as WAV/BWF, AIFF/AIFF-C or FLAC. Samples are clamped to [-1, 1].
|
||||
pub fn encode(buf: &AudioBuffer, opts: &EncodeOptions) -> Result<Vec<u8>> {
|
||||
match opts.format {
|
||||
FileFormat::Wav => wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), false, None),
|
||||
FileFormat::Aiff => aiff::encode(buf, opts.bit_depth, opts.dither),
|
||||
FileFormat::Flac => flac::encode(buf, opts.bit_depth, opts.dither),
|
||||
other => Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats [`encode`] can write, as the extensions that name them.
|
||||
pub const ENCODE_EXTENSIONS: &str = "wav, aif, aiff, flac";
|
||||
|
||||
/// The format [`encode`] writes for a file extension or format name (`wav`, `aiff`, `flac`, case-insensitive), or `None` when it cannot write it.
|
||||
pub fn encode_format_for(name: &str) -> Option<FileFormat> {
|
||||
match name.trim_start_matches('.').to_ascii_lowercase().as_str() {
|
||||
"wav" | "wave" | "bwf" => Some(FileFormat::Wav),
|
||||
"aif" | "aiff" => Some(FileFormat::Aiff),
|
||||
"flac" => Some(FileFormat::Flac),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a WAV file in the RF64 layout regardless of size (normally used only above 4 GiB).
|
||||
pub fn encode_wav_rf64(buf: &AudioBuffer, opts: &EncodeOptions) -> Result<Vec<u8>> {
|
||||
wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), true, None)
|
||||
}
|
||||
|
||||
/// Like [`encode`], with an explicit WAV speaker mask (`dwChannelMask`, e.g. 0x60F) for layouts
|
||||
/// a channel count alone cannot identify (7.0 vs 6.1, 5.1.4 vs 7.1.2, Ambisonics = 0). AIFF and
|
||||
/// FLAC have fixed channel orders and ignore it.
|
||||
pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u32) -> Result<Vec<u8>> {
|
||||
match opts.format {
|
||||
FileFormat::Wav => wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), false, Some(mask)),
|
||||
_ => encode(buf, opts),
|
||||
}
|
||||
}
|
||||
|
||||
/// The error a stream encoder returns for every call after one of its writes has failed: the
|
||||
/// sink may already hold a prefix of the failed block, so the encoder's counters can no longer
|
||||
/// describe the bytes on the sink, and later writes or `finish` must not claim success.
|
||||
pub(crate) fn failed_write_error() -> AudioError {
|
||||
AudioError::Encode("the stream encoder already failed on an earlier write; its output is incomplete".into())
|
||||
}
|
||||
|
||||
/// Writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32` or FLAC block by block, producing
|
||||
/// the same bytes [`encode_with_channel_mask`] would for the whole buffer.
|
||||
pub struct FileEncoder<W: Write + Seek>(Writer<W>);
|
||||
|
||||
enum Writer<W> {
|
||||
Wav(wav::Writer<W>),
|
||||
Aiff(aiff::Writer<W>),
|
||||
Flac(flac::Writer<W>),
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> FileEncoder<W> {
|
||||
/// Starts a file at the current position of `out`. `mask` overrides the WAV speaker mask
|
||||
/// derived from the channel count.
|
||||
pub fn new(out: W, opts: &EncodeOptions, channels: usize, sample_rate: u32, mask: Option<u32>) -> Result<Self> {
|
||||
let (depth, dither) = (opts.bit_depth, opts.dither);
|
||||
Ok(FileEncoder(match opts.format {
|
||||
FileFormat::Wav => Writer::Wav(wav::Writer::new(out, channels, sample_rate, depth, dither, opts.bwf.as_ref(), mask, false)?),
|
||||
FileFormat::Aiff => Writer::Aiff(aiff::Writer::new(out, channels, sample_rate, depth, dither)?),
|
||||
FileFormat::Flac => Writer::Flac(flac::Writer::new(out, channels, sample_rate, depth, dither)?),
|
||||
other => return Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Fails, without writing anything, when `frames` more frames would not fit the format (an
|
||||
/// AIFF holds 4 GiB, a FLAC 2^36 frames).
|
||||
pub fn check_room(&self, frames: u64) -> Result<()> {
|
||||
match &self.0 {
|
||||
Writer::Wav(w) => w.check_room(frames),
|
||||
Writer::Aiff(w) => w.check_room(frames),
|
||||
Writer::Flac(w) => w.check_room(frames),
|
||||
}
|
||||
.map(drop)
|
||||
}
|
||||
|
||||
/// Appends the first `frames` frames of `block`, one `Vec` per channel (missing samples are
|
||||
/// silence), unless they don't fit (see [`FileEncoder::check_room`]). Once a write has
|
||||
/// failed, the encoder is unusable: the sink may hold a prefix of the failed block, so
|
||||
/// every later `write` and `finish` fails instead of completing an inconsistent file.
|
||||
pub fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
match &mut self.0 {
|
||||
Writer::Wav(w) => w.write(block, frames),
|
||||
Writer::Aiff(w) => w.write(block, frames),
|
||||
Writer::Flac(w) => w.write(block, frames),
|
||||
}
|
||||
}
|
||||
|
||||
/// Completes the headers and hands back `out`, positioned at the end of the file.
|
||||
pub fn finish(self) -> Result<W> {
|
||||
match self.0 {
|
||||
Writer::Wav(w) => w.finish(),
|
||||
Writer::Aiff(w) => w.finish(),
|
||||
Writer::Flac(w) => w.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The WAVE_FORMAT_EXTENSIBLE speaker mask of a WAV file, if it has one.
|
||||
pub fn wav_channel_mask(bytes: &[u8]) -> Option<u32> {
|
||||
wav::read_channel_mask(bytes)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
//! 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,231 +0,0 @@
|
||||
//! 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, 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;
|
||||
|
||||
/// Little-endian `u16` at `pos`.
|
||||
pub(crate) fn le_u16(b: &[u8], pos: usize) -> Option<u16> {
|
||||
let s = b.get(pos..pos.checked_add(2)?)?;
|
||||
Some(u16::from_le_bytes([*s.first()?, *s.get(1)?]))
|
||||
}
|
||||
|
||||
/// Little-endian `u32` at `pos`.
|
||||
pub(crate) fn le_u32(b: &[u8], pos: usize) -> Option<u32> {
|
||||
let s: [u8; 4] = b.get(pos..pos.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(u32::from_le_bytes(s))
|
||||
}
|
||||
|
||||
/// Little-endian `u64` at `pos`.
|
||||
pub(crate) fn le_u64(b: &[u8], pos: usize) -> Option<u64> {
|
||||
let s: [u8; 8] = b.get(pos..pos.checked_add(8)?)?.try_into().ok()?;
|
||||
Some(u64::from_le_bytes(s))
|
||||
}
|
||||
|
||||
/// Big-endian `u16` at `pos`.
|
||||
pub(crate) fn be_u16(b: &[u8], pos: usize) -> Option<u16> {
|
||||
let s: [u8; 2] = b.get(pos..pos.checked_add(2)?)?.try_into().ok()?;
|
||||
Some(u16::from_be_bytes(s))
|
||||
}
|
||||
|
||||
/// Big-endian `u32` at `pos`.
|
||||
pub(crate) fn be_u32(b: &[u8], pos: usize) -> Option<u32> {
|
||||
let s: [u8; 4] = b.get(pos..pos.checked_add(4)?)?.try_into().ok()?;
|
||||
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()
|
||||
}
|
||||
|
||||
/// How the samples in a PCM byte stream are encoded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PcmKind {
|
||||
/// Signed (or, for 1-byte WAV, unsigned) integers occupying `bytes` bytes each, left-justified.
|
||||
Int { bytes: usize, big_endian: bool, unsigned: bool },
|
||||
/// IEEE float, 4 or 8 bytes.
|
||||
Float { bytes: usize, big_endian: bool },
|
||||
}
|
||||
|
||||
impl PcmKind {
|
||||
pub(crate) fn bytes(self) -> usize {
|
||||
match self {
|
||||
PcmKind::Int { bytes, .. } | PcmKind::Float { bytes, .. } => bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one sample from exactly `kind.bytes()` bytes. Returns 0 for a short slice.
|
||||
fn decode_sample(s: &[u8], kind: PcmKind) -> f32 {
|
||||
match kind {
|
||||
PcmKind::Int { bytes, big_endian, unsigned } => {
|
||||
if bytes == 0 || bytes > 4 || s.len() < bytes {
|
||||
return 0.0;
|
||||
}
|
||||
let mut acc: u32 = 0;
|
||||
for i in 0..bytes {
|
||||
let idx = if big_endian { i } else { bytes - 1 - i };
|
||||
let mut byte = s.get(idx).copied().unwrap_or(0);
|
||||
if unsigned && i == 0 {
|
||||
byte ^= 0x80;
|
||||
}
|
||||
acc = (acc << 8) | u32::from(byte);
|
||||
}
|
||||
let shifted = acc << (32 - 8 * bytes as u32);
|
||||
(f64::from(shifted as i32) / 2_147_483_648.0) as f32
|
||||
}
|
||||
PcmKind::Float { bytes, big_endian } => {
|
||||
let v = match bytes {
|
||||
4 => {
|
||||
let Some(a) = s.get(..4).and_then(|x| <[u8; 4]>::try_from(x).ok()) else { return 0.0 };
|
||||
if big_endian { f32::from_be_bytes(a) } else { f32::from_le_bytes(a) }
|
||||
}
|
||||
8 => {
|
||||
let Some(a) = s.get(..8).and_then(|x| <[u8; 8]>::try_from(x).ok()) else { return 0.0 };
|
||||
(if big_endian { f64::from_be_bytes(a) } else { f64::from_le_bytes(a) }) as f32
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
if v.is_finite() { v } else { 0.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deinterleave `frames` frames of `channels`-channel PCM from `data` into planar `f32`.
|
||||
pub(crate) fn deinterleave(data: &[u8], kind: PcmKind, channels: usize, frames: u64) -> Result<Vec<Vec<f32>>> {
|
||||
let bytes = kind.bytes();
|
||||
let block = bytes.checked_mul(channels).filter(|&b| b > 0).ok_or_else(|| AudioError::Malformed("zero-sized sample frame".into()))?;
|
||||
let available = (data.len() / block) as u64;
|
||||
let frames = frames.min(available);
|
||||
let total = frames.checked_mul(channels as u64).ok_or_else(|| AudioError::TooLarge("sample count overflows".into()))?;
|
||||
if total > MAX_DECODE_SAMPLES {
|
||||
return Err(AudioError::TooLarge(format!("{total} samples exceeds the {MAX_DECODE_SAMPLES}-sample decode limit")));
|
||||
}
|
||||
let frames = usize::try_from(frames).map_err(|_| AudioError::TooLarge("frame count exceeds address space".into()))?;
|
||||
let mut out: Vec<Vec<f32>> = (0..channels).map(|_| Vec::with_capacity(frames)).collect();
|
||||
for frame in data.chunks_exact(block).take(frames) {
|
||||
for (ch, s) in out.iter_mut().zip(frame.chunks_exact(bytes)) {
|
||||
ch.push(decode_sample(s, kind));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Converts `f32` samples to integers of a given bit depth, clamping to [-1, 1] and optionally
|
||||
/// adding triangular (TPDF) dither of ±1 LSB.
|
||||
pub(crate) struct Quantizer {
|
||||
bits: u32,
|
||||
dither: bool,
|
||||
rng: u64,
|
||||
}
|
||||
|
||||
impl Quantizer {
|
||||
/// A quantizer for one channel; each channel gets an independent dither sequence.
|
||||
pub(crate) fn new(bits: u32, dither: bool, channel: usize) -> Self {
|
||||
let rng = 0x9E37_79B9_7F4A_7C15u64 ^ (channel as u64).wrapping_add(1).wrapping_mul(0xD1B5_4A32_D192_ED03);
|
||||
Self { bits: bits.clamp(2, 32), dither, rng: if rng == 0 { 1 } else { rng } }
|
||||
}
|
||||
|
||||
/// One quantizer per channel.
|
||||
pub(crate) fn per_channel(bits: u32, dither: bool, channels: usize) -> Vec<Self> {
|
||||
(0..channels).map(|c| Self::new(bits, dither, c)).collect()
|
||||
}
|
||||
|
||||
/// Uniform in [0, 1) from a xorshift64* generator (deterministic, no OS entropy needed).
|
||||
fn uniform(&mut self) -> f64 {
|
||||
let mut x = self.rng;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.rng = x;
|
||||
(x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
pub(crate) fn quantize(&mut self, x: f32) -> i32 {
|
||||
let x = if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) };
|
||||
let scale = (1u64 << (self.bits - 1)) as f64;
|
||||
let mut v = f64::from(x) * scale;
|
||||
if self.dither {
|
||||
v += self.uniform() - self.uniform();
|
||||
}
|
||||
let max = scale - 1.0;
|
||||
v.round().clamp(-scale, max) as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// Bits per sample at `depth`, and whether they are float.
|
||||
pub(crate) fn sample_format(depth: BitDepth) -> (u16, bool) {
|
||||
match depth {
|
||||
BitDepth::Int16 => (16, false),
|
||||
BitDepth::Int24 => (24, false),
|
||||
BitDepth::Int32 => (32, false),
|
||||
BitDepth::Float32 => (32, true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Interleaves `frames` frames of planar audio (missing samples are silence) as `bits`-bit
|
||||
/// samples and writes them to `out`, a few thousand frames at a time through `scratch`.
|
||||
pub(crate) fn write_interleaved(
|
||||
out: &mut impl Write,
|
||||
scratch: &mut Vec<u8>,
|
||||
block: &[Vec<f32>],
|
||||
frames: usize,
|
||||
quantizers: &mut [Quantizer],
|
||||
(bits, is_float): (u16, bool),
|
||||
big_endian: bool,
|
||||
) -> std::io::Result<()> {
|
||||
let size = usize::from(bits / 8);
|
||||
let mut start = 0;
|
||||
while start < frames {
|
||||
let end = frames.min(start + 4096);
|
||||
scratch.clear();
|
||||
for f in start..end {
|
||||
for (c, q) in quantizers.iter_mut().enumerate() {
|
||||
let s = block.get(c).and_then(|ch| ch.get(f)).copied().unwrap_or(0.0);
|
||||
match (is_float, big_endian) {
|
||||
(true, false) => scratch.extend_from_slice(&clamp_float(s).to_le_bytes()),
|
||||
(true, true) => scratch.extend_from_slice(&clamp_float(s).to_be_bytes()),
|
||||
(false, false) => scratch.extend_from_slice(q.quantize(s).to_le_bytes().get(..size).unwrap_or_default()),
|
||||
(false, true) => scratch.extend_from_slice(q.quantize(s).to_be_bytes().get(4 - size..).unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
out.write_all(scratch)?;
|
||||
start = end;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clamp a float sample to [-1, 1], mapping NaN to 0.
|
||||
pub(crate) fn clamp_float(x: f32) -> f32 {
|
||||
if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) }
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
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 channels > usize::from(u16::MAX) {
|
||||
return Err(AudioError::Encode("too many channels".into()));
|
||||
}
|
||||
if sample_rate == 0 {
|
||||
return Err(AudioError::Encode("sample rate is 0".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
//! Multi-resolution overview for waveform drawing.
|
||||
//!
|
||||
//! 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 spans [`Peaks::spans`] will produce.
|
||||
pub const MAX_COLUMNS: usize = 1 << 20;
|
||||
|
||||
/// 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<Block>>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
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 {
|
||||
if samples.is_empty() {
|
||||
return Peaks::default();
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
/// Number of samples the overview was built from.
|
||||
pub fn len_samples(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// Number of resolution levels (0 for an empty overview).
|
||||
pub fn num_levels(&self) -> usize {
|
||||
self.levels.len()
|
||||
}
|
||||
|
||||
/// (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);
|
||||
}
|
||||
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) {
|
||||
add(lo);
|
||||
lo += 1;
|
||||
}
|
||||
while !hi.is_multiple_of(FANOUT) {
|
||||
hi -= 1;
|
||||
add(hi);
|
||||
}
|
||||
lo /= FANOUT;
|
||||
hi /= FANOUT;
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn brute(samples: &[f32], start: usize, end: usize) -> (f32, f32) {
|
||||
let s = &samples[start..end];
|
||||
(s.iter().copied().fold(f32::INFINITY, f32::min), s.iter().copied().fold(f32::NEG_INFINITY, f32::max))
|
||||
}
|
||||
|
||||
fn signal(n: usize) -> Vec<f32> {
|
||||
(0..n).map(|i| ((i as f32 * 0.037).sin() * (i as f32 * 0.0011).cos()) + if i % 997 == 0 { 0.5 } else { 0.0 }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
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), (5, 6), (31, 33), (1000, 1031)] {
|
||||
assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}");
|
||||
}
|
||||
let mut seed = 12345u64;
|
||||
for _ in 0..2000 {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
let a = (seed >> 33) as usize % 100_003;
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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.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 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);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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))]);
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
//! Decode path for everything that isn't native PCM WAV/AIFF, backed by `symphonia`.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::{
|
||||
CODEC_TYPE_AAC, CODEC_TYPE_ADPCM_G722, CODEC_TYPE_ADPCM_G726, CODEC_TYPE_ADPCM_G726LE, CODEC_TYPE_ADPCM_IMA_QT, CODEC_TYPE_ADPCM_IMA_WAV,
|
||||
CODEC_TYPE_ADPCM_MS, CODEC_TYPE_ALAC, CODEC_TYPE_FLAC, CODEC_TYPE_MP1, CODEC_TYPE_MP2, CODEC_TYPE_MP3, CODEC_TYPE_NULL, CODEC_TYPE_OPUS,
|
||||
CODEC_TYPE_PCM_ALAW, CODEC_TYPE_PCM_F32BE, CODEC_TYPE_PCM_F32LE, CODEC_TYPE_PCM_F64BE, CODEC_TYPE_PCM_F64LE, CODEC_TYPE_PCM_MULAW,
|
||||
CODEC_TYPE_VORBIS, CodecParameters, CodecType, DecoderOptions,
|
||||
};
|
||||
use symphonia::core::errors::Error as SymError;
|
||||
use symphonia::core::formats::{FormatOptions, FormatReader};
|
||||
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ReadBytes};
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Instantiate;
|
||||
|
||||
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;
|
||||
|
||||
fn map_err(e: SymError) -> AudioError {
|
||||
match e {
|
||||
SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()),
|
||||
SymError::LimitError(m) => AudioError::TooLarge(m.to_string()),
|
||||
other => AudioError::Malformed(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f`, turning a panic inside the third-party decoder into an error.
|
||||
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())))
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
|
||||
/// Symphonia's probe loop (`Probe::format`, whose hint 0.5 ignores), checking the container it
|
||||
/// finds before its reader allocates whatever the file claims.
|
||||
fn open(bytes: &[u8]) -> Result<Opened> {
|
||||
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
|
||||
let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() };
|
||||
loop {
|
||||
let found = symphonia::default::get_probe().next(&mut mss).map_err(map_err)?;
|
||||
let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX);
|
||||
let head = bytes.get(at..).unwrap_or_default();
|
||||
match (found, head.get(..10)) {
|
||||
(Instantiate::Format(reader), _) => {
|
||||
match head.get(..4) {
|
||||
Some(b"caff") => crate::caf::check(bytes, at)?,
|
||||
Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?,
|
||||
Some(b"fLaC") => {
|
||||
crate::flac::check_metadata(bytes, at)?;
|
||||
if let Some(info) = crate::flac::empty_stream_info(bytes, at) {
|
||||
return Ok(Opened::Empty(info));
|
||||
}
|
||||
}
|
||||
Some(b"RIFF") => crate::wav::check_info(bytes, at)?,
|
||||
_ => {}
|
||||
}
|
||||
return reader(mss, &fmt_opts).map(Opened::Reader).map_err(map_err);
|
||||
}
|
||||
// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims: skip it.
|
||||
(Instantiate::Metadata(_), Some([b'I', b'D', b'3', _, _, _, size @ ..])) => {
|
||||
let size = size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f));
|
||||
mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?;
|
||||
}
|
||||
(Instantiate::Metadata(tag), _) => {
|
||||
tag(&MetadataOptions::default()).read_all(&mut mss).map_err(map_err)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_from_codec(codec: CodecType, 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,
|
||||
_ => FileFormat::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_format_of(p: &CodecParameters) -> SampleFormat {
|
||||
let c = p.codec;
|
||||
if c == CODEC_TYPE_PCM_F32LE || c == CODEC_TYPE_PCM_F32BE {
|
||||
return SampleFormat::Float32;
|
||||
}
|
||||
if c == CODEC_TYPE_PCM_F64LE || c == CODEC_TYPE_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,
|
||||
];
|
||||
let lossless = !lossy.contains(&c);
|
||||
match (lossless, p.bits_per_sample) {
|
||||
(true, Some(1..=8)) => SampleFormat::Int8,
|
||||
(true, Some(9..=16)) => SampleFormat::Int16,
|
||||
(true, Some(17..=24)) => SampleFormat::Int24,
|
||||
(true, Some(25..=32)) => SampleFormat::Int32,
|
||||
_ => SampleFormat::Compressed,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
.ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
|
||||
let p = &track.codec_params;
|
||||
let sample_rate = p.sample_rate.filter(|&r| r > 0).ok_or_else(|| AudioError::Malformed("unknown sample rate".into()))?;
|
||||
let channels = p.channels.map(|c| c.count()).unwrap_or(0);
|
||||
let channels = u16::try_from(channels).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
|
||||
Ok((
|
||||
track.id,
|
||||
AudioInfo {
|
||||
format: format_from_codec(p.codec, detected),
|
||||
sample_format: sample_format_of(p),
|
||||
sample_rate,
|
||||
channels,
|
||||
frames: p.n_frames.unwrap_or(0),
|
||||
bwf: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Decode the whole first audio track. When `count_only`, samples are counted but not stored.
|
||||
fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
|
||||
let mut reader = match open(bytes)? {
|
||||
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 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 alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_TYPE_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(¶ms, &DecoderOptions::default()).map_err(map_err)?;
|
||||
|
||||
let mut planar: Vec<Vec<f32>> = Vec::new();
|
||||
let mut frames: u64 = 0;
|
||||
let mut sbuf: Option<SampleBuffer<f32>> = None;
|
||||
let mut errors = 0usize;
|
||||
loop {
|
||||
let packet = match reader.next_packet() {
|
||||
Ok(p) => p,
|
||||
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}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
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}")));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(SymError::IoError(_)) => break,
|
||||
Err(e) => return Err(map_err(e)),
|
||||
};
|
||||
let spec = *decoded.spec();
|
||||
let ch = spec.channels.count();
|
||||
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();
|
||||
}
|
||||
let n = decoded.frames() as u64;
|
||||
frames = frames.saturating_add(n);
|
||||
if frames.saturating_mul(ch as u64) > MAX_DECODE_SAMPLES {
|
||||
return Err(AudioError::TooLarge("decoded audio exceeds the 4 GiB limit".into()));
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
if info.channels == 0 {
|
||||
return Err(AudioError::Malformed("stream contains no audio".into()));
|
||||
}
|
||||
if planar.is_empty() {
|
||||
planar = (0..usize::from(info.channels)).map(|_| Vec::new()).collect();
|
||||
}
|
||||
info.frames = if count_only { frames } else { planar.iter().map(Vec::len).min().unwrap_or(0) as u64 };
|
||||
let sample_rate = info.sample_rate;
|
||||
Ok((info, AudioBuffer { sample_rate, channels: planar }))
|
||||
}
|
||||
|
||||
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
|
||||
guarded(|| {
|
||||
let reader = match open(bytes)? {
|
||||
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, detected, true).map(|(i, _)| i)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> {
|
||||
guarded(|| run(bytes, detected, false))
|
||||
}
|
||||
@@ -1,525 +0,0 @@
|
||||
//! 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, 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];
|
||||
const BEXT_MIN_LEN: usize = 602;
|
||||
|
||||
/// Parsed WAV layout (no sample data decoded yet).
|
||||
pub(crate) struct WavLayout {
|
||||
pub info: AudioInfo,
|
||||
pub kind: PcmKind,
|
||||
pub data_start: usize,
|
||||
pub data_len: usize,
|
||||
}
|
||||
|
||||
/// Outcome of parsing a WAV header.
|
||||
pub(crate) enum WavParse {
|
||||
/// A PCM / float layout we decode natively.
|
||||
Native(WavLayout),
|
||||
/// A valid WAV whose codec we hand to symphonia (ADPCM, A-law, ...).
|
||||
Foreign { bwf: Option<BwfInfo> },
|
||||
}
|
||||
|
||||
struct Fmt {
|
||||
tag: u16,
|
||||
channels: u16,
|
||||
sample_rate: u32,
|
||||
block_align: u16,
|
||||
bits: u16,
|
||||
valid_bits: u16,
|
||||
}
|
||||
|
||||
fn parse_fmt(c: &[u8]) -> Result<Fmt> {
|
||||
let short = || AudioError::Malformed("fmt chunk too short".into());
|
||||
let mut fmt = Fmt {
|
||||
tag: le_u16(c, 0).ok_or_else(short)?,
|
||||
channels: le_u16(c, 2).ok_or_else(short)?,
|
||||
sample_rate: le_u32(c, 4).ok_or_else(short)?,
|
||||
block_align: le_u16(c, 12).ok_or_else(short)?,
|
||||
bits: le_u16(c, 14).unwrap_or(0),
|
||||
valid_bits: 0,
|
||||
};
|
||||
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 {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into()));
|
||||
}
|
||||
}
|
||||
Ok(fmt)
|
||||
}
|
||||
|
||||
/// Read a NUL-padded ASCII field.
|
||||
fn fixed_str(b: &[u8]) -> String {
|
||||
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
|
||||
String::from_utf8_lossy(b.get(..end).unwrap_or_default()).trim_end().to_string()
|
||||
}
|
||||
|
||||
fn parse_bext(c: &[u8]) -> Option<BwfInfo> {
|
||||
if c.len() < 346 {
|
||||
return None;
|
||||
}
|
||||
let lo = u64::from(le_u32(c, 338)?);
|
||||
let hi = u64::from(le_u32(c, 342)?);
|
||||
Some(BwfInfo {
|
||||
description: fixed_str(c.get(0..256)?),
|
||||
originator: fixed_str(c.get(256..288)?),
|
||||
originator_reference: fixed_str(c.get(288..320)?),
|
||||
origination_date: fixed_str(c.get(320..330)?),
|
||||
origination_time: fixed_str(c.get(330..338)?),
|
||||
time_reference: (hi << 32) | lo,
|
||||
})
|
||||
}
|
||||
|
||||
/// Refuse the WAVE file at `at`, which symphonia reads (A-law, ADPCM, ...), if an INFO entry
|
||||
/// claims more bytes than the file holds: symphonia allocates each entry's length. Chunks are
|
||||
/// walked where symphonia reads them: after the bytes a chunk's parser used, not its length.
|
||||
pub(crate) fn check_info(b: &[u8], at: usize) -> Result<()> {
|
||||
if tag(b, at + 8) != Some(*b"WAVE") {
|
||||
return Ok(());
|
||||
}
|
||||
let mut pos = at + 12;
|
||||
while let Some(id) = tag(b, pos) {
|
||||
let Some(len) = le_u32(b, pos + 4).map(|n| n as usize) else { break };
|
||||
let (body, pad) = (pos + 8, len & 1);
|
||||
let used = match &id {
|
||||
b"data" => break,
|
||||
b"fmt " => fmt_used(b, body, len),
|
||||
b"LIST" if len < 4 => break,
|
||||
b"LIST" => {
|
||||
let entries = if tag(b, body) == Some(*b"INFO") { info_entries(b, body + 4, len - 4)? } else { 0 };
|
||||
// The list pads itself, then the chunk is padded again.
|
||||
4 + entries.max(len - 4) + pad
|
||||
}
|
||||
_ => len,
|
||||
};
|
||||
pos = body.saturating_add(used).saturating_add(pad);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The bytes symphonia reads of a `fmt ` chunk: some formats' parsers go by their own extra size.
|
||||
fn fmt_used(b: &[u8], body: usize, len: usize) -> usize {
|
||||
match le_u16(b, body) {
|
||||
Some(2 | 6 | 7 | 0x11) => le_u16(b, body + 16).map_or(len, |extra| 18 + usize::from(extra)),
|
||||
Some(0xfffe) => 40,
|
||||
_ => len,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the INFO entries in the `len` bytes from `start`; returns the bytes symphonia reads of
|
||||
/// them, with the pad it reads after an odd entry.
|
||||
fn info_entries(b: &[u8], start: usize, len: usize) -> Result<usize> {
|
||||
let mut used = 0;
|
||||
while used + 8 <= len {
|
||||
let Some(n) = le_u32(b, start + used + 4).map(|n| n as usize) else { break };
|
||||
if n > b.len().saturating_sub(start + used + 8) {
|
||||
return Err(AudioError::Malformed("a WAV INFO entry is larger than the file".into()));
|
||||
}
|
||||
used += 8 + n;
|
||||
used += used & 1;
|
||||
}
|
||||
Ok(used)
|
||||
}
|
||||
|
||||
/// True when the bytes start like a RIFF/RF64/BW64 WAVE file.
|
||||
pub(crate) fn is_wav(b: &[u8]) -> bool {
|
||||
matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE")
|
||||
}
|
||||
|
||||
/// True when everything from `from` to the end of the file is whole RIFF chunks (four printable
|
||||
/// ASCII id bytes, a size that fits, the pad byte after an odd size; the last chunk's pad may be
|
||||
/// missing): the metadata of a finished file, not samples.
|
||||
fn only_chunks_follow(b: &[u8], from: usize) -> bool {
|
||||
let mut pos = from;
|
||||
while pos < b.len() {
|
||||
let (Some(id), Some(size)) = (tag(b, pos), le_u32(b, pos + 4)) else { return false };
|
||||
if !id.iter().all(|c| (0x20..0x7f).contains(c)) {
|
||||
return false;
|
||||
}
|
||||
let size = size as usize;
|
||||
let Some(end) = pos.checked_add(8).and_then(|body| body.checked_add(size)) else { return false };
|
||||
if end > b.len() {
|
||||
return false;
|
||||
}
|
||||
let next = end + (size & 1);
|
||||
if end == b.len() || next == b.len() {
|
||||
return true;
|
||||
}
|
||||
pos = next;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
|
||||
if !is_wav(b) {
|
||||
return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into()));
|
||||
}
|
||||
let is_rf64 = tag(b, 0).is_some_and(|t| &t != b"RIFF");
|
||||
let mut ds64_data: Option<u64> = None;
|
||||
let mut fmt: Option<Fmt> = None;
|
||||
let mut bwf: Option<BwfInfo> = None;
|
||||
let mut data: Option<(usize, usize)> = None;
|
||||
|
||||
let mut pos: usize = 12;
|
||||
// Bound the number of chunks we look at so a file of tiny chunks can't spin forever.
|
||||
let mut chunks = 0usize;
|
||||
while let (Some(id), Some(size32)) = (tag(b, pos), le_u32(b, pos + 4)) {
|
||||
chunks += 1;
|
||||
if chunks > 100_000 {
|
||||
break;
|
||||
}
|
||||
let body = pos.checked_add(8).ok_or_else(|| AudioError::Malformed("chunk offset overflow".into()))?;
|
||||
let remaining = b.len().saturating_sub(body);
|
||||
let mut size = u64::from(size32);
|
||||
if &id == b"data" && is_rf64 && size32 == u32::MAX {
|
||||
size = ds64_data.unwrap_or(remaining as u64);
|
||||
}
|
||||
let declared = usize::try_from(size).unwrap_or(usize::MAX);
|
||||
if &id == b"data" {
|
||||
// Lenient: a truncated recording (or a streaming writer's 0 / 0xFFFFFFFF size) still
|
||||
// yields whatever audio is actually present.
|
||||
// A size of 0 in a file whose RIFF size is right and that has only chunks after it is an
|
||||
// empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording
|
||||
// that was never finalised, and what follows is audio.
|
||||
let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;
|
||||
let empty = size32 == 0 && !is_rf64 && finished && only_chunks_follow(b, body);
|
||||
let len = if size32 == 0 && !is_rf64 && !empty { remaining } else { declared.min(remaining) };
|
||||
if data.is_none() {
|
||||
data = Some((body, len));
|
||||
}
|
||||
if declared > remaining {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if declared > remaining {
|
||||
// A truncated non-audio chunk at the end of the file: stop scanning.
|
||||
break;
|
||||
}
|
||||
let c = b.get(body..body + declared).unwrap_or_default();
|
||||
match &id {
|
||||
b"fmt " => fmt = Some(parse_fmt(c)?),
|
||||
b"bext" => bwf = parse_bext(c),
|
||||
b"ds64" => ds64_data = le_u64(c, 8),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let padded = declared.saturating_add(declared & 1);
|
||||
pos = match body.checked_add(padded) {
|
||||
Some(p) => p,
|
||||
None => break,
|
||||
};
|
||||
}
|
||||
|
||||
let fmt = fmt.ok_or_else(|| AudioError::Malformed("WAV has no fmt chunk".into()))?;
|
||||
if fmt.channels == 0 {
|
||||
return Err(AudioError::Malformed("WAV declares zero channels".into()));
|
||||
}
|
||||
if fmt.sample_rate == 0 {
|
||||
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 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);
|
||||
}
|
||||
let (data_start, data_len) = data.ok_or_else(|| AudioError::Malformed("WAV has no data chunk".into()))?;
|
||||
let frames = (data_len / block) as u64;
|
||||
Ok(WavParse::Native(WavLayout {
|
||||
info: AudioInfo { format: FileFormat::Wav, sample_format, sample_rate: fmt.sample_rate, channels: fmt.channels, frames, bwf },
|
||||
kind,
|
||||
data_start,
|
||||
data_len,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn decode(b: &[u8], layout: &WavLayout) -> Result<AudioBuffer> {
|
||||
let end = layout.data_start.saturating_add(layout.data_len).min(b.len());
|
||||
let data = b.get(layout.data_start..end).unwrap_or_default();
|
||||
let channels = deinterleave(data, layout.kind, usize::from(layout.info.channels), layout.info.frames)?;
|
||||
Ok(AudioBuffer { sample_rate: layout.info.sample_rate, channels })
|
||||
}
|
||||
|
||||
/// Default speaker mask for a channel count (callers that know the layout pass their own):
|
||||
/// mono, stereo, LCR, quad, 5.0, 5.1, 6.1, 7.1 (sides + rears), 7.1.4.
|
||||
fn channel_mask(channels: usize) -> u32 {
|
||||
match channels {
|
||||
1 => 0x4,
|
||||
2 => 0x3,
|
||||
3 => 0x7,
|
||||
4 => 0x33,
|
||||
5 => 0x37,
|
||||
6 => 0x3F,
|
||||
7 => 0x13F,
|
||||
8 => 0x63F,
|
||||
// L R C LFE Lrs Rrs Lss Rss Ltf Rtf Ltr Rtr (7.1.4).
|
||||
12 => 0x2_D63F,
|
||||
n if n <= 18 => (1u32 << n) - 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `dwChannelMask` of a WAVE_FORMAT_EXTENSIBLE file (None for plain PCM/float headers).
|
||||
pub(crate) fn read_channel_mask(b: &[u8]) -> Option<u32> {
|
||||
if !is_wav(b) {
|
||||
return None;
|
||||
}
|
||||
let mut pos = 12usize;
|
||||
for _ in 0..10_000 {
|
||||
let (id, size) = (tag(b, pos)?, usize::try_from(le_u32(b, pos + 4)?).ok()?);
|
||||
if &id == b"fmt " {
|
||||
return (le_u16(b, pos + 8)? == WAVE_FORMAT_EXTENSIBLE).then(|| le_u32(b, pos + 8 + 20)).flatten();
|
||||
}
|
||||
pos = pos.checked_add(8)?.checked_add(size)?.checked_add(size & 1)?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn put_fixed_str(out: &mut Vec<u8>, s: &str, len: usize) {
|
||||
let bytes = s.as_bytes();
|
||||
let n = bytes.len().min(len);
|
||||
out.extend_from_slice(bytes.get(..n).unwrap_or_default());
|
||||
out.resize(out.len() + (len - n), 0);
|
||||
}
|
||||
|
||||
fn bext_chunk(bwf: &BwfInfo) -> Vec<u8> {
|
||||
let mut c = Vec::with_capacity(BEXT_MIN_LEN);
|
||||
put_fixed_str(&mut c, &bwf.description, 256);
|
||||
put_fixed_str(&mut c, &bwf.originator, 32);
|
||||
put_fixed_str(&mut c, &bwf.originator_reference, 32);
|
||||
put_fixed_str(&mut c, &bwf.origination_date, 10);
|
||||
put_fixed_str(&mut c, &bwf.origination_time, 8);
|
||||
c.extend_from_slice(&((bwf.time_reference & 0xFFFF_FFFF) as u32).to_le_bytes());
|
||||
c.extend_from_slice(&((bwf.time_reference >> 32) as u32).to_le_bytes());
|
||||
c.extend_from_slice(&1u16.to_le_bytes()); // version 1
|
||||
c.resize(BEXT_MIN_LEN, 0); // UMID, loudness, reserved; no coding history
|
||||
c
|
||||
}
|
||||
|
||||
fn push_chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
|
||||
out.extend_from_slice(id);
|
||||
out.extend_from_slice(&u32::try_from(body.len()).unwrap_or(u32::MAX).to_le_bytes());
|
||||
out.extend_from_slice(body);
|
||||
if body.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A WAV written block by block: a placeholder header first, then the real one (the same length,
|
||||
/// RF64 above 4 GiB, or always when `force_rf64`) written over it by [`Writer::finish`].
|
||||
pub(crate) struct Writer<W> {
|
||||
out: W,
|
||||
start: u64,
|
||||
channels: usize,
|
||||
sample_rate: u32,
|
||||
format: (u16, bool),
|
||||
bwf: Option<BwfInfo>,
|
||||
mask: Option<u32>,
|
||||
force_rf64: bool,
|
||||
quantizers: Vec<Quantizer>,
|
||||
frames: u64,
|
||||
bytes: Vec<u8>,
|
||||
/// Set when a block write has failed: the sink may hold a prefix of that block while
|
||||
/// `frames` does not count it, so the writer refuses every later call.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Writer<W> {
|
||||
pub(crate) fn new(
|
||||
mut out: W,
|
||||
channels: usize,
|
||||
sample_rate: u32,
|
||||
depth: BitDepth,
|
||||
dither: bool,
|
||||
bwf: Option<&BwfInfo>,
|
||||
mask: Option<u32>,
|
||||
force_rf64: bool,
|
||||
) -> Result<Self> {
|
||||
validate_layout(channels, sample_rate)?;
|
||||
let format = sample_format(depth);
|
||||
let start = out.stream_position()?;
|
||||
out.write_all(&header(channels, sample_rate, format, bwf, mask, 0, force_rf64)?)?;
|
||||
let quantizers = Quantizer::per_channel(u32::from(format.0), dither, channels);
|
||||
Ok(Writer {
|
||||
out,
|
||||
start,
|
||||
channels,
|
||||
sample_rate,
|
||||
format,
|
||||
bwf: bwf.cloned(),
|
||||
mask,
|
||||
force_rf64,
|
||||
quantizers,
|
||||
frames: 0,
|
||||
bytes: Vec::new(),
|
||||
failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn header(&self, frames: u64) -> Result<Vec<u8>> {
|
||||
header(self.channels, self.sample_rate, self.format, self.bwf.as_ref(), self.mask, frames, self.force_rf64)
|
||||
}
|
||||
|
||||
/// The frame count after `frames` more (RF64 has room for any bounce).
|
||||
pub(crate) fn check_room(&self, frames: u64) -> Result<u64> {
|
||||
let total = self.frames.saturating_add(frames);
|
||||
self.header(total)?;
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Appends `frames` frames of planar audio (missing samples are silence).
|
||||
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let total = self.check_room(frames as u64)?;
|
||||
if let Err(e) = write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, self.format, false) {
|
||||
self.failed = true;
|
||||
return Err(AudioError::from(e));
|
||||
}
|
||||
self.frames = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut self) -> Result<W> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let data_len = u64::from(self.format.0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
|
||||
if data_len % 2 == 1 {
|
||||
self.out.write_all(&[0])?;
|
||||
}
|
||||
let head = self.header(self.frames)?;
|
||||
self.out.seek(SeekFrom::Start(self.start))?;
|
||||
self.out.write_all(&head)?;
|
||||
self.out.seek(SeekFrom::End(0))?;
|
||||
self.out.flush()?;
|
||||
Ok(self.out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a WAV file. `force_rf64` writes the RF64 layout even for small files (for tests);
|
||||
/// it is used automatically when the file would exceed 4 GiB.
|
||||
/// `mask` overrides the speaker mask derived from the channel count.
|
||||
pub(crate) fn encode(
|
||||
buf: &AudioBuffer,
|
||||
depth: BitDepth,
|
||||
dither: bool,
|
||||
bwf: Option<&BwfInfo>,
|
||||
force_rf64: bool,
|
||||
mask: Option<u32>,
|
||||
) -> Result<Vec<u8>> {
|
||||
validate_buffer(buf)?;
|
||||
let channels = buf.channels.len();
|
||||
let frames = buf.frames();
|
||||
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, 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 = 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(&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 {
|
||||
fmt.extend_from_slice(&22u16.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes());
|
||||
fmt.extend_from_slice(&fmt_tag.to_le_bytes());
|
||||
fmt.extend_from_slice(&GUID_TAIL);
|
||||
}
|
||||
|
||||
let mut head = Vec::with_capacity(1024);
|
||||
let rf64_body_len = 28usize;
|
||||
// Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place).
|
||||
let mut meta = Vec::new();
|
||||
if let Some(bwf) = bwf {
|
||||
push_chunk(&mut meta, b"bext", &bext_chunk(bwf));
|
||||
}
|
||||
push_chunk(&mut meta, b"fmt ", &fmt);
|
||||
if is_float {
|
||||
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
|
||||
}
|
||||
let total = ((4 + (8 + rf64_body_len) + meta.len() + 8) as u64)
|
||||
.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 {
|
||||
head.extend_from_slice(b"RF64");
|
||||
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.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 {
|
||||
head.extend_from_slice(b"RIFF");
|
||||
head.extend_from_slice(&(total as u32).to_le_bytes());
|
||||
head.extend_from_slice(b"WAVE");
|
||||
push_chunk(&mut head, b"JUNK", &[0u8; 28]);
|
||||
}
|
||||
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());
|
||||
Ok(head)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
use soundcraft_audio_io::{FileFormat, encode_format_for};
|
||||
|
||||
#[test]
|
||||
fn only_formats_the_encoder_writes_are_named() {
|
||||
assert_eq!(encode_format_for("wav"), Some(FileFormat::Wav));
|
||||
assert_eq!(encode_format_for("WAV"), Some(FileFormat::Wav));
|
||||
assert_eq!(encode_format_for(".aif"), Some(FileFormat::Aiff));
|
||||
assert_eq!(encode_format_for("aiff"), Some(FileFormat::Aiff));
|
||||
assert_eq!(encode_format_for("flac"), Some(FileFormat::Flac));
|
||||
for other in ["mp3", "ogg", "m4a", "xyz", ""] {
|
||||
assert_eq!(encode_format_for(other), None, "{other}");
|
||||
}
|
||||
}
|
||||
@@ -1,487 +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);
|
||||
}
|
||||
|
||||
fn caf(chunks: &[(&[u8; 4], i64, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut out = b"caff\0\x01\0\0".to_vec();
|
||||
for (kind, size, body) in chunks {
|
||||
out.extend_from_slice(*kind);
|
||||
out.extend_from_slice(&size.to_be_bytes());
|
||||
out.extend_from_slice(body);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn caf_desc(format: &[u8; 4], bytes_per_packet: u32, frames_per_packet: u32, channels: u32, bits: u32) -> (&'static [u8; 4], i64, Vec<u8>) {
|
||||
let mut d = 48_000f64.to_be_bytes().to_vec();
|
||||
d.extend_from_slice(format);
|
||||
for v in [0, bytes_per_packet, frames_per_packet, channels, bits] {
|
||||
d.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
(b"desc", 32, d)
|
||||
}
|
||||
|
||||
fn packet_table(packets: i64, frames: i64, entries: &[u8]) -> Vec<u8> {
|
||||
[&packets.to_be_bytes()[..], &frames.to_be_bytes(), &[0; 8], entries].concat()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caf_sizes_beyond_the_file_are_errors_not_aborts() {
|
||||
let aac = || caf_desc(b"aac ", 0, 1024, 2, 0);
|
||||
let huge = 1i64 << 50;
|
||||
let cookie = caf(&[aac(), (b"kuki", huge, vec![0; 8])]);
|
||||
let files = [
|
||||
cookie.clone(),
|
||||
[vec![0; 100], cookie].concat(),
|
||||
caf(&[aac(), (b"data", -1, [&[0; 4][..], b"kuki", &huge.to_be_bytes()].concat())]),
|
||||
caf(&[aac(), (b"pakt", 24, packet_table(huge, 1024, &[])), (b"data", -1, vec![0; 16])]),
|
||||
caf(&[aac(), (b"pakt", 32, packet_table(1, 1024, &[0x82, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0])), (b"data", -1, vec![0; 64])]),
|
||||
caf(&[aac(), (b"pakt", 25, packet_table(1, 1024, &[0x80]))]),
|
||||
];
|
||||
for f in files {
|
||||
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
let constant = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"pakt", 25, packet_table(1, 1, &[1])), (b"data", -1, vec![0; 16])]);
|
||||
assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_formed_caf_still_reads() {
|
||||
let pcm: Vec<u8> = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect();
|
||||
let chan = [((100u32 << 16) | 1).to_be_bytes(), [0; 4], [0; 4]].concat();
|
||||
let data = [vec![0; 4], pcm].concat();
|
||||
let lpcm = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"chan", 12, chan), (b"free", 8, vec![0; 8]), (b"data", data.len() as i64, data)]);
|
||||
let (info, buf) = decode(&lpcm, None).unwrap();
|
||||
assert_eq!((info.format, buf.frames()), (FileFormat::Caf, 480));
|
||||
let table = packet_table(3, 3072, &[10, 0x81, 0x48, 5]);
|
||||
let aac = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"pakt", table.len() as i64, table), (b"data", 219, vec![0; 219])]);
|
||||
assert_eq!(probe(&aac, None).unwrap().frames, 3072);
|
||||
}
|
||||
|
||||
fn atom(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
[&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat()
|
||||
}
|
||||
|
||||
/// A mono 16-bit, 48 kHz audio sample entry of `version`, its version fields and codec atoms.
|
||||
fn sample_entry(kind: &[u8; 4], version: u8, fields: &[u8], codec: &[u8]) -> Vec<u8> {
|
||||
let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, version, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
|
||||
atom(kind, &[&base[..], &(48_000u32 << 16).to_be_bytes(), fields, codec].concat())
|
||||
}
|
||||
|
||||
/// An MP4 of `frames` 2-byte samples described by `entry`; `swap` may replace any version-0 atom.
|
||||
fn mp4(frames: u32, entry: &[u8], swap: impl Fn(&[u8; 4], Vec<u8>) -> Vec<u8>) -> Vec<u8> {
|
||||
let full = |kind: &[u8; 4], fields: &[u32]| {
|
||||
let body: Vec<u8> = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect();
|
||||
swap(kind, atom(kind, &body))
|
||||
};
|
||||
let moov = |offset: u32| {
|
||||
let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], entry].concat());
|
||||
let stbl =
|
||||
[stsd, full(b"stts", &[1, frames, 1]), full(b"stsc", &[1, 1, frames, 1]), full(b"stsz", &[2, frames]), full(b"stco", &[1, offset])];
|
||||
let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat();
|
||||
let mdia = [full(b"mdhd", &[0, 0, 48_000, frames, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)];
|
||||
let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat();
|
||||
atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, frames, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat())
|
||||
};
|
||||
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
|
||||
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
|
||||
let pcm: Vec<u8> = (0..frames as i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
|
||||
[ftyp, moov(offset), atom(b"mdat", &pcm)].concat()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mp4_sizes_beyond_the_file_are_errors_not_aborts() {
|
||||
let pcm = sample_entry(b"sowt", 0, &[], &[]);
|
||||
let (_, buf) = decode(&mp4(480, &pcm, |_, a| a), None).unwrap();
|
||||
assert_eq!(buf.frames(), 480);
|
||||
let huge = |kind: &[u8; 4], a: Vec<u8>| [&1u32.to_be_bytes()[..], kind, &(1u64 << 60).to_be_bytes(), &a[8..]].concat();
|
||||
// The first field after version and flags: stts's entry count, stsz's sample size.
|
||||
let first_field_max = |mut a: Vec<u8>| {
|
||||
a[12..16].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
a
|
||||
};
|
||||
let sizes: Vec<u8> = (0..480u32).flat_map(|i| if i == 479 { u32::MAX } else { 2 }.to_be_bytes()).collect();
|
||||
let per_sample = atom(b"stsz", &[&[0; 8][..], &480u32.to_be_bytes(), &sizes].concat());
|
||||
// A header that starts inside its container is read whole, past the container's end.
|
||||
let mdia = atom(b"mdia", &1u32.to_be_bytes());
|
||||
let straddling = [&b"hdlr"[..], &(1u64 << 60).to_be_bytes(), &[0; 24]].concat();
|
||||
let files = [
|
||||
mp4(480, &pcm, |k, a| if k == b"hdlr" { huge(k, a) } else { a }),
|
||||
mp4(480, &pcm, |k, a| if k == b"stts" { first_field_max(a) } else { a }),
|
||||
mp4(480, &pcm, |k, a| if k == b"stsz" { first_field_max(a) } else { a }),
|
||||
mp4(480, &pcm, |k, a| if k == b"stsz" { per_sample.clone() } else { a }),
|
||||
[atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), atom(b"moov", &atom(b"trak", &mdia)), straddling].concat(),
|
||||
];
|
||||
for f in files {
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atoms_running_past_their_parent_are_walked_once() {
|
||||
// Each moov holds a trak that runs to the end of the file; symphonia stops after the first.
|
||||
let unit = atom(b"moov", &[&u32::MAX.to_be_bytes()[..], b"trak"].concat());
|
||||
let f = [atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), unit.repeat(1 << 18)].concat();
|
||||
assert!(decode(&f, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packets_longer_than_any_codec_uses_are_refused() {
|
||||
let be = |v: &[u32]| v.iter().flat_map(|x| x.to_be_bytes()).collect::<Vec<u8>>();
|
||||
let v2 = |frames: u32| [&be(&[72])[..], &48_000f64.to_be_bytes(), &be(&[1, 0x7f00_0000, 16, 4, 2, frames])].concat();
|
||||
let lpcm = |frames: u32| sample_entry(b"lpcm", 2, &v2(frames), &[]);
|
||||
let config = |frames: u32| [&be(&[0, frames])[..], &[0, 16, 40, 10, 14, 1, 0, 255], &be(&[0, 0, 48_000])].concat();
|
||||
let alac = |frames: u32| sample_entry(b"alac", 0, &[], &atom(b"alac", &config(frames)));
|
||||
let (_, buf) = decode(&mp4(480, &lpcm(1), |_, a| a), None).unwrap();
|
||||
assert_eq!(buf.frames(), 480);
|
||||
for entry in [lpcm(1 << 21), alac(1 << 21)] {
|
||||
assert!(matches!(decode(&mp4(480, &entry, |_, a| a), None), Err(AudioError::Malformed(m)) if m.contains("declares packets")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_metadata_fields_beyond_their_block_are_refused() {
|
||||
let with_block = |kind: u8, body: &[u8]| {
|
||||
let f = small(FileFormat::Flac, BitDepth::Int16, 1);
|
||||
let head = ((0x80 | u32::from(kind)) << 24 | body.len() as u32).to_be_bytes();
|
||||
[&f[..4], &[f[4] & 0x7f], &f[5..42], &head, body, &f[42..]].concat()
|
||||
};
|
||||
let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat();
|
||||
assert_eq!(decode(&with_block(6, &picture(3)), None).unwrap().1.frames(), 300);
|
||||
let comment = [&0u32.to_le_bytes()[..], &1u32.to_le_bytes(), &u32::MAX.to_le_bytes()].concat();
|
||||
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment)] {
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_id3_tag_before_the_audio_is_skipped_unread() {
|
||||
let frame = [&b"TIT2"[..], &0x7fff_ffffu32.to_be_bytes(), &[0, 0, 3, b'h', b'i', 0]].concat();
|
||||
let tag = [&b"ID3\x03\0\0\0\0\0"[..], &[frame.len() as u8], &frame].concat();
|
||||
let flac = small(FileFormat::Flac, BitDepth::Int16, 1);
|
||||
assert_eq!(decode(&[tag, flac].concat(), None).unwrap().1.frames(), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_info_entries_beyond_the_file_are_refused() {
|
||||
let alaw = |len: u32| {
|
||||
let info = [&b"INFO"[..], b"INAM", &len.to_le_bytes(), b"hi"].concat();
|
||||
riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])])
|
||||
};
|
||||
assert_eq!(decode(&alaw(2), None).unwrap().1.frames(), 800);
|
||||
let mut f = alaw(0x7fff_fff0);
|
||||
f[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let list = f.windows(4).position(|w| w == b"LIST").unwrap();
|
||||
f[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes());
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
use soundcraft_audio_io::{
|
||||
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 {
|
||||
let mut b = AudioBuffer::new(sr, channels, frames);
|
||||
for (c, ch) in b.channels.iter_mut().enumerate() {
|
||||
let f = 220.0 * (c as f32 + 1.0);
|
||||
for (i, s) in ch.iter_mut().enumerate() {
|
||||
*s = 0.8 * (2.0 * std::f32::consts::PI * f * i as f32 / sr as f32 + c as f32).sin();
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn tolerance(depth: BitDepth, dither: bool) -> f32 {
|
||||
let lsb = match depth {
|
||||
BitDepth::Int16 => 1.0 / 32768.0,
|
||||
BitDepth::Int24 => 1.0 / 8_388_608.0,
|
||||
BitDepth::Int32 => 1e-7,
|
||||
BitDepth::Float32 => 0.0,
|
||||
};
|
||||
lsb * if dither { 1.6 } else { 0.6 } + 1e-7
|
||||
}
|
||||
|
||||
fn max_err(a: &AudioBuffer, b: &AudioBuffer) -> f32 {
|
||||
assert_eq!(a.num_channels(), b.num_channels());
|
||||
assert_eq!(a.frames(), b.frames());
|
||||
a.channels.iter().zip(&b.channels).flat_map(|(x, y)| x.iter().zip(y).map(|(p, q)| (p - q).abs())).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
const DEPTHS: [BitDepth; 4] = [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32, BitDepth::Float32];
|
||||
|
||||
fn expected_sf(d: BitDepth) -> SampleFormat {
|
||||
match d {
|
||||
BitDepth::Int16 => SampleFormat::Int16,
|
||||
BitDepth::Int24 => SampleFormat::Int24,
|
||||
BitDepth::Int32 => SampleFormat::Int32,
|
||||
BitDepth::Float32 => SampleFormat::Float32,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_and_aiff_roundtrip_all_depths_and_layouts() {
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff] {
|
||||
for depth in DEPTHS {
|
||||
for channels in [1, 2, 6] {
|
||||
for dither in [false, true] {
|
||||
let src = sine(48_000, channels, 4801);
|
||||
let opts = EncodeOptions { format, bit_depth: depth, dither, bwf: None };
|
||||
let bytes = encode(&src, &opts).unwrap();
|
||||
assert_eq!(detect_format(&bytes, None), format);
|
||||
let info = probe(&bytes, None).unwrap();
|
||||
assert_eq!(info.format, format);
|
||||
assert_eq!(info.sample_rate, 48_000);
|
||||
assert_eq!(info.channels as usize, channels);
|
||||
assert_eq!(info.frames, 4801);
|
||||
assert_eq!(info.sample_format, expected_sf(depth));
|
||||
let (dinfo, out) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(dinfo, info);
|
||||
assert_eq!(out.sample_rate, 48_000);
|
||||
let err = max_err(&src, &out);
|
||||
assert!(err <= tolerance(depth, dither), "{format:?} {depth:?} {channels}ch dither={dither}: err {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_roundtrip_is_lossless_vs_wav() {
|
||||
for depth in [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32] {
|
||||
for channels in [1, 2, 6] {
|
||||
// Spans several blocks plus a short final block.
|
||||
let src = sine(44_100, channels, 4096 * 2 + 17);
|
||||
let wav = encode(&src, &EncodeOptions { format: FileFormat::Wav, bit_depth: depth, dither: false, bwf: None }).unwrap();
|
||||
let flac = encode(&src, &EncodeOptions { format: FileFormat::Flac, bit_depth: depth, dither: false, bwf: None }).unwrap();
|
||||
assert_eq!(detect_format(&flac, None), FileFormat::Flac);
|
||||
let info = probe(&flac, None).unwrap();
|
||||
assert_eq!(info.format, FileFormat::Flac);
|
||||
assert_eq!((info.sample_rate, info.channels as usize, info.frames), (44_100, channels, src.frames() as u64));
|
||||
assert_eq!(info.sample_format, expected_sf(depth));
|
||||
let (_, from_wav) = decode(&wav, None).unwrap();
|
||||
let (finfo, from_flac) = decode(&flac, None).unwrap();
|
||||
assert_eq!(finfo.frames, src.frames() as u64);
|
||||
assert_eq!(from_flac, from_wav, "{depth:?} {channels}ch");
|
||||
if depth != BitDepth::Int32 {
|
||||
assert!(flac.len() < wav.len(), "{depth:?} {channels}ch: flac {} >= wav {}", flac.len(), wav.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_handles_silence_noise_full_scale_and_odd_rates() {
|
||||
let frames = 10_000;
|
||||
let mut b = AudioBuffer::new(37_123, 3, frames);
|
||||
let mut seed = 1u32;
|
||||
for i in 0..frames {
|
||||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
b.channels[1][i] = (seed as f32 / u32::MAX as f32) * 2.0 - 1.0; // white noise
|
||||
b.channels[2][i] = if i % 2 == 0 { 1.5 } else { -1.5 }; // clipped full-scale square
|
||||
}
|
||||
for depth in [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32] {
|
||||
for dither in [false, true] {
|
||||
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: depth, dither, bwf: None };
|
||||
let flac = encode(&b, &opts).unwrap();
|
||||
let (info, out) = decode(&flac, None).unwrap();
|
||||
assert_eq!(info.sample_rate, 37_123);
|
||||
let wav = encode(&b, &EncodeOptions { format: FileFormat::Wav, ..opts }).unwrap();
|
||||
let (_, expect) = decode(&wav, None).unwrap();
|
||||
assert_eq!(out, expect, "{depth:?} dither={dither}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_rejects_unsupported_layouts() {
|
||||
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
assert!(encode(&sine(48_000, 2, 10), &opts).is_err());
|
||||
let opts = EncodeOptions { bit_depth: BitDepth::Int16, ..opts };
|
||||
assert!(encode(&sine(48_000, 9, 10), &opts).is_err());
|
||||
assert!(encode(&sine(700_000, 1, 10), &opts).is_err());
|
||||
// Tiny buffers still encode and decode.
|
||||
let one = sine(48_000, 2, 1);
|
||||
let (_, out) = decode(&encode(&one, &opts).unwrap(), None).unwrap();
|
||||
assert_eq!(out.frames(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bwf_roundtrip() {
|
||||
let bwf = BwfInfo {
|
||||
description: "Scene 12 take 3".into(),
|
||||
originator: "SoundCraft".into(),
|
||||
originator_reference: "SC-0001".into(),
|
||||
origination_date: "2026-10-06".into(),
|
||||
origination_time: "12:34:56".into(),
|
||||
time_reference: 0x1_2345_6789, // > 32 bits
|
||||
};
|
||||
let src = sine(96_000, 2, 1000);
|
||||
for depth in DEPTHS {
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: depth, dither: false, bwf: Some(bwf.clone()) };
|
||||
let bytes = encode(&src, &opts).unwrap();
|
||||
let info = probe(&bytes, None).unwrap();
|
||||
assert_eq!(info.bwf.as_ref(), Some(&bwf));
|
||||
let (dinfo, _) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(dinfo.bwf, Some(bwf.clone()));
|
||||
// RF64 layout reads back identically.
|
||||
let rf64 = encode_wav_rf64(&src, &opts).unwrap();
|
||||
assert_eq!(&rf64[..4], b"RF64");
|
||||
let (rinfo, rbuf) = decode(&rf64, None).unwrap();
|
||||
assert_eq!(rinfo.bwf, Some(bwf.clone()));
|
||||
assert_eq!(rinfo.frames, 1000);
|
||||
assert_eq!(rbuf, decode(&bytes, None).unwrap().1);
|
||||
}
|
||||
// Overlong strings are truncated to the field width, not an error.
|
||||
let long = BwfInfo { description: "x".repeat(400), ..BwfInfo::default() };
|
||||
let bytes = encode(&src, &EncodeOptions { bwf: Some(long), ..EncodeOptions::default() }).unwrap();
|
||||
assert_eq!(probe(&bytes, None).unwrap().bwf.unwrap().description.len(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_header_layout() {
|
||||
let b = encode(&sine(44_100, 2, 10), &EncodeOptions { bit_depth: BitDepth::Int16, ..EncodeOptions::default() }).unwrap();
|
||||
// Plain PCM for 16-bit stereo; extensible otherwise.
|
||||
let fmt = b.windows(4).position(|w| w == b"fmt ").unwrap();
|
||||
assert_eq!(u16::from_le_bytes([b[fmt + 8], b[fmt + 9]]), 1);
|
||||
let b = encode(&sine(44_100, 2, 10), &EncodeOptions::default()).unwrap();
|
||||
let fmt = b.windows(4).position(|w| w == b"fmt ").unwrap();
|
||||
assert_eq!(u16::from_le_bytes([b[fmt + 8], b[fmt + 9]]), 0xFFFE);
|
||||
let riff = u32::from_le_bytes([b[4], b[5], b[6], b[7]]) as usize;
|
||||
assert_eq!(riff + 8, b.len());
|
||||
// Odd data length (24-bit mono, odd frames) gets a pad byte.
|
||||
let b = encode(&sine(44_100, 1, 3), &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(b.len() % 2, 0);
|
||||
assert_eq!(decode(&b, None).unwrap().1.frames(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_float_is_aifc() {
|
||||
let b =
|
||||
encode(&sine(44_100, 2, 10), &EncodeOptions { format: FileFormat::Aiff, bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap();
|
||||
assert_eq!(&b[8..12], b"AIFC");
|
||||
assert!(b.windows(4).any(|w| w == b"fl32"));
|
||||
let b = encode(&sine(44_100, 2, 10), &EncodeOptions { format: FileFormat::Aiff, ..EncodeOptions::default() }).unwrap();
|
||||
assert_eq!(&b[8..12], b"AIFF");
|
||||
let form = u32::from_be_bytes([b[4], b[5], b[6], b[7]]) as usize;
|
||||
assert_eq!(form + 8, b.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusual_sample_rates_roundtrip() {
|
||||
for sr in [1, 8_000, 11_025, 22_050, 44_100, 88_200, 176_400, 192_000, 352_800, 384_000, 768_000] {
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff] {
|
||||
let bytes = encode(&sine(sr, 1, 8), &EncodeOptions { format, ..EncodeOptions::default() }).unwrap();
|
||||
assert_eq!(probe(&bytes, None).unwrap().sample_rate, sr, "{format:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_rejects_bad_buffers_and_formats() {
|
||||
assert!(encode(&AudioBuffer::default(), &EncodeOptions::default()).is_err());
|
||||
assert!(encode(&AudioBuffer::new(0, 2, 10), &EncodeOptions::default()).is_err());
|
||||
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.
|
||||
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] {
|
||||
for depth in DEPTHS {
|
||||
if format == FileFormat::Flac && depth == BitDepth::Float32 {
|
||||
continue;
|
||||
}
|
||||
let bytes = encode(&b, &EncodeOptions { format, bit_depth: depth, ..EncodeOptions::default() }).unwrap();
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Ragged buffers encode the common length.
|
||||
let ragged = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.1; 10], vec![0.2; 7]] };
|
||||
let (_, out) = decode(&encode(&ragged, &EncodeOptions::default()).unwrap(), None).unwrap();
|
||||
assert_eq!(out.frames(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_buffer_helpers() {
|
||||
let b = AudioBuffer::from_interleaved(48_000, 2, &[0.1, -0.2, 0.3, -0.4, 0.5]);
|
||||
assert_eq!(b.frames(), 2);
|
||||
assert_eq!(b.num_channels(), 2);
|
||||
assert_eq!(b.interleaved(), vec![0.1, -0.2, 0.3, -0.4]);
|
||||
assert!((b.peak() - 0.4).abs() < 1e-7);
|
||||
assert!((b.duration_secs() - 2.0 / 48_000.0).abs() < 1e-12);
|
||||
assert_eq!(AudioBuffer::from_interleaved(1, 0, &[1.0]).num_channels(), 0);
|
||||
assert_eq!(AudioBuffer::new(0, 1, 5).duration_secs(), 0.0);
|
||||
assert_eq!(AudioBuffer::default().frames(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_types_roundtrip() {
|
||||
let opts = EncodeOptions { bwf: Some(BwfInfo { time_reference: 42, ..BwfInfo::default() }), ..EncodeOptions::default() };
|
||||
let s = serde_json::to_string(&opts).unwrap();
|
||||
assert_eq!(serde_json::from_str::<EncodeOptions>(&s).unwrap(), opts);
|
||||
let info = probe(&encode(&sine(48_000, 1, 4), &opts).unwrap(), None).unwrap();
|
||||
let s = serde_json::to_string(&info).unwrap();
|
||||
assert_eq!(serde_json::from_str::<soundcraft_audio_io::AudioInfo>(&s).unwrap(), info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multichannel_wav_speaker_masks() {
|
||||
let buf = |n: usize| AudioBuffer { sample_rate: 48_000, channels: (0..n).map(|c| vec![0.01 * c as f32; 64]).collect() };
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None };
|
||||
for (n, mask) in [(6usize, 0x3Fu32), (8, 0x63F), (12, 0x2_D63F)] {
|
||||
let bytes = soundcraft_audio_io::encode(&buf(n), &opts).unwrap();
|
||||
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(mask), "{n} channels");
|
||||
assert_eq!(soundcraft_audio_io::decode(&bytes, Some("wav")).unwrap().1.channels.len(), n);
|
||||
}
|
||||
// Explicit masks: 7.0 (sides + rears, no LFE) and Ambisonics (no positions).
|
||||
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(7), &opts, 0x637).unwrap();
|
||||
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(0x637));
|
||||
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(4), &opts, 0).unwrap();
|
||||
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(0));
|
||||
// Plain 16-bit stereo stays a plain PCM header.
|
||||
let o16 = EncodeOptions { bit_depth: BitDepth::Int16, ..opts };
|
||||
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(_))));
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -9,9 +9,15 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
soundcraft-engine.workspace = true
|
||||
craft-audio.workspace = true
|
||||
soundcraft-time.workspace = true
|
||||
serde_json.workspace = true
|
||||
log.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
# The launch flags (`args`): the web build has none.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
clap.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
//! The control and automation launch options, as clap types, so each flag and its environment
|
||||
//! variable are declared once for `soundcraft-cli` and the desktop app. A flag wins over its
|
||||
//! variable.
|
||||
//!
|
||||
//! Parse with [`blank_env_is_unset`] applied to the arguments, so an empty variable counts as unset,
|
||||
//! and pass a parse error through [`name_env_vars`], so a bad value names the variable it came from.
|
||||
//! [`conflict`] reports two arguments that can't go together, each named as it was given, for a
|
||||
//! conflict that depends on the mode, which clap's `conflicts_with` can't express.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use clap::error::{ContextKind, ContextValue, ErrorKind};
|
||||
use clap::parser::ValueSource;
|
||||
use clap::{Arg, ArgAction, ArgMatches, Args, Command};
|
||||
|
||||
use crate::ControlTarget;
|
||||
|
||||
/// The running app a client calls (`soundcraft-cli app`).
|
||||
#[derive(Args)]
|
||||
pub struct ControlClientArgs {
|
||||
/// The app's control port or host:port
|
||||
#[arg(long, value_name = "P", env = "SOUNDCRAFT_CONTROL_PORT", default_value = "7979", value_parser = control_target)]
|
||||
pub port: ControlTarget,
|
||||
}
|
||||
|
||||
/// The control server the desktop app starts. The same variable as [`ControlClientArgs`], so one
|
||||
/// export connects the two.
|
||||
#[derive(Args)]
|
||||
pub struct ControlServerArgs {
|
||||
/// Start the localhost JSON-lines control server on this port (0 picks a free port and prints
|
||||
/// it)
|
||||
// allow_negative_numbers makes `--control -1` a bad port rather than an unexpected argument.
|
||||
#[arg(long, value_name = "PORT", env = "SOUNDCRAFT_CONTROL_PORT", value_parser = control_target, allow_negative_numbers = true)]
|
||||
pub control: Option<ControlTarget>,
|
||||
}
|
||||
|
||||
/// The folders automation may touch. Once any root is given, files are confined to the roots, and
|
||||
/// a kind left out grants none of its access. No variable stands in for them: one left exported
|
||||
/// must not change what an agent may read or write.
|
||||
#[derive(Args)]
|
||||
pub struct AutomationArgs {
|
||||
/// A folder the agent's commands may read from; repeat for several
|
||||
#[arg(long, value_name = "DIR", action = ArgAction::Append)]
|
||||
pub automation_read_root: Vec<PathBuf>,
|
||||
/// A folder the agent's commands may write to; repeat for several
|
||||
#[arg(long, value_name = "DIR", action = ArgAction::Append)]
|
||||
pub automation_write_root: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// A [`ControlTarget`], for clap's `value_parser`: `soundcraft-cli app --port`, `mcp --connect`,
|
||||
/// `soundcraft --control`, and SOUNDCRAFT_CONTROL_PORT, which may carry spaces.
|
||||
pub fn control_target(s: &str) -> Result<ControlTarget, NotATarget> {
|
||||
let (host, port) = host_and_port(s.trim())?;
|
||||
Ok(ControlTarget { host: host.map(str::to_owned), port })
|
||||
}
|
||||
|
||||
/// `PORT` or `HOST:PORT`, split. An IPv6 host goes in brackets (`[::1]:7979`), as a socket address
|
||||
/// is written: in `::1:7979` the host and port can't be told apart.
|
||||
fn host_and_port(s: &str) -> Result<(Option<&str>, u16), NotATarget> {
|
||||
let (host, port) = match s.rsplit_once(':') {
|
||||
Some(("", _)) => return Err(NotATarget),
|
||||
Some((host, _)) if host.contains(':') && !(host.starts_with('[') && host.ends_with(']')) => return Err(NotATarget),
|
||||
Some((host, port)) => (Some(host), port),
|
||||
None => (None, s),
|
||||
};
|
||||
Ok((host, port.parse().map_err(|_| NotATarget)?))
|
||||
}
|
||||
|
||||
/// A [`control_target`] value that is neither a port nor `host:port`.
|
||||
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("expected a port, 0-65535, or host:port")]
|
||||
pub struct NotATarget;
|
||||
|
||||
/// `0.1.0 (linux x86_64)`: the workspace version and the platform the binary was built for. Every
|
||||
/// crate takes its version from the workspace, so the value is the same in both binaries. A
|
||||
/// `const` can't build it: `concat!` accepts only literals, not `std::env::consts`.
|
||||
pub fn long_version() -> &'static str {
|
||||
static LONG: OnceLock<String> = OnceLock::new();
|
||||
LONG.get_or_init(|| format!("{} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH))
|
||||
}
|
||||
|
||||
/// For `Command::mut_args`: an empty or blank variable counts as unset, so `SOUNDCRAFT_CONTROL_PORT=`
|
||||
/// turns a fallback off without `unset`. clap would pass the empty value to the parser.
|
||||
pub fn blank_env_is_unset(arg: Arg) -> Arg {
|
||||
let blank = arg.get_env().and_then(std::env::var_os).is_some_and(|v| v.to_str().is_some_and(|v| v.trim().is_empty()));
|
||||
if blank { arg.env(None::<&str>) } else { arg }
|
||||
}
|
||||
|
||||
/// clap names the flag when a value from its variable is bad or conflicts with another, as if the
|
||||
/// flag had been given: name the variable instead when the flag isn't among `args` (without the
|
||||
/// binary name; a first one naming a subcommand selects whose arguments to look in).
|
||||
pub fn name_env_vars(mut error: clap::Error, cmd: &Command, args: &[OsString]) -> clap::Error {
|
||||
// `--flag value` or `--flag=value`.
|
||||
let given =
|
||||
|long: &str| args.iter().filter_map(|a| a.to_str()?.strip_prefix("--")?.strip_prefix(long)).any(|r| r.is_empty() || r.starts_with('='));
|
||||
let sub = args.first().and_then(|name| cmd.find_subcommand(name)).unwrap_or(cmd);
|
||||
for kind in [ContextKind::InvalidArg, ContextKind::PriorArg] {
|
||||
let Some(ContextValue::String(shown)) = error.get(kind) else { continue };
|
||||
let Some(arg) = sub.get_arguments().find(|a| a.to_string() == *shown) else { continue };
|
||||
let (Some(long), Some(var)) = (arg.get_long(), arg.get_env()) else { continue };
|
||||
if !given(long) {
|
||||
error.insert(kind, ContextValue::String(var.to_string_lossy().into_owned()));
|
||||
}
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
/// clap's own conflict error when `m`, parsed by `cmd`, has both `a` and `b`, each named as it was
|
||||
/// given: the flag, or its variable.
|
||||
pub fn conflict(cmd: &mut Command, m: &ArgMatches, a: &str, b: &str) -> Result<(), clap::Error> {
|
||||
if m.value_source(a).is_none() || m.value_source(b).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let shown = |id: &str| {
|
||||
let arg = cmd.get_arguments().find(|arg| arg.get_id() == id);
|
||||
match (m.value_source(id), arg.and_then(Arg::get_env)) {
|
||||
(Some(ValueSource::EnvVariable), Some(var)) => var.to_string_lossy().into_owned(),
|
||||
_ => arg.map_or_else(|| id.to_owned(), |arg| arg.to_string()),
|
||||
}
|
||||
};
|
||||
let (shown_a, shown_b) = (shown(a), shown(b));
|
||||
let mut error = clap::Error::new(ErrorKind::ArgumentConflict).with_cmd(cmd);
|
||||
error.insert(ContextKind::InvalidArg, ContextValue::String(shown_a));
|
||||
error.insert(ContextKind::PriorArg, ContextValue::String(shown_b));
|
||||
error.insert(ContextKind::Usage, ContextValue::StyledStr(cmd.render_usage()));
|
||||
Err(error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn control_target_is_a_port_or_host_and_port() {
|
||||
let target = |host: Option<&str>, port| Ok(ControlTarget { host: host.map(str::to_owned), port });
|
||||
assert_eq!(control_target("7979"), target(None, 7979));
|
||||
assert_eq!(control_target(" 0 "), target(None, 0));
|
||||
assert_eq!(control_target("localhost:7979 "), target(Some("localhost"), 7979));
|
||||
assert_eq!(control_target("10.0.0.5:1"), target(Some("10.0.0.5"), 1));
|
||||
assert_eq!(control_target("[::1]:7979"), target(Some("[::1]"), 7979));
|
||||
for bad in ["", "x", "78787", ":7979", "host:", "host:x", "-1", "::1:7979", "[::1:7979"] {
|
||||
assert_eq!(control_target(bad), Err(NotATarget), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The control server listens on 127.0.0.1 only: `[::1]` is another address.
|
||||
#[test]
|
||||
fn a_loopback_target_is_a_port_127_0_0_1_or_localhost() {
|
||||
for loopback in ["50494", "127.0.0.1:7979", "localhost:7979"] {
|
||||
assert!(control_target(loopback).is_ok_and(|t| t.is_loopback()), "{loopback:?}");
|
||||
}
|
||||
for remote in ["10.0.0.5:7979", "example.com:7979", "[::1]:7979"] {
|
||||
assert!(control_target(remote).is_ok_and(|t| !t.is_loopback()), "{remote:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_target_displays_as_a_socket_address() {
|
||||
for (given, shown) in [("7979", "127.0.0.1:7979"), ("localhost:1", "localhost:1"), ("[::1]:7979", "[::1]:7979")] {
|
||||
assert_eq!(control_target(given).map(|t| t.to_string()), Ok(shown.to_owned()), "{given:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A command with one port flag backed by a variable no test sets.
|
||||
fn port_command() -> Command {
|
||||
Command::new("t").arg(Arg::new("port").long("port").env("SOUNDCRAFT_TEST_UNSET_PORT").value_parser(control_target))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_env_vars_names_the_variable_only_when_the_flag_was_not_given() {
|
||||
let mut cmd = port_command();
|
||||
let e = cmd.try_get_matches_from_mut(["t", "--port", "x"]).unwrap_err();
|
||||
let flag = name_env_vars(e, &cmd, &["--port".into(), "x".into()]);
|
||||
assert_eq!(flag.get(ContextKind::InvalidArg), Some(&ContextValue::String("--port <port>".into())));
|
||||
let e = cmd.try_get_matches_from_mut(["t", "--port=x"]).unwrap_err();
|
||||
// As the error from the variable would read: the flag is not among the arguments.
|
||||
let var = name_env_vars(e, &cmd, &[]);
|
||||
assert_eq!(var.get(ContextKind::InvalidArg), Some(&ContextValue::String("SOUNDCRAFT_TEST_UNSET_PORT".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_env_is_unset_keeps_an_unset_variable() {
|
||||
let arg = blank_env_is_unset(Arg::new("port").env("SOUNDCRAFT_TEST_UNSET_PORT"));
|
||||
assert_eq!(arg.get_env(), Some(std::ffi::OsStr::new("SOUNDCRAFT_TEST_UNSET_PORT")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_is_a_clap_usage_error_naming_both_flags() {
|
||||
let mut cmd = port_command().arg(Arg::new("connect").long("connect"));
|
||||
let m = cmd.try_get_matches_from_mut(["t", "--port", "1", "--connect", "2"]).unwrap();
|
||||
let e = conflict(&mut cmd, &m, "port", "connect").unwrap_err();
|
||||
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::ArgumentConflict, 2));
|
||||
assert!(e.to_string().starts_with("error: the argument '--port <port>' cannot be used with '--connect <connect>'\n\nUsage: t"), "{e}");
|
||||
let m = cmd.try_get_matches_from_mut(["t", "--port", "1"]).unwrap();
|
||||
assert!(conflict(&mut cmd, &m, "port", "connect").is_ok());
|
||||
}
|
||||
}
|
||||
+157
-42
@@ -5,15 +5,20 @@
|
||||
//! running app's `--control` port.
|
||||
//! - [`mcp::Server`]: a Model Context Protocol server over stdio (newline-delimited JSON-RPC 2.0)
|
||||
//! exposing SoundCraft as tools and resources.
|
||||
//! - [`args`]: the control and automation launch flags and their environment variables, as clap
|
||||
//! types.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod args;
|
||||
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::fmt;
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -79,54 +84,151 @@ impl Backend for Headless {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a running app's control channel listens: what `soundcraft-cli app --port`,
|
||||
/// `soundcraft-cli mcp --connect`, `soundcraft --control` and SOUNDCRAFT_CONTROL_PORT give, parsed
|
||||
/// once (`args::control_target`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ControlTarget {
|
||||
/// As given: a name, an IPv4 address, or an IPv6 address in brackets (`[::1]`). `None`:
|
||||
/// 127.0.0.1.
|
||||
pub host: Option<String>,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl ControlTarget {
|
||||
/// Can the desktop app's control server, which listens on 127.0.0.1 only, serve this target?
|
||||
/// True for no host, `127.0.0.1` and `localhost`; `[::1]` is another address.
|
||||
pub fn is_loopback(&self) -> bool {
|
||||
self.host.as_deref().is_none_or(|host| ["127.0.0.1", "localhost"].contains(&host))
|
||||
}
|
||||
}
|
||||
|
||||
/// `host:port`, as a socket address is written.
|
||||
impl fmt::Display for ControlTarget {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}:{}", self.host.as_deref().unwrap_or("127.0.0.1"), self.port)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a [`Remote`] request failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RemoteError {
|
||||
/// The host name didn't resolve.
|
||||
#[error("{addr}: {source}")]
|
||||
Resolve { addr: String, source: io::Error },
|
||||
#[error("cannot reach SoundCraft at {addr}: no addresses found")]
|
||||
NoAddress { addr: String },
|
||||
#[error("cannot reach SoundCraft at {addr}: {source}")]
|
||||
Unreachable { addr: String, source: io::Error },
|
||||
/// Reading or writing the connection failed.
|
||||
#[error(transparent)]
|
||||
Io(#[from] io::Error),
|
||||
/// The app closed the connection before it replied.
|
||||
#[error("connection closed")]
|
||||
Closed,
|
||||
/// The reply isn't JSON.
|
||||
#[error(transparent)]
|
||||
Reply(#[from] serde_json::Error),
|
||||
/// The app's reply to a request it refused.
|
||||
#[error("{0}")]
|
||||
App(String),
|
||||
/// The connection was lost after a request that changes state went out: it isn't repeated,
|
||||
/// since the app may already have executed it.
|
||||
#[error("{source}; `{method}` may or may not have been executed, check the session before repeating it")]
|
||||
Unconfirmed { method: String, source: Box<RemoteError> },
|
||||
}
|
||||
|
||||
impl RemoteError {
|
||||
/// The connection went away, so a fresh one may succeed.
|
||||
fn is_lost_connection(&self) -> bool {
|
||||
match self {
|
||||
RemoteError::Closed => true,
|
||||
RemoteError::Io(e) => matches!(e.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::ConnectionReset | io::ErrorKind::ConnectionAborted),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Connection = (TcpStream, BufReader<TcpStream>);
|
||||
|
||||
/// A TCP client for a running app's control channel.
|
||||
pub struct Remote {
|
||||
/// The target as `host:port`, for connecting and for messages.
|
||||
addr: String,
|
||||
conn: Option<(TcpStream, BufReader<TcpStream>)>,
|
||||
conn: Option<Connection>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// `target` is a port (`7979`) or `host:port`.
|
||||
pub fn new(target: &str) -> Self {
|
||||
let addr = if target.contains(':') { target.to_string() } else { format!("127.0.0.1:{target}") };
|
||||
Remote { addr, conn: None, next_id: 1 }
|
||||
/// A client for the app at `target`; it connects on the first request.
|
||||
pub fn new(target: &ControlTarget) -> Self {
|
||||
Remote { addr: target.to_string(), conn: None, next_id: 1 }
|
||||
}
|
||||
|
||||
fn connect(&mut self) -> Result<(), String> {
|
||||
if self.conn.is_some() {
|
||||
return Ok(());
|
||||
/// The open connection, connecting first when there is none.
|
||||
fn connection(&mut self) -> Result<&mut Connection, RemoteError> {
|
||||
match self.conn {
|
||||
Some(ref mut conn) => Ok(conn),
|
||||
None => Ok(self.conn.insert(connect(&self.addr)?)),
|
||||
}
|
||||
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))?;
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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))?;
|
||||
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, (RemoteError, bool)> {
|
||||
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_else(|| ("not connected".to_string(), false))?;
|
||||
writeln!(w, "{line}").map_err(|e| (e.to_string(), false))?;
|
||||
let (w, r) = self.connection().map_err(|e| (e, false))?;
|
||||
writeln!(w, "{line}").map_err(|e| (e.into(), false))?;
|
||||
let mut buf = String::new();
|
||||
r.read_line(&mut buf).map_err(|e| (e.to_string(), true))?;
|
||||
r.read_line(&mut buf).map_err(|e| (e.into(), true))?;
|
||||
if buf.is_empty() {
|
||||
return Err(("connection closed".into(), true));
|
||||
return Err((RemoteError::Closed, true));
|
||||
}
|
||||
let v: Value = serde_json::from_str(&buf).map_err(|e| (e.to_string(), true))?;
|
||||
let v: Value = serde_json::from_str(&buf).map_err(|e| (e.into(), 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(), true))
|
||||
Err((RemoteError::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string()), true))
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls `method`. A request lost with its connection is sent once more on a fresh one when
|
||||
/// it only reads state.
|
||||
pub fn request(&mut self, method: &str, params: Value) -> Result<Value, RemoteError> {
|
||||
match self.roundtrip(method, ¶ms) {
|
||||
Ok(v) => Ok(v),
|
||||
Err((e, sent)) if e.is_lost_connection() => {
|
||||
self.conn = None;
|
||||
if sent && !is_read_only(method) {
|
||||
return Err(RemoteError::Unconfirmed { method: method.to_owned(), source: Box::new(e) });
|
||||
}
|
||||
self.roundtrip(method, ¶ms).map_err(|(e, _)| e)
|
||||
}
|
||||
Err((e, _)) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects to `addr` (`host:port`), trying each address it resolves to.
|
||||
fn connect(addr: &str) -> Result<Connection, RemoteError> {
|
||||
let socks = addr.to_socket_addrs().map_err(|source| RemoteError::Resolve { addr: addr.to_owned(), source })?;
|
||||
let mut last = None;
|
||||
for sock in socks {
|
||||
match TcpStream::connect_timeout(&sock, Duration::from_millis(800)) {
|
||||
Ok(s) => {
|
||||
s.set_read_timeout(Some(Duration::from_secs(120)))?;
|
||||
let r = s.try_clone()?;
|
||||
return Ok((s, BufReader::new(r)));
|
||||
}
|
||||
Err(e) => last = Some(e),
|
||||
}
|
||||
}
|
||||
Err(match last {
|
||||
Some(source) => RemoteError::Unreachable { addr: addr.to_owned(), source },
|
||||
None => RemoteError::NoAddress { addr: addr.to_owned() },
|
||||
})
|
||||
}
|
||||
|
||||
/// Methods that only read state, so repeating them after a lost reply is harmless.
|
||||
@@ -136,19 +238,7 @@ fn is_read_only(method: &str) -> bool {
|
||||
|
||||
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, sent)) if e.contains("closed") || e.contains("Broken pipe") || e.contains("reset") => {
|
||||
self.conn = None;
|
||||
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),
|
||||
}
|
||||
self.request(method, params).map_err(|e| e.to_string())
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
true
|
||||
@@ -175,15 +265,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn remote_reports_unreachable() {
|
||||
let mut r = Remote::new("127.0.0.1:1");
|
||||
assert!(r.call("session.inspect", json!({})).is_err());
|
||||
let mut r = Remote::new(&ControlTarget { host: Some("127.0.0.1".into()), port: 1 });
|
||||
let e = r.request("session.inspect", json!({})).unwrap_err();
|
||||
assert!(matches!(e, RemoteError::Unreachable { .. }), "{e:?}");
|
||||
assert!(e.to_string().starts_with("cannot reach SoundCraft at 127.0.0.1:1: "), "{e}");
|
||||
}
|
||||
|
||||
#[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(&ControlTarget { host: Some("localhost".into()), port });
|
||||
assert!(r.connection().is_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>) {
|
||||
fn fake_app(replies: Vec<bool>) -> (ControlTarget, 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 addr = ControlTarget { host: None, port: listener.local_addr().unwrap().port() };
|
||||
let seen = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counter = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
@@ -217,4 +317,19 @@ mod tests {
|
||||
assert!(r.call("session.inspect", json!({})).is_ok());
|
||||
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_returns_an_app_error_unchanged() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = ControlTarget { host: None, port: listener.local_addr().unwrap().port() };
|
||||
std::thread::spawn(move || {
|
||||
let Ok((mut sock, _)) = listener.accept() else { return };
|
||||
let mut line = String::new();
|
||||
let _ = BufReader::new(sock.try_clone().unwrap()).read_line(&mut line);
|
||||
let _ = writeln!(sock, "{}", json!({"id": 1, "ok": false, "error": "transport.locate: nothing to reset"}));
|
||||
});
|
||||
let mut r = Remote::new(&addr);
|
||||
let err = r.call("engine.execute", json!({"command": "transport.locate"})).unwrap_err();
|
||||
assert_eq!(err, "transport.locate: nothing to reset");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"bounce_mix",
|
||||
"Render the mix to an audio file. Returns peak dBFS and integrated LUFS.",
|
||||
json!({"path": {"type": "string"}, "format": {"type": "string", "enum": ["wav", "aiff", "flac"]}, "bit_depth": {"type": "string"}, "start": {}, "end": {}, "normalize": {"type": "boolean"}}),
|
||||
json!({"path": {"type": "string"}, "format": {"type": "string", "enum": craft_audio::ENCODE_EXTENSIONS}, "bit_depth": {"type": "string"}, "start": {}, "end": {}, "normalize": {"type": "boolean"}}),
|
||||
&["path"],
|
||||
),
|
||||
tool("parity", "Feature-parity report against the incumbent's menu catalog.", json!({}), &[]),
|
||||
@@ -198,6 +198,9 @@ impl<B: Backend> Server<B> {
|
||||
|
||||
/// Handle one JSON-RPC message; returns the reply line (None for notifications).
|
||||
pub fn handle(&mut self, msg: &Value) -> Option<Value> {
|
||||
if !msg.is_object() {
|
||||
return Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "invalid request"}}));
|
||||
}
|
||||
let id = msg.get("id").cloned();
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
@@ -254,6 +257,9 @@ impl<B: Backend> Server<B> {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(Value::Array(batch)) if batch.is_empty() => {
|
||||
Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "invalid request: empty batch"}}))
|
||||
}
|
||||
Ok(Value::Array(batch)) => {
|
||||
let replies: Vec<Value> = batch.iter().filter_map(|m| self.handle(m)).collect();
|
||||
if replies.is_empty() { None } else { Some(Value::Array(replies)) }
|
||||
@@ -269,3 +275,39 @@ impl<B: Backend> Server<B> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Headless;
|
||||
use soundcraft_engine::Engine;
|
||||
|
||||
fn reply(line: &str) -> String {
|
||||
let mut server = Server::new(Headless::new(Engine::default()));
|
||||
let mut out = Vec::new();
|
||||
server.serve(line.as_bytes(), &mut out).unwrap();
|
||||
String::from_utf8(out).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_requests_get_an_invalid_request_error() {
|
||||
for input in ["[]", "42", "[1]", "\"x\""] {
|
||||
let text = reply(input);
|
||||
assert_eq!(text.lines().count(), 1, "input {input}: reply was {text:?}");
|
||||
let v: Value = serde_json::from_str(text.trim()).unwrap();
|
||||
let e = match &v {
|
||||
Value::Array(a) => a[0].clone(),
|
||||
other => other.clone(),
|
||||
};
|
||||
assert_eq!(e["error"]["code"], -32600, "input {input}");
|
||||
assert_eq!(e["id"], Value::Null, "input {input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notifications_still_get_no_reply() {
|
||||
assert_eq!(reply(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), "");
|
||||
let v: Value = serde_json::from_str(reply(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#).trim()).unwrap();
|
||||
assert_eq!(v["id"], 1);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-3
@@ -1,7 +1,6 @@
|
||||
//! `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).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_automation::{Headless, mcp::Server};
|
||||
@@ -58,7 +57,7 @@ fn fixture(name: &str) -> Fixture {
|
||||
}
|
||||
|
||||
fn server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> {
|
||||
let roots = AutomationRoots::new(read, write).unwrap();
|
||||
let roots = AutomationRoots::new(read.as_slice(), write.as_slice()).unwrap();
|
||||
Server::new(Headless::new(Engine::default()).with_automation_roots(roots))
|
||||
}
|
||||
|
||||
@@ -187,7 +186,7 @@ fn writes(dir: &Path) -> Vec<(&'static str, Value)> {
|
||||
}
|
||||
|
||||
fn demo_server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> {
|
||||
let roots = AutomationRoots::new(read, write).unwrap();
|
||||
let roots = AutomationRoots::new(read.as_slice(), write.as_slice()).unwrap();
|
||||
Server::new(Headless::new(soundcraft_engine::demo::demo_engine()).with_automation_roots(roots))
|
||||
}
|
||||
|
||||
@@ -311,6 +310,36 @@ fn links_out_of_the_roots_are_refused() {
|
||||
ok(&mut via_alias, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn exports_refuse_hard_links_without_changing_either_name() {
|
||||
let f = fixture("hard-links");
|
||||
let midi = f.write.join("linked.mid");
|
||||
let preset = f.write.join("linked.json");
|
||||
std::fs::hard_link(f.outside.join("secret.mid"), &midi).unwrap();
|
||||
std::fs::hard_link(f.outside.join("secret.json"), &preset).unwrap();
|
||||
let mut srv = demo_server(Some(&f.read), Some(&f.write));
|
||||
let before = listing(&f.write);
|
||||
|
||||
// Refusal is the safe alternative allowed by #1359: neither the outside inode nor its
|
||||
// in-root alias may be truncated, and no replacement or temporary file may be left behind.
|
||||
refused(&mut srv, "file.export_midi", json!({"path": s(&midi)}), "hard-linked");
|
||||
refused(&mut srv, "track.save_preset", json!({"path": s(&preset), "tracks": ["Kick"]}), "hard-linked");
|
||||
for path in [&midi, &preset, &f.outside.join("secret.mid"), &f.outside.join("secret.json")] {
|
||||
assert_eq!(std::fs::read(path).unwrap(), SECRET.as_bytes());
|
||||
}
|
||||
assert_eq!(listing(&f.write), before);
|
||||
|
||||
// The error offers a new export path; both commands must still work at ordinary paths.
|
||||
let fresh_midi = f.write.join("fresh.mid");
|
||||
ok(&mut srv, "file.export_midi", json!({"path": s(&fresh_midi)}));
|
||||
assert!(std::fs::read(fresh_midi).unwrap().starts_with(b"MThd"));
|
||||
let fresh_preset = f.write.join("fresh.json");
|
||||
ok(&mut srv, "track.save_preset", json!({"path": s(&fresh_preset), "tracks": ["Kick"]}));
|
||||
let preset_json: Value = serde_json::from_slice(&std::fs::read(fresh_preset).unwrap()).unwrap();
|
||||
assert_eq!(preset_json["name"], "Kick");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
@@ -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;
|
||||
@@ -23,16 +23,18 @@ 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
|
||||
# The isolated unsafe helper crate: this crate opts out of the workspace
|
||||
# `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the
|
||||
# FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
too_many_arguments = "deny"
|
||||
type_complexity = "allow"
|
||||
|
||||
+18
-1547
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,240 @@
|
||||
use super::gui::{GuiInner, GuiLink};
|
||||
use super::host::{HostState, host_get_extension, host_request_callback, host_request_process, host_request_restart};
|
||||
use super::instance::Instance;
|
||||
use super::{
|
||||
Arc, CLAP_EXT_AUDIO_PORTS, CLAP_EXT_GUI, CLAP_EXT_LATENCY, CLAP_EXT_NOTE_PORTS, CLAP_EXT_PARAMS, CLAP_EXT_STATE, CLAP_PLUGIN_FACTORY_ID,
|
||||
CLAP_VERSION, CString, ClapError, EVENT_CAP, HashMap, MAX_FEATURES, MAX_PLUGINS_PER_FACTORY, Mutex, OnceLock, Path, PathBuf, PoisonError, c_void,
|
||||
clap_host, clap_plugin_audio_ports, clap_plugin_entry, clap_plugin_factory, clap_plugin_gui, clap_plugin_latency, clap_plugin_note_ports,
|
||||
clap_plugin_params, clap_plugin_state, clap_version_is_compatible, read_cstr, scan,
|
||||
};
|
||||
use super::{CLAP_EXT_RENDER, clap_plugin_render};
|
||||
|
||||
// ---- loaded binaries -----------------------------------------------------------------------
|
||||
|
||||
/// A plugin descriptor as reported by the factory.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RawDescriptor {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub vendor: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub features: Vec<String>,
|
||||
}
|
||||
|
||||
/// A loaded, initialised CLAP binary. Loaded binaries are cached for the life of the process
|
||||
/// (see [`Bundle::load`]): plugin code must never be unmapped while an instance may still run.
|
||||
pub(crate) struct Bundle {
|
||||
entry: *const clap_plugin_entry,
|
||||
factory: *const clap_plugin_factory,
|
||||
// Dropped last: the library must outlive every pointer above.
|
||||
_lib: libloading::Library,
|
||||
}
|
||||
|
||||
// SAFETY: CLAP requires the entry and the plugin factory to be thread-safe; the raw pointers are
|
||||
// read-only vtables inside the loaded library, which `_lib` keeps mapped.
|
||||
unsafe impl Send for Bundle {}
|
||||
|
||||
// SAFETY: as above; `Bundle` has no interior mutability on the Rust side.
|
||||
unsafe impl Sync for Bundle {}
|
||||
|
||||
fn loaded() -> &'static Mutex<HashMap<PathBuf, Arc<Bundle>>> {
|
||||
static L: OnceLock<Mutex<HashMap<PathBuf, Arc<Bundle>>>> = OnceLock::new();
|
||||
L.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// A libloading error with the system's reason. libloading's own text is only "dlopen failed"
|
||||
/// (or "dlsym failed", "LoadLibraryExW failed"); the reason (`dlerror`, the Windows error) is its
|
||||
/// source.
|
||||
fn with_reason(e: &libloading::Error) -> String {
|
||||
match std::error::Error::source(e) {
|
||||
Some(why) => format!("{e}: {why}"),
|
||||
None => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Bundle {
|
||||
/// Loads (once per path, then cached for the life of the process) and initialises a `.clap`.
|
||||
pub fn load(path: &Path) -> Result<Arc<Bundle>, ClapError> {
|
||||
let mut map = loaded().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(b) = map.get(path) {
|
||||
return Ok(b.clone());
|
||||
}
|
||||
let b = Arc::new(Bundle::open(path)?);
|
||||
map.insert(path.to_path_buf(), b.clone());
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Result<Bundle, ClapError> {
|
||||
let err = |m: String| ClapError::Load(path.display().to_string(), m);
|
||||
let exe = scan::bundle_executable(path).ok_or_else(|| err("no loadable binary".into()))?;
|
||||
// SAFETY: loading a shared library runs its static initialisers. That is inherent to
|
||||
// hosting plugins; we only load files found in CLAP plugin folders or named explicitly.
|
||||
let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(with_reason(&e)))?;
|
||||
// SAFETY: `clap_entry` is the CLAP-mandated exported data symbol of type
|
||||
// `const clap_plugin_entry`; the symbol's address is therefore a `*const clap_plugin_entry`.
|
||||
let entry: *const clap_plugin_entry = match unsafe { lib.get::<*const clap_plugin_entry>(b"clap_entry\0") } {
|
||||
Ok(sym) => *sym,
|
||||
Err(e) => return Err(err(format!("not a CLAP plugin (no clap_entry): {}", with_reason(&e)))),
|
||||
};
|
||||
if entry.is_null() {
|
||||
return Err(err("null clap_entry".into()));
|
||||
}
|
||||
// SAFETY: non-null pointer to the entry struct inside the library, which `lib` keeps mapped.
|
||||
let e = unsafe { *entry };
|
||||
if !clap_version_is_compatible(e.clap_version) {
|
||||
return Err(err(format!("unsupported CLAP version {}.{}", e.clap_version.major, e.clap_version.minor)));
|
||||
}
|
||||
let (Some(init), Some(get_factory)) = (e.init, e.get_factory) else { return Err(err("incomplete clap_entry".into())) };
|
||||
let cpath = CString::new(path.to_string_lossy().into_owned()).map_err(|_| err("path contains NUL".into()))?;
|
||||
// SAFETY: `init` comes from a valid entry; the path is a valid NUL-terminated string that
|
||||
// outlives the call.
|
||||
if !unsafe { init(cpath.as_ptr()) } {
|
||||
return Err(err("clap_entry.init failed".into()));
|
||||
}
|
||||
// SAFETY: the entry is initialised; the id is a static NUL-terminated string.
|
||||
let factory = unsafe { get_factory(CLAP_PLUGIN_FACTORY_ID.as_ptr()) } as *const clap_plugin_factory;
|
||||
if factory.is_null() {
|
||||
if let Some(deinit) = e.deinit {
|
||||
// SAFETY: balanced with the successful `init` above.
|
||||
unsafe { deinit() };
|
||||
}
|
||||
return Err(err("no plugin factory".into()));
|
||||
}
|
||||
Ok(Bundle { entry, factory, _lib: lib })
|
||||
}
|
||||
|
||||
fn factory(&self) -> clap_plugin_factory {
|
||||
// SAFETY: `factory` was non-null at load time and points into the still-mapped library.
|
||||
unsafe { *self.factory }
|
||||
}
|
||||
|
||||
/// The plugins this binary provides.
|
||||
pub fn descriptors(&self) -> Vec<RawDescriptor> {
|
||||
let f = self.factory();
|
||||
let (Some(count), Some(get)) = (f.get_plugin_count, f.get_plugin_descriptor) else { return Vec::new() };
|
||||
// SAFETY: vtable call with the factory pointer it belongs to.
|
||||
let n = unsafe { count(self.factory) }.min(MAX_PLUGINS_PER_FACTORY);
|
||||
let mut out = Vec::new();
|
||||
for i in 0..n {
|
||||
// SAFETY: index is below the reported count.
|
||||
let d = unsafe { get(self.factory, i) };
|
||||
if d.is_null() {
|
||||
continue;
|
||||
}
|
||||
// SAFETY: non-null descriptor owned by the plugin library, valid while it is loaded.
|
||||
let d = unsafe { *d };
|
||||
// SAFETY: descriptor string fields are null or NUL-terminated (CLAP contract).
|
||||
let Some(id) = (unsafe { read_cstr(d.id) }).filter(|s| !s.is_empty()) else { continue };
|
||||
// SAFETY: as above.
|
||||
let name = unsafe { read_cstr(d.name) }.unwrap_or_else(|| id.clone());
|
||||
let mut features = Vec::new();
|
||||
if !d.features.is_null() {
|
||||
for k in 0..MAX_FEATURES {
|
||||
// SAFETY: `features` is a NULL-terminated array; we stop at the terminator
|
||||
// (or the cap) and never read past it.
|
||||
let p = unsafe { *d.features.add(k) };
|
||||
if p.is_null() {
|
||||
break;
|
||||
}
|
||||
// SAFETY: a non-null entry of the feature array is a NUL-terminated string.
|
||||
if let Some(s) = unsafe { read_cstr(p) } {
|
||||
features.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(RawDescriptor {
|
||||
id,
|
||||
name,
|
||||
// SAFETY: descriptor string fields are null or NUL-terminated.
|
||||
vendor: unsafe { read_cstr(d.vendor) }.unwrap_or_default(),
|
||||
// SAFETY: as above.
|
||||
version: unsafe { read_cstr(d.version) }.unwrap_or_default(),
|
||||
// SAFETY: as above.
|
||||
description: unsafe { read_cstr(d.description) }.unwrap_or_default(),
|
||||
features,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Creates and initialises one plugin instance.
|
||||
pub fn instantiate(self: &Arc<Self>, plugin_id: &str) -> Result<Instance, ClapError> {
|
||||
let err = |m: &str| ClapError::Instantiate(plugin_id.to_string(), m.to_string());
|
||||
let create = self.factory().create_plugin.ok_or_else(|| err("factory cannot create plugins"))?;
|
||||
let cid = CString::new(plugin_id).map_err(|_| err("id contains NUL"))?;
|
||||
let state = Arc::new(HostState::default());
|
||||
let host = Box::new(clap_host {
|
||||
clap_version: CLAP_VERSION,
|
||||
host_data: Arc::as_ptr(&state).cast_mut().cast::<c_void>(),
|
||||
name: c"SoundCraft".as_ptr(),
|
||||
vendor: c"SoundCraft contributors".as_ptr(),
|
||||
url: c"https://github.com/storytold/soundcraft".as_ptr(),
|
||||
version: c"0.1.0".as_ptr(),
|
||||
get_extension: Some(host_get_extension),
|
||||
request_restart: Some(host_request_restart),
|
||||
request_process: Some(host_request_process),
|
||||
request_callback: Some(host_request_callback),
|
||||
});
|
||||
// SAFETY: the factory is valid; `host` is boxed (stable address) and owned by the returned
|
||||
// `Instance`, which destroys the plugin before dropping the box; `cid` outlives the call.
|
||||
let plugin = unsafe { create(self.factory, &*host, cid.as_ptr()) };
|
||||
if plugin.is_null() {
|
||||
return Err(err("factory returned no instance"));
|
||||
}
|
||||
let mut inst = Instance {
|
||||
plugin,
|
||||
params: std::ptr::null(),
|
||||
ports: std::ptr::null(),
|
||||
latency: std::ptr::null(),
|
||||
notes: std::ptr::null(),
|
||||
state_ext: std::ptr::null(),
|
||||
render: std::ptr::null(),
|
||||
gui: Arc::new(GuiLink::default()),
|
||||
raw: Vec::with_capacity(EVENT_CAP),
|
||||
out_params: Vec::with_capacity(EVENT_CAP),
|
||||
active: false,
|
||||
processing: false,
|
||||
max_frames: 0,
|
||||
created: true,
|
||||
_host: host,
|
||||
state,
|
||||
_bundle: self.clone(),
|
||||
};
|
||||
// SAFETY: `plugin` is a fresh non-null instance; we copy its vtable struct.
|
||||
let vt = unsafe { *plugin };
|
||||
let ok = match vt.init {
|
||||
// SAFETY: `init` is called exactly once, right after creation, as CLAP requires.
|
||||
Some(init) => unsafe { init(plugin) },
|
||||
None => false,
|
||||
};
|
||||
if !ok {
|
||||
// `Drop` destroys the instance (without deactivating: it never activated).
|
||||
return Err(err("plugin init failed"));
|
||||
}
|
||||
inst.params = inst.extension::<clap_plugin_params>(CLAP_EXT_PARAMS);
|
||||
inst.ports = inst.extension::<clap_plugin_audio_ports>(CLAP_EXT_AUDIO_PORTS);
|
||||
inst.latency = inst.extension::<clap_plugin_latency>(CLAP_EXT_LATENCY);
|
||||
inst.notes = inst.extension::<clap_plugin_note_ports>(CLAP_EXT_NOTE_PORTS);
|
||||
inst.state_ext = inst.extension::<clap_plugin_state>(CLAP_EXT_STATE);
|
||||
inst.render = inst.extension::<clap_plugin_render>(CLAP_EXT_RENDER);
|
||||
let gui = inst.extension::<clap_plugin_gui>(CLAP_EXT_GUI);
|
||||
inst.gui = Arc::new(GuiLink {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Bundle {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `entry` was valid at load time and `_lib` (dropped after this) keeps it mapped.
|
||||
let e = unsafe { *self.entry };
|
||||
if let Some(deinit) = e.deinit {
|
||||
// SAFETY: balanced with the successful `init` in `open`; no instance holds an `Arc`
|
||||
// to this bundle any more (they keep it alive), so no plugin code still runs.
|
||||
unsafe { deinit() };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
use super::{
|
||||
CLAP_BEATTIME_FACTOR, CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE,
|
||||
CLAP_EVENT_TRANSPORT, CLAP_SECTIME_FACTOR, 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, MAX_STATE_BYTES, Transport, c_void, clap_event_header,
|
||||
clap_event_midi, clap_event_note, clap_event_param_value, clap_event_transport, clap_input_events, clap_istream, clap_ostream,
|
||||
clap_output_events,
|
||||
};
|
||||
|
||||
// ---- events --------------------------------------------------------------------------------
|
||||
|
||||
/// A host → plugin event, safe to build anywhere; converted to CLAP structs inside `process`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) enum Event {
|
||||
Param {
|
||||
time: u32,
|
||||
id: u32,
|
||||
value: f64,
|
||||
},
|
||||
NoteOn {
|
||||
time: u32,
|
||||
key: u8,
|
||||
velocity: f64,
|
||||
},
|
||||
NoteOff {
|
||||
time: u32,
|
||||
key: u8,
|
||||
},
|
||||
/// A raw MIDI 1.0 message (for plugins whose note port speaks only MIDI).
|
||||
Midi {
|
||||
time: u32,
|
||||
data: [u8; 3],
|
||||
},
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn time(&self) -> u32 {
|
||||
match *self {
|
||||
Event::Param { time, .. } | Event::NoteOn { time, .. } | Event::NoteOff { time, .. } | Event::Midi { time, .. } => time,
|
||||
}
|
||||
}
|
||||
pub fn set_time(&mut self, t: u32) {
|
||||
match self {
|
||||
Event::Param { time, .. } | Event::NoteOn { time, .. } | Event::NoteOff { time, .. } | Event::Midi { time, .. } => *time = t,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum RawEvent {
|
||||
Note(clap_event_note),
|
||||
Param(clap_event_param_value),
|
||||
Midi(clap_event_midi),
|
||||
}
|
||||
|
||||
impl RawEvent {
|
||||
fn header(&self) -> *const clap_event_header {
|
||||
// The header is the first field of every `repr(C)` CLAP event struct, so a pointer to it is
|
||||
// a pointer to the whole event, as CLAP expects.
|
||||
match self {
|
||||
RawEvent::Note(e) => &e.header,
|
||||
RawEvent::Param(e) => &e.header,
|
||||
RawEvent::Midi(e) => &e.header,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn from_event(e: &Event, offset: u32) -> RawEvent {
|
||||
let header = |size: usize, type_: u16, time: u32| clap_event_header {
|
||||
size: u32::try_from(size).unwrap_or(u32::MAX),
|
||||
time: time.saturating_sub(offset),
|
||||
space_id: CLAP_CORE_EVENT_SPACE_ID,
|
||||
type_,
|
||||
flags: 0,
|
||||
};
|
||||
match *e {
|
||||
Event::Param { time, id, value } => RawEvent::Param(clap_event_param_value {
|
||||
header: header(size_of::<clap_event_param_value>(), CLAP_EVENT_PARAM_VALUE, time),
|
||||
param_id: id,
|
||||
cookie: std::ptr::null_mut(),
|
||||
note_id: -1,
|
||||
port_index: -1,
|
||||
channel: -1,
|
||||
key: -1,
|
||||
value,
|
||||
}),
|
||||
Event::NoteOn { time, key, velocity } => RawEvent::Note(clap_event_note {
|
||||
header: header(size_of::<clap_event_note>(), CLAP_EVENT_NOTE_ON, time),
|
||||
note_id: -1,
|
||||
port_index: 0,
|
||||
channel: 0,
|
||||
key: i16::from(key),
|
||||
velocity,
|
||||
}),
|
||||
Event::NoteOff { time, key } => RawEvent::Note(clap_event_note {
|
||||
header: header(size_of::<clap_event_note>(), CLAP_EVENT_NOTE_OFF, time),
|
||||
note_id: -1,
|
||||
port_index: 0,
|
||||
channel: 0,
|
||||
key: i16::from(key),
|
||||
velocity: 0.0,
|
||||
}),
|
||||
Event::Midi { time, data } => {
|
||||
RawEvent::Midi(clap_event_midi { header: header(size_of::<clap_event_midi>(), CLAP_EVENT_MIDI, time), port_index: 0, data })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) unsafe extern "C" fn in_events_size(list: *const clap_input_events) -> u32 {
|
||||
if list.is_null() {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: `list` is the `clap_input_events` we built in `process`; its ctx is the event Vec.
|
||||
let v = unsafe { ((*list).ctx as *const Vec<RawEvent>).as_ref() };
|
||||
v.map_or(0, |v| u32::try_from(v.len()).unwrap_or(u32::MAX))
|
||||
}
|
||||
|
||||
pub(super) unsafe extern "C" fn in_events_get(list: *const clap_input_events, index: u32) -> *const clap_event_header {
|
||||
if list.is_null() {
|
||||
return std::ptr::null();
|
||||
}
|
||||
// SAFETY: as in `in_events_size`; the Vec is not mutated while the plugin processes.
|
||||
let v = unsafe { ((*list).ctx as *const Vec<RawEvent>).as_ref() };
|
||||
v.and_then(|v| v.get(index as usize)).map_or(std::ptr::null(), RawEvent::header)
|
||||
}
|
||||
|
||||
pub(super) unsafe extern "C" fn out_events_try_push(list: *const clap_output_events, event: *const clap_event_header) -> bool {
|
||||
// Plugin → host events are accepted. Parameter values (typically edits in the plugin's GUI)
|
||||
// are kept, while there is room, so the UI can write them into the session; the rest is
|
||||
// ignored.
|
||||
if list.is_null() || event.is_null() {
|
||||
return true;
|
||||
}
|
||||
// SAFETY: CLAP passes a valid event header for the duration of the call.
|
||||
let h = unsafe { &*event };
|
||||
if h.space_id != CLAP_CORE_EVENT_SPACE_ID || h.type_ != CLAP_EVENT_PARAM_VALUE || (h.size as usize) < size_of::<clap_event_param_value>() {
|
||||
return true;
|
||||
}
|
||||
// SAFETY: the header says this is a complete `clap_event_param_value` (type and size checked).
|
||||
let e = unsafe { &*event.cast::<clap_event_param_value>() };
|
||||
// SAFETY: `ctx` is null or the instance's preallocated `out_params` Vec, which `process`
|
||||
// borrows exclusively for the duration of the plugin call.
|
||||
if let Some(v) = unsafe { ((*list).ctx as *mut Vec<(u32, f64)>).as_mut() }
|
||||
&& v.len() < v.capacity()
|
||||
&& e.value.is_finite()
|
||||
{
|
||||
v.push((e.param_id, e.value));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ---- state streams -------------------------------------------------------------------------
|
||||
|
||||
/// Read side of an in-memory state blob.
|
||||
pub(super) struct ReadCursor<'a> {
|
||||
pub(super) data: &'a [u8],
|
||||
pub(super) pos: usize,
|
||||
}
|
||||
|
||||
pub(super) unsafe extern "C" fn istream_read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
|
||||
if stream.is_null() || buffer.is_null() {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: `ctx` is the `ReadCursor` on `load_state`'s stack frame, alive during the call.
|
||||
let Some(c) = (unsafe { ((*stream).ctx as *mut ReadCursor<'_>).as_mut() }) else { return -1 };
|
||||
let rest = c.data.get(c.pos..).unwrap_or(&[]);
|
||||
let n = rest.len().min(usize::try_from(size).unwrap_or(usize::MAX));
|
||||
if let Some(src) = rest.get(..n) {
|
||||
// SAFETY: the plugin provides `size` ≥ `n` writable bytes at `buffer`; `src` is `n`
|
||||
// readable bytes of our own slice; they cannot overlap.
|
||||
unsafe { std::ptr::copy_nonoverlapping(src.as_ptr(), buffer.cast::<u8>(), n) };
|
||||
}
|
||||
c.pos += n;
|
||||
i64::try_from(n).unwrap_or(-1)
|
||||
}
|
||||
|
||||
pub(super) unsafe extern "C" fn ostream_write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
|
||||
if stream.is_null() || buffer.is_null() {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: `ctx` is the output Vec on `save_state`'s stack frame, alive during the call.
|
||||
let Some(v) = (unsafe { ((*stream).ctx as *mut Vec<u8>).as_mut() }) else { return -1 };
|
||||
let Ok(n) = usize::try_from(size) else { return -1 };
|
||||
if v.len().saturating_add(n) > MAX_STATE_BYTES {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: the plugin provides `size` readable bytes at `buffer` for the call.
|
||||
let src = unsafe { std::slice::from_raw_parts(buffer.cast::<u8>(), n) };
|
||||
v.extend_from_slice(src);
|
||||
i64::try_from(n).unwrap_or(-1)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
use super::events::ostream_write;
|
||||
use super::host::HostState;
|
||||
use super::{
|
||||
Arc, CStr, CString, Mutex, Ordering, PoisonError, c_void, clap_ostream, clap_plugin, clap_plugin_gui, clap_plugin_state, clap_window,
|
||||
clap_window_handle, hostwin,
|
||||
};
|
||||
|
||||
// ---- editor (clap.gui) ---------------------------------------------------------------------
|
||||
|
||||
/// True on the process's main thread (AppKit requires GUI calls there).
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn on_main_thread() -> bool {
|
||||
unsafe extern "C" {
|
||||
fn pthread_main_np() -> std::ffi::c_int;
|
||||
}
|
||||
// SAFETY: a libSystem function without arguments or preconditions.
|
||||
unsafe { pthread_main_np() == 1 }
|
||||
}
|
||||
|
||||
/// True on the process's main thread: Rust names it "main", and the app runs its UI there (the
|
||||
/// rule `hostwin` and the VST3 host use). CLAP makes `clap.gui` main-thread only on every platform.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(crate) fn on_main_thread() -> bool {
|
||||
std::thread::current().name() == Some("main")
|
||||
}
|
||||
|
||||
/// The windowing API of this platform.
|
||||
fn window_api() -> Option<&'static CStr> {
|
||||
if cfg!(target_os = "macos") {
|
||||
Some(clap_sys::ext::gui::CLAP_WINDOW_API_COCOA)
|
||||
} else if cfg!(windows) {
|
||||
Some(clap_sys::ext::gui::CLAP_WINDOW_API_WIN32)
|
||||
} else if cfg!(all(unix, not(target_os = "android"))) {
|
||||
Some(clap_sys::ext::gui::CLAP_WINDOW_API_X11)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct GuiInner {
|
||||
pub(super) plugin: *const clap_plugin,
|
||||
pub(super) gui: *const clap_plugin_gui,
|
||||
pub(super) state: *const clap_plugin_state,
|
||||
/// The plugin instance exists (cleared by `Instance::drop` before it destroys the plugin).
|
||||
pub(super) alive: bool,
|
||||
pub(super) created: bool,
|
||||
pub(super) visible: bool,
|
||||
/// The host window of an embedded editor (`None` for floating editors).
|
||||
pub(super) window: Option<hostwin::HostWindow>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
pub(super) inner: Mutex<GuiInner>,
|
||||
pub(super) host: Arc<HostState>,
|
||||
}
|
||||
|
||||
impl Default for GuiLink {
|
||||
fn default() -> Self {
|
||||
GuiLink {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: the raw pointers are only dereferenced under the mutex, while `alive` guarantees the
|
||||
// plugin exists; CLAP's threading rules (main-thread GUI calls) are checked at each call.
|
||||
unsafe impl Send for GuiInner {}
|
||||
|
||||
impl GuiLink {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, GuiInner> {
|
||||
self.inner.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
pub fn available(&self) -> bool {
|
||||
let g = self.lock();
|
||||
g.alive && !g.gui.is_null()
|
||||
}
|
||||
|
||||
pub fn host(&self) -> &HostState {
|
||||
&self.host
|
||||
}
|
||||
|
||||
fn vt(g: &GuiInner) -> Option<clap_plugin_gui> {
|
||||
// SAFETY: `gui` is null or the plugin's static extension vtable, valid while `alive`.
|
||||
(g.alive).then(|| unsafe { g.gui.as_ref() }.copied()).flatten()
|
||||
}
|
||||
|
||||
/// Opens (creates and shows) the plugin's floating editor window.
|
||||
pub fn open(&self, title: &str) -> Result<(), String> {
|
||||
if !on_main_thread() {
|
||||
return Err("plugin editors must be opened on the main thread".into());
|
||||
}
|
||||
let mut g = self.lock();
|
||||
let vt = GuiLink::vt(&g).ok_or_else(|| "this plugin has no editor".to_string())?;
|
||||
if g.created {
|
||||
if let Some(show) = vt.show {
|
||||
// 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(());
|
||||
}
|
||||
let api = window_api().ok_or_else(|| "plugin editors are not supported on this platform".to_string())?;
|
||||
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) } {
|
||||
// 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);
|
||||
// SAFETY: as above; no GUI exists yet.
|
||||
if !unsafe { create(g.plugin, api.as_ptr(), true) } {
|
||||
return Err("the plugin could not create its editor".into());
|
||||
}
|
||||
g.created = true;
|
||||
if !cfg!(target_os = "macos")
|
||||
&& let Some(f) = vt.set_scale
|
||||
{
|
||||
// SAFETY: live plugin with a created GUI. (Cocoa uses logical pixels: not called.)
|
||||
unsafe { f(g.plugin, 1.0) };
|
||||
}
|
||||
if let (Some(f), Ok(t)) = (vt.suggest_title, CString::new(title)) {
|
||||
// SAFETY: live plugin with a created floating GUI; `t` outlives the call.
|
||||
unsafe { f(g.plugin, t.as_ptr()) };
|
||||
}
|
||||
let shown = match vt.show {
|
||||
// SAFETY: as above.
|
||||
Some(f) => unsafe { f(g.plugin) },
|
||||
None => false,
|
||||
};
|
||||
if !shown {
|
||||
GuiLink::destroy(&mut g);
|
||||
return Err("the plugin could not show its editor".into());
|
||||
}
|
||||
g.visible = true;
|
||||
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)
|
||||
{
|
||||
// SAFETY: live plugin with a created GUI; destroyed exactly once.
|
||||
unsafe { f(g.plugin) };
|
||||
}
|
||||
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.
|
||||
pub fn close(&self) {
|
||||
if !on_main_thread() {
|
||||
return;
|
||||
}
|
||||
GuiLink::destroy(&mut self.lock());
|
||||
}
|
||||
|
||||
pub fn is_open(&self) -> bool {
|
||||
let g = self.lock();
|
||||
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
|
||||
/// show/hide/closed requests, and returns the parameter edits reported since the last call.
|
||||
pub fn idle(&self) -> Vec<(u32, f64)> {
|
||||
if !on_main_thread() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut g = self.lock();
|
||||
if !g.alive {
|
||||
return Vec::new();
|
||||
}
|
||||
if self.host.callback.swap(false, Ordering::Relaxed) {
|
||||
// SAFETY: `plugin` is valid while `alive`; we copy its vtable struct.
|
||||
if let Some(f) = unsafe { (*g.plugin).on_main_thread } {
|
||||
// SAFETY: live plugin, main thread, requested via `request_callback`.
|
||||
unsafe { f(g.plugin) };
|
||||
}
|
||||
}
|
||||
if g.created {
|
||||
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);
|
||||
} else if let Some(vt) = GuiLink::vt(&g) {
|
||||
if self.host.gui_show.swap(false, Ordering::Relaxed)
|
||||
&& let Some(f) = vt.show
|
||||
{
|
||||
// SAFETY: live plugin with a created GUI, main thread.
|
||||
g.visible = unsafe { f(g.plugin) };
|
||||
}
|
||||
if self.host.gui_hide.swap(false, Ordering::Relaxed)
|
||||
&& let Some(f) = vt.hide
|
||||
{
|
||||
// SAFETY: as above.
|
||||
unsafe { f(g.plugin) };
|
||||
g.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(g);
|
||||
let mut e = self.host.edits.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let out = e.clone();
|
||||
e.clear();
|
||||
out
|
||||
}
|
||||
|
||||
/// Called by the instance before it destroys the plugin.
|
||||
pub(super) fn kill(&self) {
|
||||
let mut g = self.lock();
|
||||
if g.created {
|
||||
if on_main_thread() {
|
||||
GuiLink::destroy(&mut g);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
use super::{
|
||||
AtomicBool, AtomicU64, CLAP_EXT_GUI, CLAP_EXT_LATENCY, CLAP_EXT_LOG, CLAP_EXT_PARAMS, CLAP_EXT_STATE, CLAP_LOG_ERROR, CLAP_LOG_FATAL,
|
||||
CLAP_LOG_WARNING, CStr, EDIT_CAP, Mutex, Ordering, c_char, c_void, clap_host, clap_host_gui, clap_host_latency, clap_host_log, clap_host_params,
|
||||
clap_host_state, clap_id, clap_log_severity, clap_param_clear_flags, clap_param_rescan_flags, read_cstr,
|
||||
};
|
||||
|
||||
// ---- host side -----------------------------------------------------------------------------
|
||||
|
||||
/// Flags the plugin raises through the host callbacks (any thread).
|
||||
pub(crate) struct HostState {
|
||||
pub restart: AtomicBool,
|
||||
pub process: AtomicBool,
|
||||
pub callback: AtomicBool,
|
||||
pub latency_changed: AtomicBool,
|
||||
pub params_rescan: AtomicBool,
|
||||
/// `clap_host_state.mark_dirty`.
|
||||
pub dirty: AtomicBool,
|
||||
/// `clap_host_gui` requests, serviced by the editor's `idle` on the main thread.
|
||||
pub gui_closed: AtomicBool,
|
||||
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)>>,
|
||||
}
|
||||
|
||||
impl Default for HostState {
|
||||
fn default() -> Self {
|
||||
HostState {
|
||||
restart: AtomicBool::new(false),
|
||||
process: AtomicBool::new(false),
|
||||
callback: AtomicBool::new(false),
|
||||
latency_changed: AtomicBool::new(false),
|
||||
params_rescan: AtomicBool::new(false),
|
||||
dirty: AtomicBool::new(false),
|
||||
gui_closed: AtomicBool::new(false),
|
||||
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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SAFETY contract: `host` is null or one of our `clap_host` structs, whose `host_data` points
|
||||
/// to the `HostState` owned by the same `Instance`.
|
||||
unsafe fn state<'a>(host: *const clap_host) -> Option<&'a HostState> {
|
||||
if host.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: non-null host created by `Bundle::instantiate` (contract above).
|
||||
let data = unsafe { (*host).host_data } as *const HostState;
|
||||
// SAFETY: `host_data` points to the boxed `HostState`, alive as long as the instance.
|
||||
unsafe { data.as_ref() }
|
||||
}
|
||||
|
||||
pub(super) unsafe extern "C" fn host_request_restart(host: *const clap_host) {
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
if let Some(s) = unsafe { state(host) } {
|
||||
s.restart.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) unsafe extern "C" fn host_request_process(host: *const clap_host) {
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
if let Some(s) = unsafe { state(host) } {
|
||||
s.process.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) unsafe extern "C" fn host_request_callback(host: *const clap_host) {
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
if let Some(s) = unsafe { state(host) } {
|
||||
s.callback.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_log(_host: *const clap_host, severity: clap_log_severity, msg: *const c_char) {
|
||||
// SAFETY: CLAP passes a NUL-terminated message (or null, handled).
|
||||
let m = unsafe { read_cstr(msg) }.unwrap_or_default();
|
||||
match severity {
|
||||
CLAP_LOG_ERROR | CLAP_LOG_FATAL => log::error!("clap plugin: {m}"),
|
||||
CLAP_LOG_WARNING => log::warn!("clap plugin: {m}"),
|
||||
_ => log::debug!("clap plugin: {m}"),
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_params_rescan(host: *const clap_host, _flags: clap_param_rescan_flags) {
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
if let Some(s) = unsafe { state(host) } {
|
||||
s.params_rescan.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_params_clear(_host: *const clap_host, _id: clap_id, _flags: clap_param_clear_flags) {}
|
||||
|
||||
unsafe extern "C" fn host_params_request_flush(host: *const clap_host) {
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
if let Some(s) = unsafe { state(host) } {
|
||||
s.process.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_latency_changed(host: *const clap_host) {
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
if let Some(s) = unsafe { state(host) } {
|
||||
s.latency_changed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_state_mark_dirty(host: *const clap_host) {
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
if let Some(s) = unsafe { state(host) } {
|
||||
s.dirty.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
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; 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
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_gui_request_show(host: *const clap_host) -> bool {
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
match unsafe { state(host) } {
|
||||
Some(s) => {
|
||||
s.gui_show.store(true, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_gui_request_hide(host: *const clap_host) -> bool {
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
match unsafe { state(host) } {
|
||||
Some(s) => {
|
||||
s.gui_hide.store(true, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_gui_closed(host: *const clap_host, was_destroyed: bool) {
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
if let Some(s) = unsafe { state(host) } {
|
||||
if was_destroyed {
|
||||
s.gui_destroyed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
s.gui_closed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
static HOST_STATE: clap_host_state = clap_host_state { mark_dirty: Some(host_state_mark_dirty) };
|
||||
|
||||
static HOST_GUI: clap_host_gui = clap_host_gui {
|
||||
resize_hints_changed: Some(host_gui_resize_hints_changed),
|
||||
request_resize: Some(host_gui_request_resize),
|
||||
request_show: Some(host_gui_request_show),
|
||||
request_hide: Some(host_gui_request_hide),
|
||||
closed: Some(host_gui_closed),
|
||||
};
|
||||
|
||||
static HOST_LOG: clap_host_log = clap_host_log { log: Some(host_log) };
|
||||
|
||||
static HOST_PARAMS: clap_host_params =
|
||||
clap_host_params { rescan: Some(host_params_rescan), clear: Some(host_params_clear), request_flush: Some(host_params_request_flush) };
|
||||
|
||||
static HOST_LATENCY: clap_host_latency = clap_host_latency { changed: Some(host_latency_changed) };
|
||||
|
||||
pub(super) unsafe extern "C" fn host_get_extension(_host: *const clap_host, id: *const c_char) -> *const c_void {
|
||||
if id.is_null() {
|
||||
return std::ptr::null();
|
||||
}
|
||||
// SAFETY: extension ids are NUL-terminated strings (CLAP contract); non-null checked above.
|
||||
let id = unsafe { CStr::from_ptr(id) };
|
||||
if id == CLAP_EXT_LOG {
|
||||
(&HOST_LOG as *const clap_host_log).cast()
|
||||
} else if id == CLAP_EXT_PARAMS {
|
||||
(&HOST_PARAMS as *const clap_host_params).cast()
|
||||
} else if id == CLAP_EXT_LATENCY {
|
||||
(&HOST_LATENCY as *const clap_host_latency).cast()
|
||||
} else if id == CLAP_EXT_STATE {
|
||||
(&HOST_STATE as *const clap_host_state).cast()
|
||||
} else if id == CLAP_EXT_GUI {
|
||||
(&HOST_GUI as *const clap_host_gui).cast()
|
||||
} else {
|
||||
// Everything else (thread-check, timers, fd support, …) is unsupported: CLAP allows null.
|
||||
std::ptr::null()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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) {}
|
||||
@@ -0,0 +1,165 @@
|
||||
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) };
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
use super::bundle::Bundle;
|
||||
use super::events::{Event, RawEvent, ReadCursor, in_events_get, in_events_size, istream_read, out_events_try_push, transport_event};
|
||||
use super::gui::GuiLink;
|
||||
use super::host::HostState;
|
||||
use super::{
|
||||
Arc, CLAP_AUDIO_PORT_IS_MAIN, CLAP_PROCESS_ERROR, CStr, ClapError, MAX_PARAMS, MAX_PORT_CHANNELS, MAX_PORTS, Ordering, Transport, c_char, c_void,
|
||||
clap_audio_buffer, clap_audio_port_info, clap_host, clap_input_events, clap_istream, clap_note_port_info, clap_output_events, clap_param_info,
|
||||
clap_plugin, clap_plugin_audio_ports, clap_plugin_latency, clap_plugin_note_ports, clap_plugin_params, clap_plugin_state, clap_process,
|
||||
read_name,
|
||||
};
|
||||
use super::{CLAP_RENDER_OFFLINE, CLAP_RENDER_REALTIME, clap_plugin_render};
|
||||
|
||||
// ---- audio buffers -------------------------------------------------------------------------
|
||||
|
||||
/// Planar f32 buffers for every audio port in one direction, with the pointer tables CLAP needs.
|
||||
/// Allocated in `new`; `process` only rewrites pointers in place (no allocation).
|
||||
pub(crate) struct PortBuffers {
|
||||
data: Vec<Vec<Vec<f32>>>,
|
||||
ptrs: Vec<Vec<*mut f32>>,
|
||||
bufs: Vec<clap_audio_buffer>,
|
||||
frames: usize,
|
||||
}
|
||||
|
||||
// SAFETY: the raw pointers only ever point into `data`, which this struct owns; moving the struct
|
||||
// to another thread moves that ownership with it.
|
||||
unsafe impl Send for PortBuffers {}
|
||||
|
||||
impl PortBuffers {
|
||||
/// One buffer set per port, `channels[p]` channels each (capped), `frames` long.
|
||||
pub fn new(channels: &[u32], frames: usize) -> PortBuffers {
|
||||
let data: Vec<Vec<Vec<f32>>> =
|
||||
channels.iter().take(MAX_PORTS as usize).map(|&c| (0..c.min(MAX_PORT_CHANNELS)).map(|_| vec![0.0; frames]).collect()).collect();
|
||||
let ptrs = data.iter().map(|p| vec![std::ptr::null_mut(); p.len()]).collect();
|
||||
let bufs = data
|
||||
.iter()
|
||||
.map(|_| clap_audio_buffer { data32: std::ptr::null_mut(), data64: std::ptr::null_mut(), channel_count: 0, latency: 0, constant_mask: 0 })
|
||||
.collect();
|
||||
PortBuffers { data, ptrs, bufs, frames }
|
||||
}
|
||||
|
||||
pub fn channels(&self, port: usize) -> usize {
|
||||
self.data.get(port).map_or(0, Vec::len)
|
||||
}
|
||||
|
||||
pub fn channel(&self, port: usize, ch: usize) -> Option<&[f32]> {
|
||||
self.data.get(port).and_then(|p| p.get(ch)).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
pub fn channel_mut(&mut self, port: usize, ch: usize) -> Option<&mut [f32]> {
|
||||
self.data.get_mut(port).and_then(|p| p.get_mut(ch)).map(Vec::as_mut_slice)
|
||||
}
|
||||
|
||||
/// Points the CLAP tables at the current data (no allocation).
|
||||
fn refresh(&mut self) {
|
||||
for ((d, p), b) in self.data.iter_mut().zip(self.ptrs.iter_mut()).zip(self.bufs.iter_mut()) {
|
||||
for (dc, pc) in d.iter_mut().zip(p.iter_mut()) {
|
||||
*pc = dc.as_mut_ptr();
|
||||
}
|
||||
b.data32 = p.as_mut_ptr();
|
||||
b.data64 = std::ptr::null_mut();
|
||||
b.channel_count = u32::try_from(p.len()).unwrap_or(0);
|
||||
b.constant_mask = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- instances -----------------------------------------------------------------------------
|
||||
|
||||
/// What one audio port looks like.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct PortInfo {
|
||||
pub channels: u32,
|
||||
pub main: bool,
|
||||
}
|
||||
|
||||
/// One parameter as reported by `clap_plugin_params.get_info`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RawParam {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub module: String,
|
||||
pub flags: u32,
|
||||
pub min: f64,
|
||||
pub max: f64,
|
||||
pub default: f64,
|
||||
}
|
||||
|
||||
/// Note dialects of the plugin's first input note port.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct NotePort {
|
||||
pub supported: u32,
|
||||
pub preferred: u32,
|
||||
}
|
||||
|
||||
/// A live plugin instance plus the host struct it was created with.
|
||||
pub(crate) struct Instance {
|
||||
pub(super) plugin: *const clap_plugin,
|
||||
pub(super) params: *const clap_plugin_params,
|
||||
pub(super) ports: *const clap_plugin_audio_ports,
|
||||
pub(super) latency: *const clap_plugin_latency,
|
||||
pub(super) notes: *const clap_plugin_note_ports,
|
||||
pub(super) state_ext: *const clap_plugin_state,
|
||||
pub(super) render: *const clap_plugin_render,
|
||||
pub(super) gui: Arc<GuiLink>,
|
||||
pub(super) raw: Vec<RawEvent>,
|
||||
/// Parameter values the plugin reported through output events in the current `process`.
|
||||
pub(super) out_params: Vec<(u32, f64)>,
|
||||
pub(super) active: bool,
|
||||
pub(super) processing: bool,
|
||||
pub(super) max_frames: usize,
|
||||
pub(super) created: bool,
|
||||
// Field order matters for drop: the plugin is destroyed in `Drop::drop` first; then the host
|
||||
// struct and its state, then the bundle (which keeps the code mapped) go.
|
||||
pub(super) _host: Box<clap_host>,
|
||||
pub(super) state: Arc<HostState>,
|
||||
pub(super) _bundle: Arc<Bundle>,
|
||||
}
|
||||
|
||||
// SAFETY: an `Instance` is used through `&mut self` only, so calls into the plugin are always
|
||||
// serialised. CLAP lets audio-thread functions run on any one thread at a time; SoundCraft creates
|
||||
// instances on the engine/UI thread and then moves them into the mix engine (documented
|
||||
// limitation: some main-thread calls such as `activate` may then happen on the audio thread).
|
||||
unsafe impl Send for Instance {}
|
||||
|
||||
impl Instance {
|
||||
fn vt(&self) -> clap_plugin {
|
||||
// SAFETY: `plugin` is non-null (checked at creation) and alive until `Drop`.
|
||||
unsafe { *self.plugin }
|
||||
}
|
||||
|
||||
pub(super) fn extension<T>(&self, id: &CStr) -> *const T {
|
||||
match self.vt().get_extension {
|
||||
// SAFETY: valid instance; `id` is a static NUL-terminated string.
|
||||
Some(f) => unsafe { f(self.plugin, id.as_ptr()) }.cast::<T>(),
|
||||
None => std::ptr::null(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &HostState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// The editor link shared with editor handles (`None` when the plugin has no `clap.gui`).
|
||||
pub fn gui(&self) -> Option<Arc<GuiLink>> {
|
||||
self.gui.available().then(|| Arc::clone(&self.gui))
|
||||
}
|
||||
|
||||
/// 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: 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) };
|
||||
// SAFETY: valid instance; the stream and its cursor live on this frame for the call.
|
||||
unsafe { load(self.plugin, &stream) }
|
||||
}
|
||||
|
||||
/// `clap_plugin_render.set`: offline (no realtime pressure) or realtime. `false` without the
|
||||
/// extension or when the plugin can't switch.
|
||||
pub fn set_render_mode(&self, offline: bool) -> bool {
|
||||
// SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin.
|
||||
let Some(set) = (unsafe { self.render.as_ref() }).and_then(|r| r.set) else { return false };
|
||||
let mode = if offline { CLAP_RENDER_OFFLINE } else { CLAP_RENDER_REALTIME };
|
||||
// SAFETY: valid instance, called from the thread that owns it.
|
||||
unsafe { set(self.plugin, mode) }
|
||||
}
|
||||
|
||||
/// Runs `on_main_thread` if the plugin asked for a callback.
|
||||
pub fn service_callback(&mut self) {
|
||||
if self.state.callback.swap(false, Ordering::Relaxed)
|
||||
&& let Some(f) = self.vt().on_main_thread
|
||||
{
|
||||
// SAFETY: valid instance; the plugin asked for this call via `request_callback`.
|
||||
unsafe { f(self.plugin) };
|
||||
}
|
||||
}
|
||||
|
||||
pub fn params(&self) -> Vec<RawParam> {
|
||||
// SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin.
|
||||
let Some(p) = (unsafe { self.params.as_ref() }) else { return Vec::new() };
|
||||
let (Some(count), Some(get_info)) = (p.count, p.get_info) else { return Vec::new() };
|
||||
// SAFETY: vtable call with the instance it belongs to.
|
||||
let n = unsafe { count(self.plugin) }.min(MAX_PARAMS);
|
||||
let mut out = Vec::new();
|
||||
for i in 0..n {
|
||||
// SAFETY: `clap_param_info` is plain data (integers, floats, char arrays, a nullable
|
||||
// pointer); all-zero is a valid value.
|
||||
let mut info: clap_param_info = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: index below the count; `info` is a valid, writable struct.
|
||||
if !unsafe { get_info(self.plugin, i, &mut info) } {
|
||||
continue;
|
||||
}
|
||||
let (min, max) = (info.min_value, info.max_value);
|
||||
if !(min.is_finite() && max.is_finite()) || max < min {
|
||||
continue;
|
||||
}
|
||||
let default = if info.default_value.is_finite() { info.default_value.clamp(min, max) } else { min };
|
||||
out.push(RawParam { id: info.id, name: read_name(&info.name), module: read_name(&info.module), flags: info.flags, min, max, default });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn param_value(&self, id: u32) -> Option<f64> {
|
||||
// SAFETY: see `params`.
|
||||
let p = unsafe { self.params.as_ref() }?;
|
||||
let get = p.get_value?;
|
||||
let mut v = 0.0f64;
|
||||
// SAFETY: valid instance; `v` is writable.
|
||||
(unsafe { get(self.plugin, id, &mut v) } && v.is_finite()).then_some(v)
|
||||
}
|
||||
|
||||
pub fn value_text(&self, id: u32, value: f64) -> Option<String> {
|
||||
// SAFETY: see `params`.
|
||||
let p = unsafe { self.params.as_ref() }?;
|
||||
let f = p.value_to_text?;
|
||||
let mut buf = [0 as c_char; 128];
|
||||
// SAFETY: valid instance; we pass the buffer's true capacity.
|
||||
let ok = unsafe { f(self.plugin, id, value, buf.as_mut_ptr(), buf.len() as u32) };
|
||||
ok.then(|| read_name(&buf)).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// 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 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) };
|
||||
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) } {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn note_port(&self) -> Option<NotePort> {
|
||||
// SAFETY: see `params`.
|
||||
let p = unsafe { self.notes.as_ref() }?;
|
||||
let (count, get) = (p.count?, p.get?);
|
||||
// SAFETY: vtable call with its instance.
|
||||
if unsafe { count(self.plugin, true) } == 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: plain data; all-zero is valid.
|
||||
let mut info: clap_note_port_info = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: index 0 is below the non-zero count; `info` is writable.
|
||||
unsafe { get(self.plugin, 0, true, &mut info) }.then_some(NotePort { supported: info.supported_dialects, preferred: info.preferred_dialect })
|
||||
}
|
||||
|
||||
pub fn latency(&self) -> u32 {
|
||||
// SAFETY: see `params`.
|
||||
match unsafe { self.latency.as_ref() }.and_then(|l| l.get) {
|
||||
// SAFETY: valid instance.
|
||||
Some(f) => unsafe { f(self.plugin) },
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.active
|
||||
}
|
||||
|
||||
pub fn activate(&mut self, sample_rate: f64, min_frames: u32, max_frames: u32) -> Result<(), ClapError> {
|
||||
self.deactivate();
|
||||
let f = self.vt().activate.ok_or_else(|| ClapError::Process("plugin has no activate".into()))?;
|
||||
// SAFETY: valid, inactive instance; arguments are sane (checked by the caller).
|
||||
if !unsafe { f(self.plugin, sample_rate, min_frames, max_frames) } {
|
||||
return Err(ClapError::Process("activate failed".into()));
|
||||
}
|
||||
self.active = true;
|
||||
self.max_frames = max_frames as usize;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn deactivate(&mut self) {
|
||||
self.stop_processing();
|
||||
if self.active {
|
||||
if let Some(f) = self.vt().deactivate {
|
||||
// SAFETY: valid, active instance.
|
||||
unsafe { f(self.plugin) };
|
||||
}
|
||||
self.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_processing(&mut self) {
|
||||
if self.processing {
|
||||
if let Some(f) = self.vt().stop_processing {
|
||||
// SAFETY: valid instance in the processing state.
|
||||
unsafe { f(self.plugin) };
|
||||
}
|
||||
self.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
if self.active
|
||||
&& let Some(f) = self.vt().reset
|
||||
{
|
||||
// SAFETY: valid, active instance.
|
||||
unsafe { f(self.plugin) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one `clap_process` call over `frames` frames. Events must be sorted by time; their
|
||||
/// times are made relative to `offset`. Returns the CLAP process status.
|
||||
pub fn process(&mut self, input: ProcessInput<'_>) -> Result<i32, ClapError> {
|
||||
let ProcessInput { frames, steady_time, inputs, outputs, events, offset, transport } = input;
|
||||
if !self.active {
|
||||
return Err(ClapError::Process("not active".into()));
|
||||
}
|
||||
if frames == 0 || frames > self.max_frames || frames > inputs.frames || frames > outputs.frames {
|
||||
return Err(ClapError::Process("block larger than the activated size".into()));
|
||||
}
|
||||
if !self.processing {
|
||||
let ok = match self.vt().start_processing {
|
||||
// SAFETY: valid, active instance.
|
||||
Some(f) => unsafe { f(self.plugin) },
|
||||
None => true,
|
||||
};
|
||||
if !ok {
|
||||
return Err(ClapError::Process("start_processing failed".into()));
|
||||
}
|
||||
self.processing = true;
|
||||
}
|
||||
let process = self.vt().process.ok_or_else(|| ClapError::Process("plugin has no process".into()))?;
|
||||
self.raw.clear();
|
||||
for e in events.iter().take(self.raw.capacity()) {
|
||||
self.raw.push(RawEvent::from_event(e, offset));
|
||||
}
|
||||
inputs.refresh();
|
||||
outputs.refresh();
|
||||
let in_events = clap_input_events {
|
||||
ctx: (&self.raw as *const Vec<RawEvent>).cast_mut().cast::<c_void>(),
|
||||
size: Some(in_events_size),
|
||||
get: Some(in_events_get),
|
||||
};
|
||||
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: 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),
|
||||
audio_outputs_count: u32::try_from(outputs.bufs.len()).unwrap_or(0),
|
||||
in_events: &in_events,
|
||||
out_events: &out_events,
|
||||
};
|
||||
// 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 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()
|
||||
{
|
||||
// Never blocks or grows: a busy UI or a full queue drops the edits.
|
||||
for &e in &self.out_params {
|
||||
if edits.len() < edits.capacity() {
|
||||
edits.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.out_params.clear();
|
||||
if status == CLAP_PROCESS_ERROR { Err(ClapError::Process("plugin reported a processing error".into())) } else { Ok(status) }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ProcessInput<'a> {
|
||||
pub frames: usize,
|
||||
pub steady_time: i64,
|
||||
pub inputs: &'a mut PortBuffers,
|
||||
pub outputs: &'a mut PortBuffers,
|
||||
pub events: &'a [Event],
|
||||
pub offset: u32,
|
||||
pub transport: Option<&'a Transport>,
|
||||
}
|
||||
|
||||
impl Drop for Instance {
|
||||
fn drop(&mut self) {
|
||||
// Editors first (CLAP: destroy the GUI before the plugin), and no handle may call in after.
|
||||
self.gui.kill();
|
||||
self.deactivate();
|
||||
if self.created
|
||||
&& let Some(f) = self.vt().destroy
|
||||
{
|
||||
// SAFETY: valid instance, deactivated above; destroyed exactly once.
|
||||
unsafe { f(self.plugin) };
|
||||
}
|
||||
self.created = false;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//! plugins exactly like the built-in ones. Plugin ids are `clap:<CLAP plugin id>`; parameter ids
|
||||
//! are the CLAP param ids as decimal strings, with their human names in [`PluginInfo::params`].
|
||||
//!
|
||||
//! This is SoundCraft's single isolated `unsafe` crate (craftrules never-crash): `unsafe` is
|
||||
//! This is SoundCraft's single isolated `unsafe` crate: `unsafe` is
|
||||
//! denied crate-wide and allowed only in the private `ffi` module. The public API is safe, returns
|
||||
//! `Result`/`Option` and never panics. Limits of in-process hosting: a plugin that itself crashes
|
||||
//! (segfault) takes the process down with it; we cannot sandbox native code.
|
||||
@@ -88,7 +88,7 @@ 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()
|
||||
@@ -118,7 +118,7 @@ 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())
|
||||
@@ -222,10 +222,33 @@ mod tests {
|
||||
assert!(plugin_info("reverb").is_none());
|
||||
}
|
||||
|
||||
/// A new directory that this test alone created, readable only by its user on Unix. Creation
|
||||
/// fails if the path already exists: the test then loads native libraries from it, so it must
|
||||
/// never reuse a directory someone else could have put a library in. It lives next to the test
|
||||
/// binary (in the build directory), not under the temp dir: `TMPDIR`/`TEMP` can name a shared
|
||||
/// directory without a sticky bit or with inheritable write access, where another user could
|
||||
/// rename or populate it. Anyone who can write to the build directory can already replace the
|
||||
/// test binary itself, so this parent adds no new trust.
|
||||
fn fresh_private_dir(prefix: &str) -> std::path::PathBuf {
|
||||
let exe = std::env::current_exe().expect("test binary path");
|
||||
let parent = exe.parent().expect("test binary has a parent directory");
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |t| t.as_nanos());
|
||||
let d = parent.join(format!("{prefix}-{}-{nanos}", std::process::id()));
|
||||
#[cfg(unix)]
|
||||
let builder = {
|
||||
let mut b = std::fs::DirBuilder::new();
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
|
||||
b
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let builder = std::fs::DirBuilder::new();
|
||||
builder.create(&d).unwrap_or_else(|e| panic!("cannot create a fresh test directory {}: {e}", d.display()));
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_files_are_errors_not_crashes() {
|
||||
let d = std::env::temp_dir().join(format!("soundcraft-clap-garbage-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let d = fresh_private_dir("soundcraft-clap-garbage");
|
||||
std::fs::create_dir_all(d.join("Bundle.clap/Contents/MacOS")).unwrap();
|
||||
std::fs::write(d.join("garbage.clap"), b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
|
||||
std::fs::write(d.join("empty.clap"), b"").unwrap();
|
||||
@@ -239,4 +262,17 @@ mod tests {
|
||||
assert!(instantiate("clap:does.not.exist").is_err());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A binary the system refuses to load reports the system's reason (missing library, wrong
|
||||
/// glibc, not an ELF file), not just "dlopen failed".
|
||||
#[test]
|
||||
fn a_binary_that_does_not_load_says_why() {
|
||||
let d = fresh_private_dir("soundcraft-clap-reason");
|
||||
let f = d.join("garbage.clap");
|
||||
std::fs::write(&f, b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
|
||||
let msg = load_bundle(&f).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
let reason = msg.split_once("failed: ").map_or("", |(_, r)| r.trim());
|
||||
assert!(!reason.is_empty(), "no reason in: {msg}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user